Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton aa6297f7ae fix(tui): preserve multi-click text selection 2026-08-26 10:57:09 -04:00
112 changed files with 1574 additions and 5750 deletions
+10 -11
View File
@@ -125,7 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -350,6 +350,7 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -364,7 +365,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -731,7 +732,6 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -837,7 +837,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@playwright/test": "catalog:",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "10.4.4",
"@storybook/addon-docs": "10.4.4",
@@ -2158,19 +2157,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.10", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.10", "@opencode-ai/pty-darwin-x64": "0.1.10", "@opencode-ai/pty-linux-arm64-gnu": "0.1.10", "@opencode-ai/pty-linux-arm64-musl": "0.1.10", "@opencode-ai/pty-linux-x64-gnu": "0.1.10", "@opencode-ai/pty-linux-x64-musl": "0.1.10" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-cEJT1ADtmnb+df2wrlUcsGny6Q7pTe9Sa7keISzCO0xN1FrL1aS6+eleBPpDimHjgM/sXqvLwJv0UiAeiAvgxQ=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.9", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.9", "@opencode-ai/pty-darwin-x64": "0.1.9", "@opencode-ai/pty-linux-arm64-gnu": "0.1.9", "@opencode-ai/pty-linux-arm64-musl": "0.1.9", "@opencode-ai/pty-linux-x64-gnu": "0.1.9", "@opencode-ai/pty-linux-x64-musl": "0.1.9" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-9WysQgX9J3RXfZy/t/8MGqf1IGLeckyHQsT/eVnKiBnp+GnzOXtZePryj08CN/3GT2BuP5tWqMRMH0JzCMsrgg=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-j7aszDFRwCIazGUT9eIy4PZwh4rltjvRmoicPRTK3kONN3v0MMflstkmAFDYYpqDPTNh3qJ6xkQmB+DugEbhAg=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-a2OZGutBdVGDO+X4t37L2K8wD1phTcLJBcNLj8j3LqCmaHbJmQyNYYFo6i8loZCTjjhb+8Wq0wMxSLny4tqdwA=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-UAMP/E4lo9RGQF7xrfIwpW2ZEemj308rCogJy14ruKYJt5MwHeGNTynGiHE/1JlDLRy+21wV50jpugADgT71ag=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-+vyRLwzNMzP/JFtYEIkMHxRC9Lkd7sUeuAqI5medtdgZmvTwS3NdkHbq6zQQG3rTQ9asObPKilvcpQDf+LYHRQ=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-lTPlZNQ66koFHZqoPmvvq0SetlepKVQYgnLryhlVfYtcryWDJM7gV4+P66V12RwqWQTjt2u8j12mtg3axSKg2w=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vjNKhCsw6mI6w+9bITCyCmb5nS+XiWA8xMGn02GOKE9AahcVX9JIbjUF1zAmPXZ1QD7nxWoanh4FvnqukbYPHw=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-IDmWHRylMR/ZfMw9/AAktO/Edi4TITPC+Tq7Xx3JZHsDgSba3QdyE11uNL0zM1myTGdk6Yrt4rpdAzaItPnDjw=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-xpS0N6/uEiJPabv6Ib9BpOlfyZdcUES7sMVa4bCrgcxy6y4bnQMeGF/Ju4u7LBABD/rjBKZM0XhPMnvZ6qoX1w=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Q1yob0/8X2JoJZzFmNKUc32XDRAe0avKQ8PLKkpJr30qWXSrGmhltgcDmn94Q70zW9Ght9on84T7cmge9brvdQ=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-N1Dx8rOLkpJd2DSetZZW9dPnpL3mxbRQagk/7K7TbS7M1rDnLazhq8/T9vulLET8KTwu6lmWcZYuSDfPLNWXnA=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7RLHWQxX/wfUKJJP2ZMMtkXaPsrgoMNKzE6PL/LbnYbMBtkqfld9EDcMv1RFZ0CqjNFgI0Hg4eRk6x+ZNc/wyQ=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-osb203LrlwXpQKQABrCfD5QRVK9Ajiq5atulC/VuMye6KkErcetDnjQvobjhaEjgnSEGPCH4U9sM84okGqip5Q=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2IiQK8EPwoDber8epX9NlZhaQwvDyN17nTMSPEjtk4s=",
"aarch64-linux": "sha256-/ggedGSQ2RWbhX/CahTpLz1TLqlf+hu95u1ys9F/CdI=",
"aarch64-darwin": "sha256-RaEiXwQEfpFiehG4m3IMulopP1b54PtrAECY+e2aJ6Q=",
"x86_64-darwin": "sha256-MANNi8GyHkvJzU7Xw6FQJHsx3fBCFJfGqlSOY9zez7k="
"x86_64-linux": "sha256-QWLIdvu985FH5I9cZJOAuoeFeXU+4Jx9RzBB9RPoeeQ=",
"aarch64-linux": "sha256-SSzGD5hMj2vFvyw+dUPR9g/ZH6qhs0ZyZ/DnltZt3N8=",
"aarch64-darwin": "sha256-CeFUxiV+e8pKho+YcSclC3soQBogoxNMxwyIMztAExU=",
"x86_64-darwin": "sha256-FYwcACzU72y0+KtOpFfU7ndak8vMasqMgd5NLS6+XtY="
}
}
+21 -23
View File
@@ -346,8 +346,7 @@ export const Event = Schema.StructWithRest(
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
// OutputItemAdded/Done permit a null item in the Open Responses OpenAPI schema.
item: optionalNull(StreamItem),
item: Schema.optional(StreamItem),
response: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
@@ -813,7 +812,7 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string"
item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -892,7 +891,7 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
if (item?.type === "message" && item.id) {
const phase = messagePhase(item.phase)
return [
{
@@ -923,7 +922,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const metadata = item.id ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -942,7 +941,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
@@ -989,7 +988,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
return [
@@ -1014,7 +1013,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
state: ParserState,
event: Event,
) {
if (event.item_id === undefined) return [state, NO_EVENTS] satisfies StepResult
if (!event.item_id) return [state, NO_EVENTS] satisfies StepResult
const tool = state.tools[event.item_id]
if (!tool) return [state, NO_EVENTS] satisfies StepResult
const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined
@@ -1045,7 +1044,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
if (item.type === "message" && item.id) {
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
@@ -1076,7 +1075,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name,
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
providerMetadata: item.id ? providerMetadata(state, { itemId: item.id }) : undefined,
})
const result =
item.arguments === undefined
@@ -1137,7 +1136,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
([current, events], item) => {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (
id === undefined ||
!id ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]))
)
@@ -1220,13 +1219,12 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
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.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`)
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
event.type === "response.output_text.delta"
? onOutputTextDelta(state, event, event.item_id)
@@ -1235,7 +1233,7 @@ export const step = (state: ParserState, input: Event) => {
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
if (event.item_id === undefined || typeof value !== "string")
if (!event.item_id || typeof value !== "string")
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
@@ -1244,7 +1242,7 @@ export const step = (state: ParserState, input: Event) => {
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (
@@ -1252,24 +1250,24 @@ export const step = (state: ParserState, input: Event) => {
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_text.done"
) {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id !== undefined
return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_summary_part.done")
return event.item_id !== undefined
return event.item_id
? 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)
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id !== undefined
event.output_index !== undefined && id
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
@@ -1277,11 +1275,11 @@ export const step = (state: ParserState, input: Event) => {
)
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
return event.item_id !== undefined
return event.item_id
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
@@ -184,7 +184,7 @@ const HOSTED_TOOLS = {
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
return event.item_id
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
-116
View File
@@ -1,116 +0,0 @@
import { Effect, Schema } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { ProviderShared } from "../protocols/shared.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("groq")
export type ProviderOptions = Pick<OpenAIProviderOptionsInput, "reasoningEffort"> & {
/** Controls visible reasoning on GPT-OSS; other models always use parsed reasoning. */
readonly includeReasoning?: boolean
readonly parallelToolCalls?: boolean
readonly serviceTier?: "on_demand" | "flex" | "auto" | "performance" | (string & {})
readonly user?: string
}
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
const Options = Schema.Struct({
includeReasoning: Schema.optional(Schema.Boolean),
parallelToolCalls: Schema.optional(Schema.Boolean),
serviceTier: Schema.optional(Schema.String),
user: Schema.optional(Schema.String),
})
export const protocol = Protocol.make({
id: "groq-chat",
body: {
schema: Schema.Struct({
...OpenAIChat.bodyFields,
reasoning_format: Schema.optional(Schema.Literal("parsed")),
include_reasoning: Schema.optional(Schema.Boolean),
parallel_tool_calls: Schema.optional(Schema.Boolean),
service_tier: Schema.optional(Schema.String),
user: Schema.optional(Schema.String),
}),
from: Effect.fn("Groq.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(
request.providerOptions ?? {},
)
const gptOSS = request.model.id.startsWith("openai/gpt-oss-")
return {
...(yield* OpenAIChat.fromRequest(request)),
reasoning_format: gptOSS ? undefined : ("parsed" as const),
include_reasoning: gptOSS ? options.includeReasoning : undefined,
parallel_tool_calls: options.parallelToolCalls,
service_tier: options.serviceTier,
user: options.user,
}
}),
},
stream: OpenAIChat.protocol.stream,
})
export const route = Route.make({
id: "groq-chat",
provider: id,
providerMetadataKey: "openai",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
framing: Framing.sse,
})
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.groq.baseURL },
auth: AuthOptions.bearer(input, "GROQ_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<ProviderOptions>({
id: modelID,
compatibility: {
maxTokensField: "max_completion_tokens",
reasoningField: "reasoning",
requireReasoning: false,
supportsStore: false,
supportsStrictMode: false,
},
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Groq from "./groq.js"
-1
View File
@@ -12,7 +12,6 @@ export * as GoogleVertex from "./google-vertex.js"
export * as GoogleVertexChat from "./google-vertex-chat.js"
export * as GoogleVertexMessages from "./google-vertex-messages.js"
export * as GoogleVertexResponses from "./google-vertex-responses.js"
export * as Groq from "./groq.js"
export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"model": "openai/gpt-oss-20b",
"tags": ["prefix:groq-chat", "provider:groq", "protocol:groq-chat", "text", "usage"],
"name": "groq-chat/streams-text-with-usage",
"recordedAt": "2026-08-26T14:40:09.833Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.groq.com/openai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-oss-20b\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"low\",\"max_completion_tokens\":512,\"include_reasoning\":false,\"service_tier\":\"on_demand\",\"user\":\"recorded-test\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"seed\":94036161}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}},\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[],\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -29,7 +29,6 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/togetherai"),
import("@opencode-ai/ai/providers/cerebras"),
import("@opencode-ai/ai/providers/deepinfra"),
import("@opencode-ai/ai/providers/groq"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -1,185 +0,0 @@
import { configure } from "@opencode-ai/ai/providers/groq"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, LLMResponse, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.GROQ_API_KEY ?? "fixture"
const recorded = recordedTests({
prefix: "groq-chat",
provider: "groq",
protocol: "groq-chat",
requires: ["GROQ_API_KEY"],
})
const weather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris", "London"] } },
required: ["city"],
additionalProperties: false,
},
})
describe("Groq recorded", () => {
recorded.effect.with(
"streams text with usage",
{ tags: ["text", "usage"], metadata: { model: "openai/gpt-oss-20b" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: configure({
apiKey,
providerOptions: {
includeReasoning: false,
reasoningEffort: "low",
serviceTier: "on_demand",
user: "recorded-test",
},
}).model("openai/gpt-oss-20b"),
prompt: "Reply with exactly one word: hello",
generation: { maxTokens: 512 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body).toMatchObject({
max_completion_tokens: 512,
stream_options: { include_usage: true },
include_reasoning: false,
service_tier: "on_demand",
user: "recorded-test",
})
expect(compiled.body.max_tokens).toBeUndefined()
expect(compiled.body.store).toBeUndefined()
expect(compiled.body.reasoning_format).toBeUndefined()
const response = yield* LLMClient.generate(request)
expect(response.text.toLowerCase().trim()).toBe("hello")
expect(response.reasoning).toBe("")
expect(response.events.some(LLMEvent.is.textDelta)).toBe(true)
expectUsage(response)
}),
60_000,
)
for (const item of [
{
name: "continues Qwen parallel tool calls",
model: configure({ apiKey, providerOptions: { parallelToolCalls: true, reasoningEffort: "none" } }).model(
"qwen/qwen3.6-27b",
),
cities: ["Paris", "London"],
reasoning: false,
},
{
name: "replays GPT OSS reasoning through a tool loop",
model: configure({ apiKey, providerOptions: { includeReasoning: true, reasoningEffort: "low" } }).model(
"openai/gpt-oss-20b",
),
cities: ["Paris"],
reasoning: true,
},
]) {
recorded.effect.with(
item.name,
{
tags: ["tool", "tool-loop", "usage", item.reasoning ? "reasoning" : "parallel"],
metadata: { model: item.model.id },
},
() =>
Effect.gen(function* () {
const request = LLM.request({
model: item.model,
prompt: `Look up the current weather in ${item.cities.join(" and ")}. Call lookup_weather once for each city in the same response before answering. After receiving all results, report each city's weather in one short sentence.`,
tools: [weather],
toolChoice: "required",
generation: { maxTokens: 1536 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.stream_options).toEqual({ include_usage: true })
expect(compiled.body.store).toBeUndefined()
expect(compiled.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
expect(compiled.body.tools[0].function.strict).toBeUndefined()
if (!item.reasoning) expect(compiled.body.parallel_tool_calls).toBe(true)
const first = yield* LLMClient.generate(request)
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.toolCalls).toHaveLength(item.cities.length)
expect(new Set(first.toolCalls.map((call) => call.id)).size).toBe(item.cities.length)
expect(first.toolCalls.map((call) => call.input)).toEqual(
expect.arrayContaining(item.cities.map((city) => ({ city }))),
)
expect(first.toolCalls.every((call) => call.name === "lookup_weather")).toBe(true)
expectUsage(first)
if (item.reasoning) {
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
}
const followUp = LLMRequest.update(request, {
toolChoice: ToolChoice.make("none"),
messages: [
...request.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
),
],
})
const replay = yield* compileRequest(followUp)
if (item.reasoning) {
expect(replay.body.messages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "assistant", reasoning: first.reasoning })]),
)
}
expect(replay.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
const second = yield* LLMClient.generate(followUp)
expect(second.finishReason.normalized).toBe("stop")
expect(second.toolCalls).toHaveLength(0)
expect(second.text.toLowerCase()).toContain("sunny")
item.cities.forEach((city) => expect(second.text).toContain(city))
expectUsage(second)
}),
60_000,
)
}
recorded.effect.with(
"streams Qwen parsed reasoning",
{ tags: ["reasoning", "usage"], metadata: { model: "qwen/qwen3.6-27b" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: configure({
apiKey,
providerOptions: { reasoningEffort: "default" },
}).model("qwen/qwen3.6-27b"),
prompt:
"What is 173 multiplied by 219? Think through the arithmetic, then reply with only the final integer.",
generation: { maxTokens: 2048 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body).toMatchObject({ reasoning_format: "parsed", reasoning_effort: "default" })
expect(compiled.body.include_reasoning).toBeUndefined()
const response = yield* LLMClient.generate(request)
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.text).not.toContain("<think>")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
expectUsage(response)
}),
60_000,
)
})
function expectUsage(response: LLMResponse) {
expect(response.usage).toBeDefined()
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}
-112
View File
@@ -1,112 +0,0 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LanguageModel, LLM, Message } from "../../src/index.js"
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
import { Groq } from "../../src/providers/groq.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { weatherTool } from "../recorded-scenarios.js"
it.effect("Groq reuses Chat streaming and defaults to parsed reasoning", () =>
Effect.gen(function* () {
expect(Groq.protocol.stream).toBe(OpenAIChat.protocol.stream)
const model = Groq.configure({ apiKey: "fixture" }).model("llama-3.3-70b-versatile")
expect(model.route.endpoint.baseURL).toBe("https://api.groq.com/openai/v1")
const compiled = yield* compileRequest(
LLM.request({ model, prompt: "Hello", tools: [weatherTool], generation: { maxTokens: 64 } }),
)
expect(compiled.body).toMatchObject({
max_completion_tokens: 64,
stream_options: { include_usage: true },
reasoning_format: "parsed",
})
for (const key of ["store", "max_tokens", "include_reasoning", "parallel_tool_calls", "service_tier", "user"])
expect(compiled.body[key]).toBeUndefined()
expect(compiled.body.tools?.[0]?.function).not.toHaveProperty("strict")
}),
)
it.effect("Groq lowers its own options for custom catalog identities and endpoints", () =>
Effect.gen(function* () {
const model = LanguageModel.update(
Groq.model("qwen/qwen3.6-27b", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-client": "test" },
body: { custom: "value" },
providerOptions: {
reasoningEffort: "default",
parallelToolCalls: true,
serviceTier: "flex",
user: "test-user",
},
}),
{ provider: "custom-groq" },
)
const compiled = yield* compileRequest(
LLM.request({ model, prompt: "Hello", providerOptions: { parallelToolCalls: false, includeReasoning: false } }),
)
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
expect(model.route.defaults.http?.body).toEqual({ custom: "value" })
expect(compiled.body).toMatchObject({
reasoning_effort: "default",
reasoning_format: "parsed",
parallel_tool_calls: false,
service_tier: "flex",
user: "test-user",
})
expect(compiled.body.include_reasoning).toBeUndefined()
for (const key of ["reasoningFormat", "reasoningEffort", "parallelToolCalls", "serviceTier"])
expect(compiled.body).not.toHaveProperty(key)
}),
)
it.effect("Groq replays reasoning only when present and preserves explicit reasoning exclusion", () =>
Effect.gen(function* () {
const compiled = yield* compileRequest(
LLM.request({
model: Groq.configure({ apiKey: "fixture" }).model("openai/gpt-oss-20b"),
messages: [
Message.user("Think"),
Message.assistant([
{ type: "reasoning", text: "Thinking" },
{ type: "text", text: "Answer" },
]),
Message.user("Again"),
Message.assistant("Answer only"),
Message.user("Continue"),
],
providerOptions: { reasoningEffort: "low", includeReasoning: false },
}),
)
expect(compiled.body).toMatchObject({ reasoning_effort: "low", include_reasoning: false })
expect(compiled.body.reasoning_format).toBeUndefined()
expect(compiled.body.messages[1]).toMatchObject({ reasoning: "Thinking", content: "Answer" })
expect(compiled.body.messages[1]).not.toHaveProperty("reasoning_content")
expect(compiled.body.messages[3]).not.toHaveProperty("reasoning")
}),
)
it.effect("Groq omits reasoning_format for the GPT-OSS family by default", () =>
Effect.gen(function* () {
for (const id of ["openai/gpt-oss-20b", "openai/gpt-oss-120b", "openai/gpt-oss-safeguard-20b"]) {
const compiled = yield* compileRequest(
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model(id), prompt: "Hello" }),
)
expect(compiled.body.reasoning_format).toBeUndefined()
expect(compiled.body.include_reasoning).toBeUndefined()
}
}),
)
it.effect("Groq validates option types", () =>
Effect.gen(function* () {
for (const providerOptions of [{ includeReasoning: "false" }, { parallelToolCalls: "false" }]) {
const error = yield* compileRequest(
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model("qwen"), prompt: "Hello", providerOptions }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}
}),
)
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
import { Cerebras, DeepInfra, Groq, TogetherAI } from "../../src/providers/index.js"
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
@@ -155,12 +155,6 @@ describe("native OpenAI-compatible providers", () => {
token: "deepinfra-secret",
url: "https://api.deepinfra.com/v1/openai/chat/completions",
},
{
model: Groq.configure().model("llama"),
env: { GROQ_API_KEY: "groq-secret" },
token: "groq-secret",
url: "https://api.groq.com/openai/v1/chat/completions",
},
]
yield* Effect.forEach(scenarios, (scenario) =>
@@ -307,287 +307,6 @@ describe("Open Responses-compatible route", () => {
}),
)
describe("stream validation", () => {
const request = LLM.request({
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
prompt: "Respond.",
})
const fixtures = [
{
item: { type: "message" },
events: [
{ type: "response.output_text.delta", delta: "Preserved" },
{ type: "response.output_text.done", text: "Preserved" },
{ type: "response.refusal.delta", delta: "Preserved" },
{ type: "response.refusal.done", refusal: "Preserved" },
],
},
{
item: { type: "reasoning", encrypted_content: "encrypted-state" },
events: [
{ type: "response.reasoning.delta", delta: "Preserved" },
{ type: "response.reasoning.done", text: "Preserved" },
{ type: "response.reasoning_summary_text.delta", delta: "Preserved" },
{ type: "response.reasoning_summary_text.done", text: "Preserved" },
{ type: "response.reasoning_text.done", text: "Preserved" },
],
},
{
item: { type: "function_call", call_id: "call_1", name: "lookup" },
events: [
{ type: "response.function_call_arguments.delta", delta: '{"query":"Preserved"}' },
{ type: "response.function_call_arguments.done", arguments: '{"query":"Preserved"}' },
],
},
]
const routings = [
{ name: "empty item and event IDs", id: "", item_id: "" },
{ name: "empty event ID with registered index", id: "item_1", item_id: "", output_index: 2 },
{ name: "empty stored ID with registered index", id: "", item_id: "wrong_item", output_index: 2 },
{ name: "empty item and event IDs with registered index", id: "", item_id: "", output_index: 2 },
]
fixtures.forEach((fixture) => {
fixture.events.forEach((event) => {
routings.forEach((routing) => {
it.effect(`${event.type} preserves content with ${routing.name}`, () =>
Effect.gen(function* () {
const item = { ...fixture.item, id: routing.id }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: routing.output_index, item },
{ ...event, item_id: routing.item_id, output_index: routing.output_index },
{ type: "response.output_item.done", output_index: routing.output_index, item },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
const metadata = { openresponses: { itemId: routing.id } }
if (fixture.item.type === "function_call") {
expect(response.toolCalls).toEqual([
expect.objectContaining({
id: "call_1",
name: "lookup",
input: { query: "Preserved" },
providerMetadata: metadata,
}),
])
return
}
if (fixture.item.type === "reasoning") {
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Preserved",
providerMetadata: {
openresponses: { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
},
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
return
}
expect(response.message.content).toEqual([
{ type: "text", text: "Preserved", providerMetadata: metadata },
])
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
expect.objectContaining({ id: routing.id, providerMetadata: metadata }),
])
}),
)
})
})
})
routings.forEach((routing) => {
it.effect(`preserves reasoning summary boundaries and terminal metadata with ${routing.name}`, () =>
Effect.gen(function* () {
const address = { item_id: routing.item_id, output_index: routing.output_index }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: routing.output_index,
item: { type: "reasoning", id: routing.id },
},
{ type: "response.reasoning_summary_part.added", ...address, summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", ...address, summary_index: 0, delta: "First." },
{ type: "response.reasoning_summary_text.done", ...address, summary_index: 0, text: "First." },
{ type: "response.reasoning_summary_part.done", ...address, summary_index: 0 },
{ type: "response.reasoning_summary_part.added", ...address, summary_index: 1 },
{ type: "response.reasoning_summary_text.done", ...address, summary_index: 1, text: "Second." },
{ type: "response.reasoning_summary_part.done", ...address, summary_index: 1 },
{
type: "response.completed",
response: { output: [{ type: "reasoning", id: routing.id, encrypted_content: "final-state" }] },
},
),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "First.",
providerMetadata: { openresponses: { itemId: routing.id } },
},
{
type: "reasoning",
text: "Second.",
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
expect.objectContaining({
id: `${routing.id}:0`,
providerMetadata: { openresponses: { itemId: routing.id } },
}),
expect.objectContaining({
id: `${routing.id}:1`,
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
}),
])
}),
)
})
it.effect("reconciles pending empty-ID function arguments from completed output", () =>
Effect.gen(function* () {
const item = { type: "function_call", id: "", call_id: "call_1", name: "lookup" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item },
{ type: "response.function_call_arguments.delta", item_id: "", delta: '{"query":"partial' },
{
type: "response.completed",
response: { output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.toolCalls).toEqual([
expect.objectContaining({
id: "call_1",
name: "lookup",
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "" } },
}),
])
}),
)
it.effect("treats null output items as no-ops without disturbing registered items", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: null },
{ type: "response.output_item.done", output_index: 0, item: null },
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 0, item_id: "wrong_item", delta: "Before " },
{ type: "response.output_item.added", output_index: 0, item: null },
{ type: "response.output_item.done", output_index: 0, item: null },
{ type: "response.output_text.delta", output_index: 0, item_id: "wrong_item", delta: "after" },
{ type: "response.output_item.done", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "text", text: "Before after", providerMetadata: { openresponses: { itemId: "msg_1" } } },
])
expect(response.events.map((event) => event.type)).toEqual([
"step-start",
"text-start",
"text-delta",
"text-delta",
"text-end",
"step-finish",
"finish",
])
}),
)
it.effect("rejects missing, null, and non-string event IDs even with a registered output index", () =>
Effect.gen(function* () {
yield* Effect.forEach(
[
...fixtures.flatMap((fixture) => fixture.events.map((event) => ({ item: fixture.item, event }))),
...["response.reasoning_summary_part.added", "response.reasoning_summary_part.done"].map((type) => ({
item: { type: "reasoning" },
event: { type, summary_index: 0 },
})),
],
(fixture) =>
Effect.forEach([undefined, null, 0, false, {}, []], (item_id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { ...fixture.item, id: "item_1" },
},
{ ...fixture.event, output_index: 0, item_id },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
)
}),
)
it.effect("keeps malformed output item IDs invalid", () =>
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
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")
}),
),
),
)
}),
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
@@ -2229,35 +2229,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("accepts empty IDs for native reasoning text deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "" } },
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "", delta: "Raw" },
{
type: "response.output_item.done",
output_index: 1,
item: { type: "reasoning", id: "", encrypted_content: "state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Raw",
providerMetadata: { openai: { itemId: "", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("falls back to item ids when an output index was not registered", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
-2
View File
@@ -1,5 +1,3 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report
component-tests/test-results
component-tests/playwright-report
@@ -1,26 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
story("shows the thinking level control while relevant", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const composer = component.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await expect(control).toBeVisible()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(high).toBeVisible()
await high.click()
await input.focus()
await expect(control).toBeVisible()
await input.blur()
await expect(control).toBeVisible()
})
@@ -26,13 +26,10 @@ test("session settings use the remote server context", async ({ page }) => {
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
const sessionHeading = page.getByRole("heading", { name: sessionB.title, exact: true, includeHidden: true })
await expect(sessionHeading).toBeVisible()
const activeTabs = page.locator('[data-titlebar-tab-slot][data-active="true"]')
await expect(activeTabs).toHaveCount(1)
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(activeTabs).toHaveCount(0)
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeAttached()
@@ -70,49 +67,6 @@ test("session settings use the remote server context", async ({ page }) => {
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(sessionHeading).toBeVisible()
await expect(activeTabs).toHaveCount(1)
await page.keyboard.press("Control+,")
await expect(settings).toBeVisible()
await page.locator(`[data-titlebar-tab-slot]:has(a[href$="/session/${sessionB.id}"])`).click()
await expect(settings).toBeHidden()
await expect(sessionHeading).toBeVisible()
await expect(activeTabs).toHaveCount(1)
})
test("new session works while draft settings are open", async ({ page }) => {
await installSseTransport(page, { server: serverA })
await installSseTransport(page, { server: serverB })
await mockServers(page, [])
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
const newSession = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "New session", exact: true })
await newSession.click()
await expect(page).toHaveURL((url) => url.pathname === "/new-session" && !!url.searchParams.get("draftId"))
const draftURL = page.url()
const editor = page.locator('[data-component="composer-editor"][contenteditable="true"]')
await expect(editor).toBeEditable()
await editor.fill("Keep the original draft")
const tabs = page.locator("[data-titlebar-tab-slot]")
const activeTabs = page.locator('[data-titlebar-tab-slot][data-active="true"]')
await expect(tabs).toHaveCount(2)
await expect(activeTabs).toHaveCount(1)
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(activeTabs).toHaveCount(0)
await newSession.click()
await expect(page).toHaveURL(
(url) => url.pathname === "/new-session" && !!url.searchParams.get("draftId") && url.href !== draftURL,
)
await expect(settings).toBeHidden()
await expect(tabs).toHaveCount(3)
await expect(activeTabs).toHaveCount(1)
await expect(activeTabs.locator("a")).toHaveAttribute("href", `/new-session${new URL(page.url()).search}`)
await expect(editor).toBeEditable()
await expect(editor).toBeEmpty()
})
test("auto-accept responds for an unfocused server session", async ({ page }) => {
@@ -431,10 +385,9 @@ async function mockServers(
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
return json(route, { data: [] })
if (url.pathname === "/api/location")
return json(route, { directory, project: { id: remote ? sessionB.projectID : "project-server-a" } })
if (url.pathname === "/api/location") return json(route, { directory })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: { current: "main", default: "main" } } })
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
return json(route, {})
})
@@ -12,6 +12,54 @@ test.beforeEach(async ({ page }) => {
await openReview(page)
})
test("opens the comment editor when code is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const value = 'after'", { exact: true })
await expectAppVisible(line)
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("opens the comment editor for a line number range", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const start = review.locator('[data-column-number="1"]').last()
const end = review.locator('[data-column-number="3"]').last()
await expectAppVisible(start)
await expectAppVisible(end)
await start.dragTo(end)
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a diff line is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const first = 1", { exact: true })
await expectAppVisible(line)
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("stages a submitted line comment in the prompt context", async ({ page }) => {
page.on("request", (request) => {
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
@@ -201,15 +201,12 @@ test("editing restores the existing draft and replaces only the original queue p
await view.input.fill("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await expect(view.input).toBeFocused()
await view.input.press("Escape")
await expect(view.input).toHaveText("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await expect(view.input).toBeFocused()
await view.input.fill("tighten the error copy and add a retry hint")
await expect(view.input).toHaveText("tighten the error copy and add a retry hint")
await view.input.press("Enter")
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
@@ -1,43 +1,16 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
const shellID = "prt_space_button_shell"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
shell(shellID, "completed", lines(5)),
textPart(
"prt_space_following",
"Following content leaves room to focus the command away from the bottom. ".repeat(40),
),
]),
],
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
settings: { shellToolPartsExpanded: false },
reducedMotion: true,
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.getByRole("button", { name: "Used Shell" })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(300)
await trigger.scrollIntoViewIfNeeded()
await scroller.hover()
await page.mouse.wheel(0, -100)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(50)
await expect(trigger).toBeInViewport()
await trigger.focus()
await expect(trigger).toBeFocused()
const before = await scroller.evaluate((element) => element.scrollTop)
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
@@ -8,6 +8,21 @@ import {
userText,
} from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
const id = "prt_file_projection_write"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
]),
],
settings: { editToolPartsExpanded: true },
})
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
})
test("renders a completed single-file patch", async ({ page }) => {
const id = "prt_file_projection_single_patch"
await setupTimeline(page, {
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("keeps patch file disclosures independent", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update three files" },
{ metadata: { files } },
),
]),
],
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await modified.getByRole("button").click()
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
}
}
function source(changed: boolean) {
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
"",
)
}
@@ -122,7 +122,6 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
@@ -130,7 +129,6 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
await timeline.send(status("idle"), 300)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
})
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
@@ -122,7 +122,6 @@ test("updates running compactions to failed and cancelled boundaries", async ({
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
await expect(page.getByText("Partial summary that should be discarded.", { exact: true })).toBeVisible()
await timeline.send(
compactionFailed({
sessionID,
@@ -141,9 +140,6 @@ test("updates running compactions to failed and cancelled boundaries", async ({
await expect(failed).not.toContainText("Partial summary that should be discarded.")
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
await expect(compactions).toHaveCount(2)
await timeline.send(compactionDelta({ sessionID, text: "Summary before cancellation." }))
await expect(page.getByText("Summary before cancellation.", { exact: true })).toBeVisible()
await timeline.send(
compactionFailed({
sessionID,
@@ -156,7 +152,88 @@ test("updates running compactions to failed and cancelled boundaries", async ({
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
await expect(cancelled).not.toContainText("Summary before cancellation.")
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
],
},
],
})
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
await setupTimeline(page, {
sessionMessages: [
user,
{
id: "msg_location",
type: "location-switched",
location: { directory },
time: { created: 2 },
},
],
})
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
@@ -194,6 +271,11 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await request
})
test("waits for completion before labeling requested background work", async ({ page }) => {
await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] })
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
})
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
@@ -7,9 +7,86 @@ import {
toolPart,
userMessage,
userText,
type PartSeed,
} from "../performance/timeline-stability/fixture"
test.describe("session timeline projection", () => {
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
const parts = [
toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
toolPart("prt_04_list", "list", "completed", { path: "src" }),
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
toolPart(
"prt_websearch",
"websearch",
"completed",
{ query: "timeline stability" },
{ output: "https://example.com/result" },
),
toolPart("prt_task", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
toolPart(
"prt_bash",
"shell",
"completed",
{ command: "printf stable" },
{ output: "stable", title: "printf stable" },
),
editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart(
"prt_question",
"question",
"completed",
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
{ metadata: { answers: [["Yes"]] } },
),
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const first = page.locator(
'[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]',
)
const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]')
await expect(first).toBeVisible()
await expect(second).toBeVisible()
await first.getByRole("button").click()
await second.getByRole("button").click()
for (const id of [
"prt_webfetch",
"prt_websearch",
"prt_task",
"prt_bash",
"prt_edit",
"prt_write",
"prt_patch",
"prt_question",
"prt_skill",
"prt_custom",
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
await expect(edit).toContainText("Edit")
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
const first = "prt_patch_first"
const second = "prt_patch_second"
@@ -81,6 +158,43 @@ test.describe("session timeline projection", () => {
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
})
test("combines adjacent edit calls and repeated files into one group", async ({ page }) => {
const first = "prt_edit_first"
const second = "prt_edit_second"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
first,
"edit",
"completed",
{ path: "src/first.ts", oldString: "one", newString: "two" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
toolPart(
second,
"edit",
"completed",
{ path: "src/first.ts", oldString: "two", newString: "three" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
]),
],
settings: { editToolPartsExpanded: true },
})
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
const firstUser = userMessage(
[
@@ -122,6 +236,25 @@ test.describe("session timeline projection", () => {
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
})
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
const user = userMessage()
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
id: "msg_1001_before",
error: { type: "MessageAbortedError", message: "Stopped" },
})
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
id: "msg_1002_after",
created: 1700000003000,
})
await setupTimeline(page, { messages: [user, before, after] })
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
const rows = await page
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
@@ -158,8 +291,77 @@ test.describe("session timeline projection", () => {
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
userText(text, { id: "prt_user_rich" }),
{
id: "prt_user_image",
type: "file",
mime: "image/png",
filename: "pixel.png",
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
{
id: "prt_user_attachment",
type: "file",
mime: "application/json",
filename: "tsconfig.json",
url: "data:application/json;base64,e30=",
},
{
id: "prt_user_reference",
type: "file",
mime: "text/plain",
filename: "a.ts",
url: "src/a.ts",
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
},
{
id: "prt_user_agent",
type: "agent",
name: "explore",
source: { value: "@explore", start: 4, end: 12 },
},
]
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
await expect(page.getByAltText("pixel.png")).toBeVisible()
await expect(page.getByText("tsconfig.json")).toBeVisible()
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
})
})
function editPart(id: string) {
return toolPart(
id,
"edit",
"completed",
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
},
},
)
}
function patchPart(id: string) {
return toolPart(
id,
"patch",
"completed",
{ patchText: "Update the projected files" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
},
},
)
}
function patchFile(file: string, status: "added" | "modified" | "deleted") {
return {
file,
@@ -7,25 +7,33 @@ import {
renderedPartID,
setupTimeline,
shell,
toolPart,
status,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
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"), 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)
test("groups every collapsed tool until visible text separates the stack", async ({ page }) => {
const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"),
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
shell("prt_boundary_05_shell", "completed", "done"),
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
"Final after early idle",
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
const group = page.locator(
'[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]',
)
await expect(group).toBeVisible()
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
})
test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => {
@@ -124,3 +132,18 @@ test("keeps failed search calls and their error cards inside the collapsed stack
"Search timed out after 30 seconds",
)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
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"), 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(
"Final after early idle",
)
})
@@ -7,6 +7,31 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
)
parts.push(
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
error: "The user dismissed this question",
}),
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
)
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length))
await group.getByRole("button").click()
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
await expect(page.getByText(/dismissed/i)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
for (let index = 0; index < ordinary.length; index++) {
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
}
})
test("transitions shell and question through running error outcomes", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
@@ -113,6 +138,62 @@ test("preserves surviving grouped patch state when its first patch fails", async
.toBeGreaterThanOrEqual(-0.5)
})
test("labels all web search provider variants", async ({ page }) => {
const parts = [
toolPart(
"prt_search_parallel",
"websearch",
"completed",
{ query: "parallel" },
{ metadata: { provider: "parallel" } },
),
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
const tools = page.locator('[data-component="context-tool-group-list"]')
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
test("labels completed searches with result counts", async ({ page }) => {
const glob = "prt_glob_count"
const grep = "prt_grep_count"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(glob, "glob", "completed", { path: ".", pattern: "**/*.ts" }, { metadata: { count: 1 } }),
toolPart(grep, "grep", "completed", { path: ".", pattern: "value" }, { metadata: { matches: 12 } }),
]),
],
})
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
test("labels read tools from their path input", async ({ page }) => {
const id = "prt_read_path"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
})
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
@@ -140,6 +221,36 @@ test("groups instruction files loaded by the same read", async ({ page }) => {
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveCount(0)
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "frontend-design" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`)
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
const names = loaded.locator('[data-component="text-shimmer"]')
await expect(names).toHaveCount(2)
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
})
test("groups only consecutive successful skill tools", async ({ page }) => {
const parts = [
toolPart("prt_skill_first", "skill", "completed", { id: "ocpr" }),
@@ -162,3 +273,14 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
function questionInput() {
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
}
function errorInput(tool: string) {
if (tool === "shell") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" }
if (tool === "patch") return { patchText: "Update src/error.ts" }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "skill") return { name: "failure" }
return { target: "failure" }
}
@@ -87,7 +87,6 @@ test("clears the terminal line with Command+Delete", async ({ page }) => {
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
await expect.poll(() => sendPtyOutput).toBeDefined()
await page.keyboard.press("Meta+Backspace")
@@ -127,6 +127,7 @@ test("animates review and terminal panels while caching hidden terminal content"
await expectStackedGeometry(page)
await expectPanelGapHeld(page)
await resetTerminalTopMotion(page)
await resetTerminalBottomMotion(page)
await resetTerminalAnchorGaps(page)
await resetPanelGaps(page)
@@ -138,6 +139,7 @@ test("animates review and terminal panels while caching hidden terminal content"
await expect(panel).toBeVisible()
await expectHeightMotions(page, "session-side-region", 2)
await expectHeightMotions(page, "session-side-terminal-region", 2)
await expectTerminalTopMotion(page)
await expectTerminalBottomFixed(page)
await expectTerminalTopAnchored(page)
await expectPanelGapHeld(page)
@@ -223,6 +225,7 @@ type MotionProbe = {
terminalAnchorGaps: number[]
resetAnchorOnMotion: boolean
panelGaps: number[]
terminalTops: number[]
terminalBottoms: number[]
heights: string[]
animations: string[]
@@ -240,6 +243,7 @@ async function installMotionProbe(page: Page) {
terminalAnchorGaps: [],
resetAnchorOnMotion: false,
panelGaps: [],
terminalTops: [],
terminalBottoms: [],
heights: [],
animations: [],
@@ -266,6 +270,7 @@ async function installMotionProbe(page: Page) {
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
if (!terminal || !terminalContent) return
probe.terminalTops.push(terminal.getBoundingClientRect().top)
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
probe.terminalContentSizes.push({
width: terminalContent.getBoundingClientRect().width,
@@ -441,6 +446,13 @@ async function expectStackPainted(page: Page) {
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
}
async function resetTerminalTopMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalTops = []
})
}
async function resetTerminalBottomMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
@@ -504,6 +516,17 @@ async function expectTerminalContentCachedSize(page: Page) {
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
}
async function expectTerminalTopMotion(page: Page) {
const tops = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
)
const unique = [...new Set(tops)]
const range = Math.max(...unique) - Math.min(...unique)
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
}
async function expectHeightMotions(page: Page, slot: string, count: number) {
await expect
.poll(() =>
@@ -1,137 +0,0 @@
import { expect, test } from "@playwright/test"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { once } from "node:events"
const legacy = `
self.addEventListener("install", event => event.waitUntil(
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache =>
cache.addAll(["/index.html", "/assets/app-old.js", "/assets/lazy-old.js"])
)
))
self.addEventListener("fetch", event => {
if (event.request.mode === "navigate") {
event.respondWith(caches.match("/index.html"))
return
}
event.respondWith(caches.match(event.request).then(response => response || fetch(event.request)))
})
`
const fixture = test.extend<{ site: { url: string; upgrade: () => void; repair: () => void } }>({
site: async ({}, use) => {
const worker = await readFile(new URL("../../dist/sw.js", import.meta.url), "utf8")
const state = { version: "old", repaired: false }
const server = createServer((request, response) => {
const pathname = new URL(request.url ?? "/", "http://localhost").pathname
const prefix = state.version === "old" ? "/assets" : "/_assets"
response.setHeader("cache-control", "no-store")
if (pathname === "/sw.js") {
response.setHeader("content-type", "text/javascript")
response.end(state.version === "old" ? legacy : worker)
return
}
if (pathname === `${prefix}/app-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`import "${prefix}/startup-${state.version}.js"`)
return
}
if (pathname === `${prefix}/startup-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`
document.getElementById("root").innerHTML = '<h1>${state.version}</h1><label>Draft<input></label><button>Load older chunk</button><output></output>'
document.querySelector("button").onclick = () => import("/assets/lazy-old.js")
`)
return
}
if (
(pathname === "/assets/lazy-old.js" && state.version === "old") ||
(pathname === "/_assets/retry.js" && state.repaired)
) {
response.setHeader("content-type", "text/javascript")
response.end('document.querySelector("output").textContent = "Older chunk loaded"')
return
}
// Deliberately retain the old server's fallback so the worker must reject HTML asset responses itself.
response.setHeader("content-type", "text/html")
response.end(`<div id="root"></div><script type="module" src="${prefix}/app-${state.version}.js"></script>`)
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
try {
await use({
url: `http://127.0.0.1:${address.port}`,
upgrade: () => (state.version = "new"),
repair: () => (state.repaired = true),
})
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
},
})
fixture("updates a legacy worker without reloading drafts or deleting old chunks", async ({ page, site }) => {
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("old")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByLabel("Draft").fill("Keep this unsent prompt")
site.upgrade()
await page.evaluate(async () => {
const cache = await caches.open("opencode-assets")
await cache.put(
"/_assets/startup-new.js",
new Response("<html>stale fallback</html>", {
headers: { "content-type": "text/html" },
}),
)
const changed = new Promise<void>((resolve) =>
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true }),
)
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) throw new Error("Missing legacy worker")
await registration.update()
await changed
})
await expect(page.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await page.getByRole("button", { name: "Load older chunk" }).click()
await expect(page.getByRole("status")).toHaveText("Older chunk loaded")
await page.goto(`${site.url}/workspace/example`)
await expect(page.getByRole("heading")).toHaveText("new")
await expect
.poll(() =>
page.evaluate(async () =>
(await (await caches.open("opencode-assets")).match("/_assets/startup-new.js"))?.headers.get("content-type"),
),
)
.toBe("text/javascript")
})
fixture("does not cache HTML responses under asset URLs", async ({ page, site }) => {
site.upgrade()
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/html",
)
site.repair()
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/javascript",
)
})
@@ -1,9 +0,0 @@
import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: ".",
testMatch: "*.spec.ts",
outputDir: "../test-results/service-worker",
timeout: 30_000,
use: { browserName: "chromium" },
})
+1 -1
View File
@@ -7,5 +7,5 @@
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"]
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
}
-3
View File
@@ -26,11 +26,8 @@
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
@@ -1,4 +0,0 @@
import { fileURLToPath } from "node:url"
import { componentConfig } from "../storybook/playwright/config"
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)))
+1 -4
View File
@@ -9,10 +9,7 @@ const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
testIgnore: [
"service-worker/**",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
outputDir: "./e2e/test-results",
timeout: 60_000,
expect: {
+3 -3
View File
@@ -1,10 +1,10 @@
/_assets/*.js
/assets/*.js
Content-Type: application/javascript
/_assets/*.mjs
/assets/*.mjs
Content-Type: application/javascript
/_assets/*.css
/assets/*.css
Content-Type: text/css
/*.js
+3 -7
View File
@@ -12,7 +12,6 @@ import { usePlatform } from "@/runtime/platform/platform"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useSettingsSurface } from "@/settings/surface"
import { WindowsAppMenu } from "./windows-menu"
import { applyPath, backPath, forwardPath } from "./history"
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
@@ -47,7 +46,6 @@ export function Titlebar(props: {
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const surface = useSettingsSurface()
const navigate = useNavigate()
const location = useLocation()
const mobile = createMediaQuery("(max-width: 767px)")
@@ -201,11 +199,11 @@ export function Titlebar(props: {
}
}
const currentTab = () => (surface.store.open ? undefined : matchRoute(layout.route()))
const currentTab = () => matchRoute(layout.route())
createEffect(() => {
const route = layout.route()
if (!tabs.ready() || surface.store.open) return
if (!tabs.ready()) return
const tab = currentTab()
if (tab) {
const current = session()
@@ -256,7 +254,7 @@ export function Titlebar(props: {
return
}
case "draft": {
const activeTab = matchRoute(route)
const activeTab = currentTab()
if (activeTab?.type !== "draft") return
const model = tabs.stateValue<ComposerState>(activeTab, "prompt")?.model.current()
@@ -374,7 +372,6 @@ export function Titlebar(props: {
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
surface.close()
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
}}
@@ -417,7 +414,6 @@ export function Titlebar(props: {
forceTruncate={false}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
surface.close()
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
+1 -7
View File
@@ -21,12 +21,6 @@
"@/*": ["./src/*"]
}
},
"include": [
"src",
"component-tests",
"playwright.components.config.ts",
"../storybook/playwright/*.ts",
"package.json"
],
"include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"]
}
+20 -32
View File
@@ -29,44 +29,33 @@ export default defineConfig({
injectRegister: false,
manifest: false,
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: false,
skipWaiting: true,
skipWaiting: false,
inlineWorkboxRuntime: true,
// Always fetch the current HTML. Precaching a partial build can strand it without its chunks after an upgrade.
navigateFallback: null,
globPatterns: [],
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api(?:\/|$)/],
globPatterns: [
"index.html",
"site.webmanifest",
"favicon*",
"apple-touch-icon*",
"web-app-manifest*",
"assets/index-*.{js,css}",
"assets/session-*.js",
"assets/IBMPlexMono-Text-*.woff2",
"assets/Inter.ttf",
"assets/JetBrainsMonoNerdFontMono-Regular.woff2",
],
runtimeCaching: [
{
urlPattern: ({ url }) =>
url.origin === self.location.origin &&
(url.pathname.startsWith("/_assets/") || url.pathname.startsWith("/assets/")),
urlPattern: ({ url }) => url.origin === self.location.origin && url.pathname.startsWith("/assets/"),
handler: "CacheFirst",
options: {
cacheName: "opencode-assets",
plugins: [
{
cachedResponseWillBeUsed: async ({ request, cachedResponse }) => {
if (
cachedResponse?.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(cachedResponse.headers.get("content-type") ?? "")
)
return cachedResponse
// Keep old tabs' precached chunks usable without retaining their stale HTML navigation handler.
const response = await caches.match(request, {
cacheName: `workbox-precache-v2-${self.location.origin}/`,
})
return response?.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
? response
: null
},
cacheWillUpdate: async ({ response }) =>
response.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
? response
: null,
},
],
cacheableResponse: {
statuses: [200],
},
expiration: {
maxEntries: 1000,
},
@@ -83,7 +72,6 @@ export default defineConfig({
port: 3000,
},
build: {
assetsDir: "_assets",
target: "esnext",
sourcemap: true,
},
+1 -1
View File
@@ -27,7 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
-2
View File
@@ -26,8 +26,6 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
const key = url.pathname.replace(/^\//, "")
if (key.startsWith("_assets/") && assets[key] === undefined)
return Effect.succeed(HttpServerResponse.empty({ status: 404, headers: { "cache-control": "no-store" } }))
const name = assets[key] !== undefined ? key : "index.html"
const file = assets[name]
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
+2 -19
View File
@@ -19,7 +19,7 @@ describe("web UI", () => {
await writeFile(asset, "console.log('embedded')")
const assets = {
"index.html": await Bun.file(index).text(),
"_assets/app.js": await Bun.file(asset).text(),
"app.js": await Bun.file(asset).text(),
"sw.js": "service worker",
"registerSW.js": "registration",
"font.woff2": new Uint8Array([0, 1, 2, 255]),
@@ -53,16 +53,7 @@ describe("web UI", () => {
expect(missing.status).toBe(404)
expect(yield* Effect.promise(() => missing.text())).toBe("")
yield* Effect.forEach(["/_assets/old.js", "/_assets/old.css", "/_assets/missing"], (pathname) =>
Effect.gen(function* () {
const missing = yield* Effect.promise(() => fetch(`${origin}${pathname}`))
expect(missing.status).toBe(404)
expect(missing.headers.get("cache-control")).toBe("no-store")
expect(yield* Effect.promise(() => missing.text())).toBe("")
}),
)
const script = yield* Effect.promise(() => fetch(`${origin}/_assets/app.js`))
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
expect(script.headers.get("content-type")).toContain("javascript")
expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable")
@@ -83,14 +74,6 @@ describe("web UI", () => {
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`))
expect(dotted.status).toBe(200)
expect(yield* Effect.promise(() => dotted.text())).toContain("embedded")
const legacy = yield* Effect.promise(() => fetch(`${origin}/assets/missing.js`))
expect(legacy.status).toBe(200)
expect(yield* Effect.promise(() => legacy.text())).toContain("embedded")
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
)
-109
View File
@@ -514,115 +514,6 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe keeps one request open while delivering multiple events", async () => {
const requests: Request[] = []
const events = [
{ id: "evt_first", created: 1, type: "server.connected", data: {} },
{ id: "evt_second", created: 2, type: "server.connected", data: {} },
]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
requests.push(input instanceof Request ? input : new Request(input, init))
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
})
},
})
const received = []
for await (const event of client.event.subscribe()) received.push(event)
expect(received).toEqual(events)
expect(requests).toHaveLength(1)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe delivers every event from one stream chunk", async () => {
const events = Array.from({ length: 4 }, (_, index) => ({
id: `evt_burst_${index}`,
created: index,
type: "server.connected",
data: {},
}))
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(new TextEncoder().encode(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")), {
headers: { "content-type": "text/event-stream" },
}),
})
const received = []
for await (const event of client.event.subscribe()) received.push(event)
expect(received).toEqual(events)
expect(new Set(received.map((event) => event.id)).size).toBe(4)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe parses split JSON and a split multibyte code point", async () => {
const event = {
id: "evt_split",
created: 1,
type: "server.connected",
data: { text: "split snowman \u2603\u2603\u2603" },
}
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
const multibyte = encoded.indexOf(new TextEncoder().encode("\u2603")[0]!)
const boundaries = [9, multibyte + 1, multibyte + 2, encoded.length]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
new ReadableStream({
start(controller) {
boundaries.forEach((end, index) =>
controller.enqueue(encoded.slice(index ? boundaries[index - 1] : 0, end)),
)
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe ignores server heartbeat comments", async () => {
const event = { id: "evt_sentinel", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(`: heartbeat\n\ndata: ${JSON.stringify(event)}\n\n: heartbeat\n\n`, {
headers: { "content-type": "text/event-stream" },
}),
})
const received = []
for await (const item of client.event.subscribe()) received.push(item)
expect(received).toEqual([event])
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary health requests", async () => {
const requests: string[] = []
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(new URL(request.url).pathname)
if (new URL(request.url).pathname === "/api/event") {
return new Response(`data: ${JSON.stringify(event)}\n\n`, {
headers: { "content-type": "text/event-stream" },
})
}
return Response.json({ healthy: true, version: "2.0.0", pid: 1 })
},
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
await expect(client.health.get()).resolves.toEqual({ healthy: true, version: "2.0.0", pid: 1 })
expect(requests).toEqual(["/api/event", "/api/health"])
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
+2 -1
View File
@@ -105,6 +105,7 @@
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -118,7 +119,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
-1
View File
@@ -55,7 +55,6 @@ export function map(input: MapInput): Mapping | undefined {
}
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/groq":
case "@ai-sdk/togetherai":
return {
package: `@opencode-ai/ai/providers/${input.packageName.slice("@ai-sdk/".length)}`,
+94 -167
View File
@@ -1,41 +1,11 @@
export * as Job from "./job.js"
import { Array, Cause, Clock, Context, Deferred, Effect, Exit, Layer, Schema, Scope, SynchronizedRef } from "effect"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Identifier } from "./id/id.js"
import { KV } from "./kv.js"
import { SessionMessage } from "./session/message.js"
import { SessionSchema } from "./session/schema.js"
const Background = Schema.Struct({
id: Schema.String,
notificationID: SessionMessage.ID,
recovery: Schema.Union([
Schema.Struct({
kind: Schema.Literal("shell"),
sessionID: SessionSchema.ID,
shellID: Schema.String,
command: Schema.String,
}),
Schema.Struct({
kind: Schema.Literal("subagent"),
parentSessionID: SessionSchema.ID,
childSessionID: SessionSchema.ID,
agent: Schema.String,
description: Schema.String,
}),
]),
status: Schema.Literals(["running", "completed", "error", "cancelled"]),
output: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(Schema.String),
})
export type Background = typeof Background.Type
export type Recovery = Background["recovery"]
export type Status = Background["status"]
const decodeBackground = Schema.decodeUnknownResult(Background)
const backgroundPrefix = "job.background/"
export type Status = "running" | "completed" | "error" | "cancelled"
export type Info = {
id: string
@@ -47,7 +17,6 @@ export type Info = {
output?: string
error?: string
metadata?: Record<string, unknown>
notificationID?: SessionMessage.ID
}
type Active = {
@@ -58,7 +27,6 @@ type Active = {
token: object
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
recovery?: Recovery
}
type State = {
@@ -95,8 +63,6 @@ export type StartInput = {
type: string
title?: string
metadata?: Record<string, unknown>
recovery?: Recovery
notificationID?: SessionMessage.ID
run: Effect.Effect<string, unknown>
}
@@ -130,8 +96,6 @@ export interface Interface {
readonly background: (id: string) => Effect.Effect<Info | undefined>
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
readonly pendingBackground: Effect.Effect<readonly Background[]>
readonly completeBackground: (notificationID: SessionMessage.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Job") {}
@@ -162,57 +126,43 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
}
/**
* Makes one scoped, process-local registry. Explicitly recoverable background
* work also owns a durable notification marker until its notification is admitted.
* Makes one scoped, process-local registry. Entries are intentionally not
* durable: process restart or owner-scope closure loses status and interrupts
* live work. Persisted observation, restart recovery, and remote workers need a
* separate durable ownership slice rather than pretending this registry has
* those semantics.
*/
export const make = Effect.gen(function* () {
const kv = yield* KV.Service
const state: State = {
jobs: yield* SynchronizedRef.make(new Map()),
scope: yield* Scope.Scope,
}
const persistBackground = Effect.fnUntraced(function* (job: Active) {
if (!job.recovery || !job.info.notificationID) return
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
id: job.info.id,
notificationID: job.info.notificationID,
recovery: job.recovery,
status: job.info.status,
...(job.info.output !== undefined ? { output: job.info.output } : {}),
...(job.info.error !== undefined ? { error: job.info.error } : {}),
})
})
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
: Cause.hasInterruptsOnly(exit.cause)
? "cancelled"
: "error"
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status,
completed_at,
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
},
}
if (status !== "cancelled") yield* persistBackground(next)
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
: Cause.hasInterruptsOnly(exit.cause)
? "cancelled"
: "error"
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status,
completed_at,
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
},
}
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
})
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.scope) {
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
@@ -220,6 +170,22 @@ export const make = Effect.gen(function* () {
return result.info
})
const fork = Effect.fnUntraced(function* (
scope: Scope.Scope,
id: string,
token: object,
run: Effect.Effect<string, unknown>,
) {
return yield* run.pipe(
Effect.matchCauseEffect({
onSuccess: (output) => settle(id, token, Exit.succeed(output)),
onFailure: (cause) => settle(id, token, Exit.failCause(cause)),
}),
Effect.asVoid,
Effect.forkIn(scope, { startImmediately: true }),
)
})
const get: Interface["get"] = Effect.fn("Job.get")(function* (id) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
if (!job) return undefined
@@ -235,10 +201,10 @@ export const make = Effect.gen(function* () {
const backgrounded = yield* Deferred.make<Info>()
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [StartResult, Map<string, Active>]> {
Effect.fnUntraced(function* (jobs) {
const existing = jobs.get(id)
if (existing?.info.status === "running") {
return [{ info: snapshot(existing) }, jobs]
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
}
const scope = yield* Scope.fork(state.scope, "parallel")
const token = {}
@@ -250,7 +216,6 @@ export const make = Effect.gen(function* () {
status: "running" as const,
started_at,
metadata: input.metadata,
...(input.notificationID ? { notificationID: input.notificationID } : {}),
},
done,
backgrounded,
@@ -258,18 +223,14 @@ export const make = Effect.gen(function* () {
token,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
StartResult,
Map<string, Active>,
]
}),
)
if ("scope" in result)
yield* restore(input.run).pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, result.token, exit)),
Effect.asVoid,
Effect.forkIn(result.scope, { startImmediately: true }),
)
if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run))
return result.info
}),
)
@@ -320,31 +281,20 @@ export const make = Effect.gen(function* () {
).pipe(Effect.ensuring(removeBlock(input)))
})
const markBackground = Effect.fnUntraced(function* (job: Active) {
const next = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
...(job.recovery ? { notificationID: job.info.notificationID ?? SessionMessage.ID.create() } : {}),
},
}
yield* persistBackground(next)
return next
})
const background: Interface["background"] = Effect.fn("Job.background")(function* (id) {
const result = yield* SynchronizedRef.modifyEffect(
const result = yield* SynchronizedRef.modify(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [BackgroundResult, Map<string, Active>]> {
(jobs): readonly [BackgroundResult, Map<string, Active>] => {
const job = jobs.get(id)
// Recoverable work may finish before the caller backgrounds it.
if (!job || (job.info.status !== "running" && !job.recovery)) return [{}, jobs]
if (!job || job.info.status !== "running") return [{}, jobs]
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
const next = yield* markBackground(job)
const next = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
}),
},
)
if (result.info && result.backgrounded)
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
@@ -352,83 +302,60 @@ export const make = Effect.gen(function* () {
})
const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) {
const result = yield* SynchronizedRef.modifyEffect(
const result = yield* SynchronizedRef.modify(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<
readonly [Required<BackgroundResult>[], Map<string, Active>]
> {
const results: Required<BackgroundResult>[] = []
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
const results: BackgroundResult[] = []
const next = new Map(jobs)
for (const [id, job] of jobs) {
if (job.info.status !== "running") continue
if (job.isBackgrounded) continue
if (input.type !== undefined && job.info.type !== input.type) continue
if (!job.blockingSessions.has(input.sessionID)) continue
const updated = yield* markBackground(job)
const updated = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
next.set(id, updated)
}
return [results, next]
}),
},
)
yield* Effect.forEach(result, (item) => Deferred.succeed(item.backgrounded, item.info), { discard: true })
return result.map((item) => item.info)
yield* Effect.forEach(
result,
(item) => (item.info && item.backgrounded ? Deferred.succeed(item.backgrounded, item.info) : Effect.void),
{ discard: true },
)
return result.flatMap((item) => (item.info ? [item.info] : []))
})
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status: "cancelled" as const,
completed_at,
},
}
yield* persistBackground(next)
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status: "cancelled" as const,
completed_at,
},
}
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
})
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
return result.info
})
const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () {
const recovered: Background[] = []
let after: string | undefined
do {
const page = yield* kv.scan({ prefix: backgroundPrefix, after })
recovered.push(...Array.filterMap(page.entries, (entry) => decodeBackground(entry.value)))
after = page.next
} while (after)
return recovered
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
kv.remove(`${backgroundPrefix}${notificationID}`),
)
return Service.of({
get,
start,
wait,
block,
background,
backgroundAll,
cancel,
pendingBackground,
completeBackground,
})
return Service.of({ get, start, wait, block, background, backgroundAll, cancel })
})
const layer = Layer.effect(Service, make)
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
-2
View File
@@ -337,7 +337,6 @@ function usesAPIKeyAuth(packageName: string | undefined) {
name === "@ai-sdk/deepinfra" ||
name === "@ai-sdk/openai-compatible" ||
name === "@ai-sdk/google" ||
name === "@ai-sdk/groq" ||
name === "@ai-sdk/togetherai" ||
name === "@ai-sdk/xai" ||
name === "@openrouter/ai-sdk-provider" ||
@@ -350,7 +349,6 @@ function usesAPIKeyAuth(packageName: string | undefined) {
name === "@opencode-ai/ai/providers/deepinfra" ||
name === "@opencode-ai/ai/providers/openai-compatible" ||
name === "@opencode-ai/ai/providers/google" ||
name === "@opencode-ai/ai/providers/groq" ||
name === "@opencode-ai/ai/providers/togetherai" ||
name === "@opencode-ai/ai/providers/xai" ||
name === "@opencode-ai/ai/providers/openrouter" ||
+2
View File
@@ -11,6 +11,7 @@ import { GatewayPlugin } from "./provider/gateway.js"
import { GithubCopilotPlugin } from "./provider/github-copilot.js"
import { GitLabPlugin } from "./provider/gitlab.js"
import { GoogleVertexPlugin } from "./provider/google-vertex.js"
import { GroqPlugin } from "./provider/groq.js"
import { KiloPlugin } from "./provider/kilo.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
@@ -44,6 +45,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GithubCopilotPlugin,
GitLabPlugin,
GoogleVertexPlugin,
GroqPlugin,
KiloPlugin,
LLMGatewayPlugin,
LMStudioPlugin,
+10
View File
@@ -0,0 +1,10 @@
import { createProviderPlugin } from "./factory.js"
export const GroqPlugin = createProviderPlugin({
id: "opencode.provider.groq",
package: "@ai-sdk/groq",
load: async (options) => {
const { createGroq } = await import("@ai-sdk/groq")
return createGroq(options)
},
})
+1 -3
View File
@@ -29,7 +29,7 @@ export interface Interface {
| "wait"
| "context"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
readonly agent: {
readonly list: (
@@ -92,8 +92,6 @@ export const layerWithCell = (cell: Cell) =>
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
background: (id) => require(cell, (runtime) => runtime.job.background(id)),
cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)),
completeBackground: (notificationID) =>
require(cell, (runtime) => runtime.job.completeBackground(notificationID)),
},
location: {
agent: {
-1
View File
@@ -62,7 +62,6 @@ const builtins = new Map<string, () => Promise<unknown>>([
"@opencode-ai/ai/providers/google-vertex/messages",
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
],
["@opencode-ai/ai/providers/groq", () => import("@opencode-ai/ai/providers/groq")],
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
+2 -5
View File
@@ -3,7 +3,6 @@ export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Job } from "../job.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEvent } from "./event.js"
@@ -53,7 +52,6 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const db = (yield* Database.Service).db
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
@@ -96,7 +94,7 @@ export const layer = Layer.effect(
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
if (result._tag === "Complete") return
if (result.type === "complete") return
return yield* drain(sessionID, false, result.continuation, promotable)
})
}
@@ -120,7 +118,6 @@ export const layer = Layer.effect(
if (outcome.type === "interrupted") {
// A user cancel releases the claim: the turn must not resurrect at the next
// boot. Shutdown interruption keeps it for restart continuity.
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
yield* bus.publish(
SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason },
@@ -170,7 +167,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
+17 -159
View File
@@ -3,8 +3,6 @@ export * as SessionRestart from "./restart.js"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../../bus.js"
import { Job } from "../../job.js"
import { Session } from "../../session.js"
import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionSchema } from "../schema.js"
@@ -47,9 +45,6 @@ export interface Interface {
* process: crash, SIGKILL, isolate eviction, and graceful restart all leave
* the same durable signature.
*
* Recovery is at-least-once: local coordination prevents concurrent drains,
* not repeated external side effects after a crash.
*
* The sweep assumes every orphaned claim's owner is dead. The managed-server
* protocol guarantees this: a successor is only spawned after the previous
* process is confirmed dead (client service `kill`/`evict` poll the PID), the
@@ -67,16 +62,14 @@ export const layer = (options?: Options) =>
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const scope = yield* Effect.scope
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
const prepareResume = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
const resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
// Durable before the resume runs, so a crash inside the resumed turn is
// counted by the next sweep and the budget cannot be dodged.
const attempts = yield* store.countResume(sessionID)
if (attempts === undefined) return false
if (attempts === undefined) return // the Session was deleted since listing
if (attempts > maxAttempts) {
// Terminalize instead: the release hook clears the claim and resets the
// counter atomically with the terminal event.
@@ -85,166 +78,31 @@ export const layer = (options?: Options) =>
{ sessionID, error: RESUME_EXHAUSTED },
{ commit: () => store.release(sessionID) },
)
return false
return
}
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
return true
})
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
) {
const state = background.status === "running" ? "cancelled" : background.status
const text =
background.status === "running"
? "Command cancelled because the server restarted"
: state === "completed"
? (background.output ?? "Command completed")
: state === "error"
? (background.error ?? "Command failed")
: "Command cancelled"
yield* sessions
.synthetic({
id: background.notificationID,
sessionID: recovery.sessionID,
description: recovery.command,
text: `<shell id="${background.id}" state="${state}" command="${recovery.command}">\n${text}\n</shell>`,
metadata: {
source: "shell",
jobID: background.id,
shellID: recovery.shellID,
state,
},
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
Effect.orDie,
)
yield* jobs.completeBackground(background.notificationID)
})
const recoverSubagent = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
suspended: ReadonlySet<SessionSchema.ID>,
) {
const child = yield* store.get(recovery.childSessionID)
if (!child || child.parentID !== recovery.parentSessionID || !(yield* store.get(recovery.parentSessionID))) {
yield* jobs.completeBackground(background.notificationID)
return
}
const notify = Effect.fnUntraced(function* (result: Pick<Job.Background, "status" | "output" | "error">) {
if (result.status === "running") return
const text =
result.status === "completed"
? (result.output ?? "Subagent completed without a text response.")
: result.status === "error"
? (result.error ?? "Subagent failed")
: "Subagent cancelled"
yield* sessions
.synthetic({
id: background.notificationID,
sessionID: recovery.parentSessionID,
...(suspended.has(recovery.parentSessionID) ? { resume: false } : {}),
description: recovery.description,
text: `<subagent sessionID="${recovery.childSessionID}" state="${result.status}" description="${recovery.description}">\n${text}\n</subagent>`,
metadata: {
source: "subagent",
childID: recovery.childSessionID,
agent: recovery.agent,
state: result.status,
},
})
.pipe(Effect.orDie)
yield* jobs.completeBackground(background.notificationID)
})
if (background.status !== "running") {
yield* notify(background)
return
}
if ((yield* execution.active).has(recovery.childSessionID)) return
if (!(yield* prepareResume(recovery.childSessionID))) {
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
return
}
yield* jobs.start({
id: background.id,
type: "subagent",
title: recovery.description,
notificationID: background.notificationID,
recovery,
run: execution.resume(recovery.childSessionID).pipe(
Effect.andThen(store.context(recovery.childSessionID)),
Effect.map((messages) => {
const assistant = messages.findLast(
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
if (assistant?.type !== "assistant") return "Subagent completed without a text response."
return (
assistant.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("") || "Subagent completed without a text response."
)
}),
),
})
yield* jobs.background(background.id)
yield* jobs.wait({ id: background.id }).pipe(
Effect.flatMap((result) => (result.info ? notify(result.info) : Effect.void)),
Effect.ignore,
Effect.forkIn(scope),
)
// Forked into the service scope so boot never waits on resumed turns;
// resuming an already-live Session joins its execution. Drain failures
// are logged and durably recorded by the execution layer.
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
})
return Service.of({
resumeSuspendedSessions: Effect.gen(function* () {
// Child claims never drive recovery (children are not resumed), so a
// dead child's claim is noise no terminal will ever release. Clearing
// is safe even against a live child: claims are recovery markers, not
// locks, and children are excluded from that recovery.
yield* store.releaseChildClaims
const active = yield* execution.active
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
const pending = yield* jobs.pendingBackground
yield* store.releaseChildClaims(
pending.flatMap((background) =>
background.status === "running" && background.recovery.kind === "subagent"
? [background.recovery.childSessionID]
: [],
),
)
yield* Effect.forEach(
pending,
Effect.fnUntraced(function* (background) {
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
yield* recovery.kind === "shell"
? recoverShell(background, recovery)
: recoverSubagent(background, recovery, suspended)
}),
{ discard: true },
)
// Background completion can wake a parent, so inspect local ownership only after recovery.
const resumed = yield* execution.active
yield* Effect.forEach(
(yield* store.listSuspended()).filter((sessionID) => !resumed.has(sessionID)),
(sessionID) =>
execution
.resume(sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope), Effect.when(prepareResume(sessionID))),
{ concurrency: "unbounded", discard: true },
)
// Async observers consult this set at delivery; later completions wake parents normally.
suspended.clear()
// Sessions already draining in this process keep their claim; resuming
// them would only inject a stray continuation into a live turn.
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
}),
})
}),
@@ -253,5 +111,5 @@ export const layer = (options?: Options) =>
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [SessionStore.node, SessionExecution.node, Bus.node, Job.node, Session.node],
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
+1 -1
View File
@@ -45,7 +45,7 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
return decline ? Result.succeed(decline) : Result.fail(cause)
}
export interface Prepared {
interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
/**
+4 -6
View File
@@ -1,7 +1,7 @@
export * as SessionRunner from "./index.js"
import type { AIError } from "@opencode-ai/ai"
import { Context, Data, Effect } from "effect"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
@@ -19,11 +19,9 @@ export type RunError =
export type Continuation = { readonly step: number }
export type DrainResult = Data.TaggedEnum<{
Complete: {}
Moved: { readonly continuation?: Continuation }
}>
export const DrainResult = Data.taggedEnum<DrainResult>()
export type DrainResult =
| { readonly type: "complete" }
| { readonly type: "moved"; readonly continuation?: Continuation }
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
+498 -99
View File
@@ -1,9 +1,20 @@
export * as SessionRunnerLLM from "./llm.js"
import { Message } from "@opencode-ai/ai"
import { Cause, Config, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
import {
LLMClient,
AIError,
InvalidProviderOutputReason,
LLMEvent,
Message,
isContextOverflowFailure,
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Config, Data, Effect, Exit, Fiber, FiberMap, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
@@ -15,18 +26,100 @@ import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Continuation } from "./index.js"
import { Service, type Continuation } from "./index.js"
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform.js"
import { StepFailedError } from "../error.js"
import { toSessionError } from "../to-session-error.js"
import { SessionRunnerRetry } from "./retry.js"
import { SessionStep } from "./step.js"
import { SessionUsage } from "../usage.js"
import { ToolOutput } from "../../tool-output.js"
import { PluginSupervisor } from "../../plugin/supervisor.js"
import { Tool } from "../../tool.js"
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean; readonly step: number }
Retry: { readonly step: number }
Continue: {
readonly cause: AIError
readonly error: SessionRunnerRetry.RetryableFailure["error"]
readonly step: number
}
RecoverFull: { readonly step: number }
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
}>
const CallOutcome = Data.taggedEnum<CallOutcome>()
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isDecline = (
error: SessionModelRequest.ExecuteError,
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
const isInterruptedStream = (failure: AIError) => {
if (failure.reason._tag === "InvalidProviderOutput")
return failure.reason.classification === "incomplete-stream"
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
return false
}
/**
* Classifies how the owned tool fibers ended. Interrupts abort the step; a user decline
* settles its own call and then aborts the step; a defect from a tool implementation
* becomes a failed tool call the model can read; a typed infrastructure failure must
* fail the assistant and then the drain.
*/
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>, never>,
calls: ReadonlyArray<ToolCall>,
) => {
// Exits align with calls by construction: one owned fiber per accepted local call.
const exits = settled._tag === "Success" ? settled.value : []
const declines = exits.flatMap((exit, index) =>
exit._tag === "Failure"
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
)
: [],
)
const causes =
settled._tag === "Failure"
? [settled.cause]
: exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
// The first non-interrupt, non-decline failure, rebuilt without decline reasons so the
// drain's error channel never carries a decline.
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason)
? isDecline(reason.error)
? []
: // A typed failure here broke the ExecuteError contract (the per-fiber
// `catchTag("Tool.Error")` consumes honest ones). Surfacing it as a defect
// keeps it from being dropped, which would leave its call unsettled forever.
[Cause.makeDieReason(reason.error)]
: [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})
.at(0)
return {
interrupted: causes.some(Cause.hasInterrupts),
declines,
failure,
}
}
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
const CONTINUE_AFTER_INCOMPLETE_STREAM =
"The previous response was interrupted. Continue from where you left off without repeating completed content."
@@ -34,15 +127,17 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const modelTransport = yield* SessionModelTransport.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const plugins = yield* PluginSupervisor.Service
const title = yield* SessionTitle.Service
const steps = yield* SessionStep.make
const toolOutput = yield* ToolOutput.Service
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
Config.withDefault(false),
Effect.orDie,
@@ -71,7 +166,10 @@ const layer = Layer.effect(
})
// Title generation starts once input is visible and must not delay model execution.
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
* Execution lifecycle is published per busy period by SessionExecution, not here.
*/
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
@@ -81,25 +179,30 @@ const layer = Layer.effect(
let force = input.force
let continuation = input.continuation
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) return DrainResult.Complete()
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable)))
return { type: "complete" as const }
yield* plugins.flush
yield* settleStaleToolCalls(input.sessionID)
while (true) {
// Scope gates input promotion, not a between-step control that is next in line.
// Between-turn control items run under any drain scope: scope gates which user
// input may promote, not whether admitted housekeeping runs. Steered control
// items go ahead of any queued input; only a queue-delivered control item
// parked behind a queued prompt is not the next eligible item.
if (yield* runPendingCompaction(input.sessionID, "input")) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID, "input")) return DrainResult.Moved({})
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return DrainResult.Complete()
return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result._tag === "Moved") return result
if (result.type === "moved") return result
force = false
continuation = undefined
}
})
/** Work this drain may perform: scoped input, or a between-turn control item next in line. */
const eligible = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, promotable: SessionInbox.Promotable) {
if (yield* SessionInbox.has(db, sessionID, promotable)) return true
if (promotable === "input") return false
@@ -107,20 +210,31 @@ const layer = Layer.effect(
return next?.type === "compaction" || next?.type === "move"
})
/** Queued inputs wait until the current model work reaches idle; later Steps absorb only steers. */
/**
* Runs logical steps until no tool result or newly admitted steer requires another
* model call. Queued inputs remain pending until the current model work reaches idle.
*/
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID,
continuation: Continuation | undefined,
drainPromotable: SessionInbox.Promotable,
) {
// Fresh work may promote queued input; resumed turns and later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
let step = continuation?.step ?? 1
let next = continuation
// The drain admitted this work, so the first step always runs — even after a
// control item consumed at this boundary (unlike drain's one-shot force).
let first = true
// Every boundary has the same shape: control items first, then one exit decision,
// then the model. The turn continues only while the first step, a continuation, or
// steer input is owed. Deciding after control items means consuming the last
// steered compaction ends the turn instead of issuing an input-free model call.
while (true) {
if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return DrainResult.Moved({ continuation: next })
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer"))) return DrainResult.Complete()
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer")))
return { type: "complete" as const }
const result = yield* runStep(sessionID, promotable, step)
first = false
promotable = "steer"
@@ -129,100 +243,391 @@ const layer = Layer.effect(
}
})
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
const runStep = Effect.fn("SessionRunner.runStep")(function* (
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
const runStep = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
step: number,
) {
// Minting message identity before any attempt lets retries resume the same durable
// message. A compaction restart re-mints: the old message is stranded behind the new
// compaction boundary, so the rebuilt step needs identity inside the new epoch.
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
const retry = yield* Schedule.toStepWithSleep(
SessionRunnerRetry.schedule(bus, sessionID, () => assistantMessageID),
)
/**
* Consumes one retry allowance: sleeps the scheduled backoff, or publishes
* Step.Failed and fails once attempts are exhausted. The step loop performs
* the retry itself on the next iteration.
*/
const waitForRetry = (failure: SessionRunnerRetry.RetryableFailure) =>
retry(failure).pipe(
Effect.as(CallOutcome.Retry({ step: failure.step })),
Pull.catchDone(() =>
bus
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID,
error: failure.error,
})
.pipe(Effect.andThen(Effect.fail(failure.cause))),
),
)
let currentPromotable: SessionInbox.Promotable | undefined = promotable
let currentStep = step
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
let recoverOverflow = true
// Continuation rejection permits one immediate full-context Physical Attempt without generic backoff.
let recoverContinuation = true
while (true) {
const selected = yield* context.select(sessionID)
// A blocked initial instruction baseline must leave admitted input pending.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = currentPromotable
? yield* SessionInbox.promote(db, bus, selected.session.id, currentPromotable)
: 0
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
currentStep = promoted > 0 ? 1 : currentStep
currentPromotable = undefined
const loaded = yield* context.load(selected)
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
assistantMessageID = SessionMessage.ID.create()
continue
}
const stepLimitReached = loaded.agent.info.steps !== undefined && currentStep >= loaded.agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* modelRequests.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
yield* diagnosePromptCache(sessionID, prepared.request)
const outcome = yield* steps.attempt({
const outcome = yield* callModel(
sessionID,
assistantMessageID,
agent: loaded.agent.id,
model: loaded.model,
prepared,
toolsDisabled: stepLimitReached,
currentPromotable,
currentStep,
recoverOverflow,
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
: Effect.succeed(false),
),
})
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: currentStep }
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
Effect.gen(function* () {
if (outcome._tag === "Retry")
yield* bus.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
return yield* outcome.cause
}),
),
)
if (outcome._tag === "Continue") {
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
assistantMessageID = SessionMessage.ID.create()
}
continue
}
if (outcome._tag === "Compacted") {
recoverOverflow = false
assistantMessageID,
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
if (outcome._tag === "Continue") {
yield* retry(
new SessionRunnerRetry.RetryableFailure({
cause: outcome.cause,
error: outcome.error,
step: outcome.step,
}),
).pipe(Pull.catchDone(() => Effect.fail(outcome.cause)))
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
})
assistantMessageID = SessionMessage.ID.create()
continue
}
recoverContinuation = false
if (outcome._tag === "Restart") {
if (outcome.recoveredOverflow) recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
}
if (outcome._tag === "RecoverFull") recoverContinuation = false
// Neither a retry nor a compaction restart re-promotes input.
currentPromotable = undefined
currentStep = outcome.step
}
})
/**
* Prepares and runs at most one model call, executes its local tools, and durably
* settles the step. Compaction may instead request that the logical step restart.
*/
const callModel = Effect.fn("SessionRunner.callModel")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable | undefined,
step: number,
recoverOverflow: boolean,
recoverContinuation: boolean,
assistantMessageID: SessionMessage.ID,
) {
const selected = yield* context.select(sessionID)
// Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected)
const { session, agent } = loaded
const resolved = loaded.model
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, resolved }
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
return yield* new StepFailedError({ error: compacted.error })
}
const stepLimitReached = agent.info.steps !== undefined && currentStep >= agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: agent.info,
model: resolved,
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* modelRequests.prepare({
scope: { session, agentID: agent.id, model: resolved, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// The final Step keeps definitions available to protocols with native "none",
// preserving their prompt cache prefix. Calls are still rejected at execution.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
yield* diagnosePromptCache(session.id, prepared.request)
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
return prepared.executeTool(input)
}
// Every local tool call forked here is owned until it reaches one durable settlement.
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(bus, {
sessionID: session.id,
agent: agent.id,
// The selected catalog identity, not model.id: route-level ids are provider API
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref,
providerMetadataKey: transcript.providerMetadataKey,
snapshot: startSnapshot,
assistantMessageID,
})
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
tokens: finish.tokens,
})
const captureStepEnd = Effect.fnUntraced(function* () {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? startSnapshot === snapshot
? []
: yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.orElseSucceed(() => undefined))
: undefined
return { snapshot, files }
})
const publishStepEnd = (finish: NonNullable<StepRecord["finish"]>) =>
Effect.gen(function* () {
const end = yield* captureStepEnd()
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: finish.finish,
rawFinish: finish.rawFinish,
providerState: finish.providerState,
...stepUsage(finish),
...end,
})
})
// Concurrent writers, no lock: the provider loop and each tool fiber publish
// durable events unserialized. This is safe because every publisher method commits
// its state marks synchronously before its first await (see publish-llm-event.ts),
// every required event order is per-source (each source is one sequential fiber),
// and a fiber's events are causally after its own Tool.Called: the fork happens
// below that publish. Cross-source order is unconstrained; either interleaving is
// a truthful history of concurrent work.
//
// The stream is defined here but runs inside the settlement mask below: publish each
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (
LLMEvent.is.providerError(event) &&
isContextOverflowFailure(event) &&
!publisher.record().outputStarted
) {
overflowFailure = event
return
}
yield* publisher.publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
toolRuns.push({
call: event,
fiber: yield* Effect.uninterruptibleMask((restore) =>
restore(
executeTool({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
call: event,
// Progress is ephemeral, not durable history: nothing to order.
progress: (update) => publisher.progress(event.id, update),
}),
).pipe(
// The fiber owns its call: it publishes its own completion, masked so a
// finished execution always reaches its durable settlement.
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
),
).pipe(Effect.forkScoped),
})
}),
),
Effect.ensuring(publisher.flush()),
)
// Settle: only the stream and the fiber joins are interruptible (restore); every
// other line is protected so a started call always reaches one durable outcome.
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
// away non-interrupt failures, so both interrupt checks stay Cause-based.
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
// Join every owned tool run first: await all exits, not just the first failure.
// Afterwards no fiber is alive, settlement is the only writer, and the record
// is final. A failed join means the waiting itself was interrupted, so the runs
// we abandoned are interrupted before settlement closes them out.
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(
Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }),
).pipe(Effect.exit)
if (joined._tag === "Failure") yield* interruptTools
const tools = classifyToolExits(
joined,
toolRuns.map((run) => run.call),
)
// A context overflow before any assistant output is recoverable: compact and
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
compaction.enabled() &&
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(compaction.compact(compactionInput))).status === "completed"
)
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
// An unrecovered held-back overflow becomes the step's durable provider error.
if (overflowFailure) yield* publisher.publish(overflowFailure)
// A thrown LLM failure not already recorded as the provider error either
// escapes as a scheduled retry or fails the assistant durably.
const unknownFinish =
stream._tag === "Success" && publisher.record().finish?.finish === "unknown"
? new AIError({
module: "session",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended with an unknown finish reason.",
}),
})
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
if (
recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!publisher.record().outputStarted
)
return CallOutcome.RecoverFull({ step: currentStep })
if (
llmFailure &&
llmError &&
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.record().outputStarted
) {
// RetryScheduled and Step.Failed fold onto an existing assistant message, so
// Step.Started must be durable before the failure escapes.
yield* publisher.startAssistant()
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
error: llmError,
step: currentStep,
})
}
if (llmError) yield* publisher.failAssistant(llmError)
// Close every unsettled call with the reason it could not settle truthfully,
// and fail the assistant when the step itself cannot complete. A declined call
// settles with its own reason before the generic sweeps.
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
if (tools.declines.length > 0 || streamInterrupted || tools.interrupted) {
yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
yield* publisher.failAssistant(STEP_INTERRUPTED)
}
if (tools.failure !== undefined) {
const error = toSessionError(Cause.squash(tools.failure))
yield* publisher.failUnsettledTools(error)
}
// Local calls have joined, so the remaining sweeps only close hosted calls the
// provider promised but never resolved.
if (publisher.record().providerFailed) yield* publisher.failUnsettledTools(TOOLS_INTERRUPTED)
if (llmError) yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
// A clean stream that still left hosted calls unresolved fails the step itself.
if (stream._tag === "Success" && !publisher.record().providerFailed) {
const hostedResultMissing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (hostedResultMissing && !publisher.record().finish) yield* publisher.failAssistant(RESULT_MISSING)
}
// One terminal event: Step.Ended on a clean finish, Step.Failed otherwise.
const record = publisher.record()
if (record.finish && !record.failure) yield* publishStepEnd(record.finish)
if (record.failure) {
const end = yield* captureStepEnd()
yield* publisher.publishStepFailure({
...(record.finish ? stepUsage(record.finish) : {}),
...end,
})
}
if (
llmFailure &&
llmError &&
isInterruptedStream(llmFailure) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return CallOutcome.Continue({
cause: llmFailure,
error: llmError,
step: currentStep,
})
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return CallOutcome.Completed({
// A local call or malformed tool input requires another model step, unless
// this step already exhausted the agent's allowance.
needsContinuation:
!stepLimitReached && record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
step: currentStep,
})
}),
)
}, Effect.scoped)
/** Executes a previously admitted manual compaction request, if one is pending. */
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
@@ -294,6 +699,7 @@ const layer = Layer.effect(
)
})
/** Closes stale tool calls left active by an earlier interrupted drain. */
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
) {
@@ -301,18 +707,11 @@ const layer = Layer.effect(
if (message.type !== "assistant") continue
for (const tool of message.content) {
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
const metadata = tool.state.status === "running" ? tool.state.metadata : undefined
const childID =
tool.name === "subagent" && typeof metadata?.sessionID === "string" ? metadata.sessionID : undefined
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
id: tool.id,
error: {
type: "aborted",
message: `Tool execution interrupted: ${tool.name}${childID ? ` (sessionID: ${childID})` : ""}`,
},
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
}
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Clock, Effect, Iterable } from "effect"
import { Clock, Effect } from "effect"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -39,7 +39,13 @@ export interface StepRecord {
readonly providerState?: SessionMessage.ProviderState
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly needsContinuation: boolean
readonly calls: ReadonlyArray<{
readonly id: string
readonly name: string
readonly called: boolean
readonly settled: boolean
readonly providerExecuted: boolean
}>
}
/** Derives canonical model content from a provider-hosted tool result. */
@@ -79,6 +85,7 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const deltaBatchInterval = 100
type ToolState = {
readonly assistantMessageID: SessionMessage.ID
readonly name: string
called: boolean
settled: boolean
@@ -243,7 +250,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID,
assistantMessageID: tool.assistantMessageID,
id,
text: value,
})
@@ -262,8 +269,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
readonly providerExecuted?: boolean
}) {
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
yield* startAssistant()
const assistantMessageID = yield* startAssistant()
const tool: ToolState = {
assistantMessageID,
name: event.name,
called: false,
settled: false,
@@ -306,7 +314,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
error: {
type: "tool.input-json",
@@ -325,12 +333,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID,
assistantMessageID: tool.assistantMessageID,
id,
error:
tool.name === "subagent" && error.type === "aborted" && typeof tool.progress?.sessionID === "string"
? { ...error, message: `${error.message} (sessionID: ${tool.progress.sessionID})` }
: error,
error,
...failureSnapshot(tool, metadata),
executed: tool.providerExecuted,
})
@@ -378,6 +383,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
(error: SessionError.Error, scope: "hosted" | "all" = "all") => failTools(error, scope),
)
const assistantMessageIDForTool = (id: string) => {
const tool = tools.get(id)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${id}`))
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
switch (event.type) {
case "step-start":
@@ -445,7 +455,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.providerExecuted = event.providerExecuted === true
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
input: asRecord(event.input),
executed: tool.providerExecuted,
@@ -471,7 +481,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
if (event.result.type === "error") {
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
error: { type: "tool.execution", message: stringify(event.result.value) },
...failureSnapshot(tool),
@@ -482,7 +492,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
content: hostedContent(event.result),
executed,
@@ -499,7 +509,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.settled = true
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
error:
event.message === `Unknown tool: ${event.name}`
@@ -541,7 +551,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
tool.progress = update
yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID,
assistantMessageID: tool.assistantMessageID,
id,
metadata: update,
})
@@ -564,7 +574,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID,
assistantMessageID: tool.assistantMessageID,
id,
content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
@@ -589,12 +599,16 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
providerFailed,
failure: stepFailure,
finish: stepSettlement,
needsContinuation: Iterable.some(
tools.values(),
(tool) => !tool.providerExecuted && (tool.called || tool.settled),
),
calls: Array.from(tools, ([id, tool]) => ({
id,
name: tool.name,
called: tool.called,
settled: tool.settled,
providerExecuted: tool.providerExecuted,
})),
}),
startAssistant,
streamed,
assistantMessageID: assistantMessageIDForTool,
}
}
+16 -12
View File
@@ -2,17 +2,17 @@ export * as SessionRunnerRetry from "./retry.js"
import { AIError } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Duration, Effect, Schedule } from "effect"
import { Data, Duration, Effect, Schedule } from "effect"
import { Bus } from "../../bus.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
export interface Input {
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
readonly cause: AIError
readonly error: SessionError.Error
readonly assistantMessageID: SessionMessage.ID
}
readonly step: number
}> {}
export function isRetryable(error: AIError) {
const override = "http" in error.reason ? error.reason.http?.response?.headers["x-should-retry"] : undefined
@@ -40,25 +40,29 @@ export function isRetryable(error: AIError) {
}
}
const retryAfter = (input: Input) => {
if (input.cause.reason._tag === "RateLimit" || input.cause.reason._tag === "ProviderInternal")
return input.cause.reason.retryAfterMs
const retryAfter = (failure: RetryableFailure) => {
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
return failure.cause.reason.retryAfterMs
return undefined
}
export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
export const schedule = (
bus: Bus.Interface,
sessionID: SessionSchema.ID,
assistantMessageID: () => SessionMessage.ID,
) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<Input>(),
Schedule.modifyDelay(({ input, duration: delay }) => {
const minimum = retryAfter(input)
Schedule.setInputType<RetryableFailure>(),
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
const minimum = retryAfter(failure)
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}),
Schedule.tap((metadata) =>
bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: metadata.input.assistantMessageID,
assistantMessageID: assistantMessageID(),
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
-293
View File
@@ -1,293 +0,0 @@
export * as SessionStep from "./step.js"
import {
AIError,
InvalidProviderOutputReason,
LLMClient,
LLMEvent,
isContextOverflowFailure,
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Agent } from "../../agent.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { Snapshot } from "../../snapshot.js"
import { Tool } from "../../tool.js"
import { ToolOutput } from "../../tool-output.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { StepFailedError } from "../error.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionSchema } from "../schema.js"
import { toSessionError } from "../to-session-error.js"
import { SessionUsage } from "../usage.js"
import { SessionRunnerModel } from "./model.js"
import { createLLMEventPublisher } from "./publish-llm-event.js"
import { SessionRunnerRetry } from "./retry.js"
export type Outcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean }
Retry: { readonly cause: AIError; readonly error: SessionError.Error }
Continue: { readonly cause: AIError; readonly error: SessionError.Error }
RecoverFull: {}
Compacted: {}
}>
const Outcome = Data.taggedEnum<Outcome>()
interface Input {
readonly sessionID: SessionSchema.ID
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly prepared: SessionModelRequest.Prepared
readonly toolsDisabled: boolean
readonly recoverContinuation: boolean
/** The runner owns compaction policy; the attempt invokes it only before durable output. */
readonly recoverOverflow: Effect.Effect<boolean>
}
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
/** Captures Location-scoped dependencies without introducing another service or execution loop. */
export const make = Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const snapshots = yield* Snapshot.Service
const toolOutput = yield* ToolOutput.Service
const attempt = Effect.fn("SessionStep.attempt")(function* (input: Input) {
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(bus, {
sessionID: input.sessionID,
assistantMessageID: input.assistantMessageID,
agent: input.agent,
model: input.model.ref,
providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider,
snapshot: startSnapshot,
})
const toolRuns: Array<{
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, SessionModelRequest.ExecuteError>
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const executeTool = (call: ToolCall) => {
if (input.toolsDisabled) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
return input.prepared.executeTool({
sessionID: input.sessionID,
agent: input.agent,
messageID: input.assistantMessageID,
call,
progress: (update) => publisher.progress(call.id, update),
})
}
// Provider and tool fibers retain per-source order without a shared writer queue.
// A local execution starts only after its Tool.Called publication completes.
let overflowFailure: ProviderErrorEvent | undefined
// Read to the end, not just the finish event, so the next request can reuse this response.
const providerStream = llm.stream(input.prepared.request, input.prepared.options).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (
LLMEvent.is.providerError(event) &&
isContextOverflowFailure(event) &&
!publisher.record().outputStarted
) {
overflowFailure = event
return
}
yield* publisher.publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
toolRuns.push({
call: event,
fiber: yield* Effect.uninterruptibleMask((restore) =>
restore(executeTool(event)).pipe(
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
),
).pipe(Effect.forkScoped),
})
}),
),
Effect.ensuring(publisher.flush()),
)
// Keep the final tool and Step events uninterruptible, even when the work itself is cancelled.
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
if (joined._tag === "Failure") yield* interruptTools
const tools = classifyToolExits(
joined,
toolRuns.map((run) => run.call),
)
if (
!publisher.record().outputStarted &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(input.recoverOverflow))
)
return Outcome.Compacted()
if (overflowFailure) yield* publisher.publish(overflowFailure)
const recorded = publisher.record()
const unknownFinish =
stream._tag === "Success" && recorded.finish?.finish === "unknown"
? new AIError({
module: "session",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended with an unknown finish reason.",
}),
})
: undefined
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
if (
input.recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!recorded.outputStarted
)
return Outcome.RecoverFull()
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
// Retry state projects onto the existing assistant, even before it has produced output.
yield* publisher.startAssistant()
return Outcome.Retry({ cause: llmFailure, error: llmError })
}
if (llmError) yield* publisher.failAssistant(llmError)
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
const toolFailure = interrupted
? TOOLS_INTERRUPTED
: tools.failure !== undefined
? toSessionError(Cause.squash(tools.failure))
: recorded.providerFailed
? TOOLS_INTERRUPTED
: undefined
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
// All local fibers have joined; only provider-hosted results can still be missing.
if (llmError || (stream._tag === "Success" && !recorded.providerFailed)) {
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
}
const record = publisher.record()
if (record.finish || record.failure) {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? startSnapshot === snapshot
? []
: yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.orElseSucceed(() => undefined))
: undefined
const usage = record.finish
? { cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens), tokens: record.finish.tokens }
: undefined
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
if (record.finish && usage && !record.failure)
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* publisher.startAssistant(),
finish: record.finish.finish,
rawFinish: record.finish.rawFinish,
providerState: record.finish.providerState,
...usage,
snapshot,
files,
})
}
if (
llmFailure &&
llmError &&
isInterruptedStream(llmFailure) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
)
return Outcome.Continue({ cause: llmFailure, error: llmError })
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: !input.toolsDisabled && record.needsContinuation,
})
}),
)
}, Effect.scoped)
return { attempt }
})
const isDecline = (
error: SessionModelRequest.ExecuteError,
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
const isInterruptedStream = (failure: AIError) => {
if (failure.reason._tag === "InvalidProviderOutput") return failure.reason.classification === "incomplete-stream"
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
return false
}
/** Keep every joined exit associated with its call; a decline is not an infrastructure failure. */
const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>>,
calls: ReadonlyArray<ToolCall>,
) => {
const exits = settled._tag === "Success" ? settled.value : []
const declines = exits.flatMap((exit, index) =>
exit._tag === "Failure"
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
)
: [],
)
const causes =
settled._tag === "Failure"
? [settled.cause]
: exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeDieReason(reason.error)]) : [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})
.at(0)
return { interrupted: causes.some(Cause.hasInterrupts), declines, failure }
}
+14 -20
View File
@@ -1,6 +1,6 @@
export * as SessionStore from "./store.js"
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
import { Context, Effect, Layer } from "effect"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -18,8 +18,9 @@ export interface Interface {
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
/**
* Top-level Sessions holding an execution claim. Recoverable background
* children are resumed separately through their durable Job records.
* Top-level Sessions holding an execution claim. Child (subagent) Sessions
* are excluded: a resumed parent re-runs its tool call and spawns fresh
* children, so resuming orphaned children would duplicate their work.
*/
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
/**
@@ -32,10 +33,11 @@ export interface Interface {
/** Releases the claim and resets resume accounting. Terminal events call this on commit. */
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
/**
* Clears orphaned child claims except children owned by recoverable
* background subagent jobs.
* Clears orphaned child (subagent) claims. Children are never resumed
* independently, so a dead child's claim is noise no terminal will ever
* release.
*/
readonly releaseChildClaims: (recoverable: ReadonlyArray<Session.ID>) => Effect.Effect<void>
readonly releaseChildClaims: Effect.Effect<void>
/**
* Durably counts one more resume of an orphaned claim, returning the new
* total — or undefined when the Session no longer exists.
@@ -101,20 +103,12 @@ const layer = Layer.effect(
.run()
.pipe(Effect.orDie)
}),
releaseChildClaims: Effect.fn("SessionStore.releaseChildClaims")((recoverable) =>
db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(
and(
isNotNull(SessionTable.time_suspended),
isNotNull(SessionTable.parent_id),
recoverable.length > 0 ? notInArray(SessionTable.id, Array.from(recoverable)) : undefined,
),
)
.run()
.pipe(Effect.orDie, Effect.asVoid),
),
releaseChildClaims: db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(and(isNotNull(SessionTable.time_suspended), isNotNull(SessionTable.parent_id)))
.run()
.pipe(Effect.orDie, Effect.asVoid, Effect.withSpan("SessionStore.releaseChildClaims")),
countResume: Effect.fn("SessionStore.countResume")(function* (sessionID) {
const row = yield* db
.update(SessionTable)
+2 -2
View File
@@ -134,8 +134,8 @@ const layer = () =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Teardown interrupts pending commands; it is not a terminal command failure.
yield* Deferred.interrupt(session.done)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
+2 -2
View File
@@ -271,12 +271,12 @@ function schemaMakeError(error: unknown) {
}
const validateName = (name: string) =>
/^[A-Za-z0-9_-]{1,64}$/.test(name)
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))
namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({
+51 -46
View File
@@ -115,45 +115,56 @@ export const Plugin = {
const permission = yield* Permission.Service
const config = yield* Config.Service
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(
function* (
sessionID: SessionSchema.ID,
id: string,
shellID: string,
command: string,
settled: Deferred.Deferred<Output>,
) {
const info = (yield* runtime.job.wait({ id })).info
if (!info || info.status === "running") return
const output = info.status === "completed" ? yield* Deferred.await(settled) : undefined
const text = output
? resultMessages(output).join("\n\n")
: info.status === "error"
? (info.error ?? "Command failed")
: "Command cancelled"
yield* runtime.session.synthetic({
...(info.notificationID ? { id: info.notificationID } : {}),
sessionID,
text: `<shell id="${id}" state="${info.status}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
jobID: id,
shellID,
state: info.status,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
})
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
},
Effect.forkIn(scope, { startImmediately: true }),
)
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
id: string,
shellID: string,
command: string,
settled: Deferred.Deferred<Output>,
) {
yield* runtime.job.wait({ id: id }).pipe(
Effect.flatMap((result) =>
Effect.gen(function* () {
const info = result.info
if (!info) return
const state =
info.status === "completed"
? "completed"
: info.status === "error"
? "error"
: info.status === "cancelled"
? "cancelled"
: undefined
if (state === undefined) return
const output = state === "completed" ? yield* Deferred.await(settled) : undefined
const text = output
? resultMessages(output).join("\n\n")
: state === "error"
? (info.error ?? "Command failed")
: "Command cancelled"
yield* runtime.session.synthetic({
sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
jobID: id,
shellID,
state,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
})
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
})
yield* ctx.tool
.transform((draft) =>
@@ -275,7 +286,7 @@ export const Plugin = {
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => resultMessages(output).join("\n\n")),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
@@ -283,12 +294,6 @@ export const Plugin = {
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
recovery: {
kind: "shell",
sessionID: context.sessionID,
shellID: info.id,
command: info.command,
},
run,
})
+39 -27
View File
@@ -78,6 +78,22 @@ export const Plugin = {
return text.length > 0 ? text : NO_TEXT
})
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
agent: string,
description: string,
state: "completed" | "error" | "cancelled",
text: string,
) {
yield* runtime.session.synthetic({
sessionID: parentID,
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state },
})
})
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
@@ -88,24 +104,23 @@ export const Plugin = {
const key = `${childID}:${startedAt}`
if (notifications.has(key)) return
notifications.add(key)
yield* Effect.gen(function* () {
const info = (yield* runtime.job.wait({ id: childID })).info
if (!info || info.status === "running") return
const text =
info.status === "completed"
? (info.output ?? NO_TEXT)
: info.status === "error"
? (info.error ?? "Subagent failed")
: "Subagent cancelled"
yield* runtime.session.synthetic({
...(info.notificationID ? { id: info.notificationID } : {}),
sessionID: parentID,
text: `<subagent sessionID="${childID}" state="${info.status}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state: info.status },
})
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
}).pipe(
yield* runtime.job.wait({ id: childID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed")
return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT)
if (result.info?.status === "error")
return injectCompletion(
parentID,
childID,
agent,
description,
"error",
result.info.error ?? "Subagent failed",
)
if (result.info?.status === "cancelled")
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
return Effect.void
}),
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
Effect.forkIn(scope, { startImmediately: true }),
)
@@ -224,7 +239,6 @@ export const Plugin = {
existing === undefined
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
: input.prompt,
...(background && existing === undefined ? { resume: false } : {}),
})
.pipe(
Effect.mapError(
@@ -232,19 +246,17 @@ export const Plugin = {
),
)
const run = Effect.gen(function* () {
yield* runtime.session.resume(child.id)
return yield* latestAssistantText(child.id)
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
const info = yield* runtime.job.start({
id: child.id,
type: name,
title: input.description,
metadata: {},
recovery: {
kind: "subagent",
parentSessionID: context.sessionID,
childSessionID: child.id,
agent: agent.name,
description: input.description,
},
run: runtime.session.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))),
run,
})
if (background) {
+2 -2
View File
@@ -61,8 +61,8 @@ describe("AISDKNative", () => {
})
})
test("maps Cerebras, DeepInfra, Groq, and Together AI settings, headers, and reasoning options to native providers", () => {
for (const name of ["cerebras", "deepinfra", "groq", "togetherai"]) {
test("maps Cerebras, DeepInfra, and Together AI settings, headers, and reasoning options to native providers", () => {
for (const name of ["cerebras", "deepinfra", "togetherai"]) {
expect(
map(`@ai-sdk/${name}`, {
apiKey: "secret",
+1 -174
View File
@@ -1,13 +1,11 @@
import { describe, expect } from "bun:test"
import { Job } from "@opencode-ai/core/job"
import { KV } from "@opencode-ai/core/kv"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Job.node, KV.node])))
const it = testEffect(AppNodeBuilder.build(Job.node))
describe("Job", () => {
it.live("tracks process-local work through explicit observation", () =>
@@ -147,177 +145,6 @@ describe("Job", () => {
}),
)
it.live("retains background ownership and terminal output until notification acknowledgment", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const recovery = {
kind: "shell" as const,
sessionID: SessionSchema.ID.make("ses_background_shell"),
shellID: "shell_background",
command: "echo done",
}
const job = yield* jobs.start({ type: "shell", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined()
const background = yield* jobs.background(job.id)
const running = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(running).toMatchObject({ id: job.id, recovery, status: "running" })
expect(running?.notificationID).toStartWith("msg_")
expect(background?.notificationID).toBe(running?.notificationID)
yield* Deferred.succeed(latch, undefined)
yield* jobs.wait({ id: job.id })
const completed = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(completed).toMatchObject({
id: job.id,
notificationID: running?.notificationID,
recovery,
status: "completed",
output: "done",
})
if (!completed) return yield* Effect.die("background marker missing")
yield* jobs.completeBackground(completed.notificationID)
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined()
}),
)
it.live("persists backgroundAll ownership before releasing a blocked subagent", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const parentSessionID = SessionSchema.ID.make("ses_background_parent")
const latch = yield* Deferred.make<void>()
const recovery = {
kind: "subagent" as const,
parentSessionID,
childSessionID: SessionSchema.ID.make("ses_background_child"),
agent: "explore",
description: "Explore background recovery",
}
const job = yield* jobs.start({ type: "subagent", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
const waiting = yield* jobs
.block({ id: job.id, sessionID: parentSessionID })
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
yield* jobs.backgroundAll({ sessionID: parentSessionID })
expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } })
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(marker).toMatchObject({ id: job.id, recovery, status: "running" })
if (!marker) return yield* Effect.die("background marker missing")
yield* jobs.cancel(job.id)
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toMatchObject({
notificationID: marker.notificationID,
status: "cancelled",
})
yield* jobs.completeBackground(marker.notificationID)
}),
)
it.live("retains terminal errors for recovery until notification acknowledgment", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "shell",
recovery: {
kind: "shell",
sessionID: SessionSchema.ID.make("ses_background_error"),
shellID: "shell_error",
command: "exit 1",
},
run: Deferred.await(latch).pipe(Effect.andThen(Effect.fail(new Error("shell failed")))),
})
yield* jobs.background(job.id)
yield* Deferred.succeed(latch, undefined)
yield* jobs.wait({ id: job.id })
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(marker).toMatchObject({ id: job.id, status: "error", error: "shell failed" })
if (!marker) return yield* Effect.die("background marker missing")
yield* jobs.completeBackground(marker.notificationID)
}),
)
it.live("durably backgrounds recoverable work that has already failed", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({
type: "shell",
recovery: {
kind: "shell",
sessionID: SessionSchema.ID.make("ses_immediate_error"),
shellID: "shell_immediate_error",
command: "exit 1",
},
run: Effect.fail(new Error("shell failed")),
})
expect((yield* jobs.wait({ id: job.id })).info?.status).toBe("error")
const background = yield* jobs.background(job.id)
expect(background?.notificationID).toStartWith("msg_")
expect(yield* jobs.pendingBackground).toMatchObject([
{ id: job.id, notificationID: background?.notificationID, status: "error", error: "shell failed" },
])
}),
)
it.live("recovers a background marker after its process-local registry closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const previous = yield* Job.make.pipe(Scope.provide(scope))
const job = yield* previous.start({
type: "shell",
recovery: {
kind: "shell",
sessionID: SessionSchema.ID.make("ses_background_restart"),
shellID: "shell_restart",
command: "sleep 60",
},
run: Effect.never,
})
yield* previous.background(job.id)
yield* Scope.close(scope, Exit.void)
const current = yield* Job.make
const marker = (yield* current.pendingBackground).find((item) => item.id === job.id)
expect(marker).toMatchObject({ id: job.id, status: "running" })
if (!marker) return yield* Effect.die("background marker missing")
yield* current.completeBackground(marker.notificationID)
}),
)
it.live("preserves running background ownership when its work is interrupted", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const interrupted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: SessionSchema.ID.make("ses_interrupted_parent"),
childSessionID: SessionSchema.ID.make("ses_interrupted_child"),
agent: "explore",
description: "Continue after shutdown",
},
run: Deferred.await(interrupted).pipe(Effect.andThen(Effect.interrupt)),
})
yield* jobs.background(job.id)
yield* Deferred.succeed(interrupted, undefined)
yield* jobs.wait({ id: job.id })
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(marker).toMatchObject({ id: job.id, status: "running" })
if (!marker) return yield* Effect.die("background marker missing")
yield* jobs.completeBackground(marker.notificationID)
}),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
-16
View File
@@ -915,12 +915,6 @@ describe("ModelResolver", () => {
{ reasoning: { effort: "high" } },
{ reasoning: { effort: "high" } },
],
[
"@ai-sdk/groq",
"@opencode-ai/ai/providers/groq",
{ reasoningEffort: "high", parallelToolCalls: false },
{ reasoningEffort: "high", parallelToolCalls: false },
],
[
"@ai-sdk/togetherai",
"@opencode-ai/ai/providers/togetherai",
@@ -979,7 +973,6 @@ describe("ModelResolver", () => {
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
["@ai-sdk/groq", "@opencode-ai/ai/providers/groq", "api-model"],
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
@@ -1109,11 +1102,6 @@ describe("ModelResolver", () => {
const togetherai = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/togetherai"), { settings: { reasoningEffort: "high" } }),
)
const groq = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/groq"), {
settings: { reasoningEffort: "high", parallelToolCalls: false },
}),
)
const xai = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
)
@@ -1144,10 +1132,6 @@ describe("ModelResolver", () => {
expect(togetherai.route.id).toBe("togetherai-chat")
expect(togetherai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
expect(String(togetherai.provider)).toBe("test-provider")
expect(groq.route.id).toBe("groq-chat")
expect(groq.route.protocol).toBe("groq-chat")
expect(groq.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", parallelToolCalls: false })
expect(String(groq.provider)).toBe("test-provider")
expect(xai.route.id).toBe("openai-responses")
expect(xai.route.defaults.providerOptions).toEqual({
reasoningEffort: "high",
+1 -2
View File
@@ -223,7 +223,6 @@ describe("Npm.add", () => {
).toBeTruthy()
})
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
@@ -263,7 +262,7 @@ describe("Npm.add", () => {
return yield* npm.add(mutable, { refresh: true })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
}, 30_000)
})
})
describe("Npm.resolve", () => {
@@ -7,6 +7,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
@@ -20,6 +21,7 @@ const providers = [
{ id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" },
{ id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" },
{ id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" },
{ id: "groq", plugin: GroqPlugin, package: "@ai-sdk/groq", provider: "groq.chat" },
{ id: "mistral", plugin: MistralPlugin, package: "@ai-sdk/mistral", provider: "mistral.chat" },
{ id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" },
{ id: "venice", plugin: VenicePlugin, package: "venice-ai-sdk-provider", provider: "custom-provider.chat" },
-1
View File
@@ -12,7 +12,6 @@ describe("Provider", () => {
"@opencode-ai/ai/providers/google-vertex/chat",
"@opencode-ai/ai/providers/google-vertex/responses",
"@opencode-ai/ai/providers/google-vertex/messages",
"@opencode-ai/ai/providers/groq",
"@opencode-ai/ai/providers/togetherai",
]
+6 -622
View File
@@ -4,8 +4,6 @@ import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { KV } from "@opencode-ai/core/kv"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
@@ -25,9 +23,7 @@ import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "
import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => {
@@ -64,13 +60,14 @@ describe("SessionExecution lifecycle", () => {
const idle = Session.ID.make("ses_recover_idle")
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
yield* seedSessions(database, [idle])
// Children recover through background Job records, never through the root claim sweep.
// An orphaned child is never resumed: the resumed parent re-runs its
// tool call and spawns a fresh child instead.
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
expect(yield* store.listSuspended()).toEqual([parent])
// The sweep clears orphaned child claims outright; parents keep theirs.
yield* store.releaseChildClaims([])
yield* store.releaseChildClaims
expect(yield* claims(database)).toEqual({ [parent]: true, [child]: false, [idle]: false })
}),
)
@@ -150,66 +147,6 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("does not resume a user-cancelled background child whose notification was not admitted", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const parent = Session.ID.make("ses_cancelled_background_parent")
const child = Session.ID.make("ses_cancelled_background_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent })
const running = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const jobs = yield* Job.make.pipe(Scope.provide(scope))
const context = yield* buildExecution(
scope,
() => Deferred.succeed(running, undefined).pipe(Effect.andThen(Effect.never)),
undefined,
jobs,
)
const execution = Context.get(context, SessionExecution.Service)
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "general",
description: "Cancelled inspection",
},
run: execution.resume(child).pipe(Effect.as("unused")),
})
yield* jobs.background(child)
yield* Deferred.await(running)
expect(yield* execution.interrupt(child)).toBeTrue()
yield* execution.awaitIdle(child)
expect((yield* jobs.wait({ id: child })).info?.status).toBe("cancelled")
expect(yield* jobs.pendingBackground).toMatchObject([{ id: child, status: "cancelled" }])
expect((yield* claims(database))[child]).toBe(false)
yield* Scope.close(scope, Exit.void)
const restartedScope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(restartedScope, Exit.void))
const restartedJobs = yield* Job.make.pipe(Scope.provide(restartedScope))
const drained: Session.ID[] = []
const restarted = yield* buildExecution(
restartedScope,
({ sessionID }) => Effect.sync(() => void drained.push(sessionID)),
undefined,
restartedJobs,
)
yield* Context.get(restarted, SessionRestart.Service).resumeSuspendedSessions
yield* Context.get(restarted, SessionExecution.Service).awaitIdle(parent)
expect(drained).toEqual([parent])
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{ payload: { text: expect.stringContaining("Subagent cancelled"), metadata: { state: "cancelled" } } },
])
expect(yield* restartedJobs.pendingBackground).toEqual([])
}),
)
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
@@ -367,518 +304,6 @@ describe("SessionExecution lifecycle", () => {
)
})
describe("SessionRestart background recovery", () => {
it.effect("admits orphaned shell notices without waking and delivers them once on the next run", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
const jobs = yield* Job.Service
const bus = yield* Bus.Service
const parent = Session.ID.make("ses_background_recovery_parent")
const child = Session.ID.make("ses_background_recovery_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
yield* seedBackground(jobs, parent, [
{ id: "call-background-shell", shellID: "sh_background_orphan", command: "sleep 60" },
])
yield* seedBackground(jobs, child, [{ id: "call-child-shell", shellID: "sh_child_orphan", command: "sleep 30" }])
expect(yield* store.listSuspended()).toEqual([])
expect(yield* jobs.pendingBackground).toHaveLength(2)
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.sync(() => void drained.push(sessionID)).pipe(
Effect.andThen(SessionInbox.promote(database.db, bus, sessionID, "steer")),
Effect.asVoid,
),
undefined,
restarted,
)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{
type: "synthetic",
payload: {
description: "sleep 60",
text: expect.stringContaining("server restarted"),
metadata: {
source: "shell",
jobID: "call-background-shell",
shellID: "sh_background_orphan",
state: "cancelled",
},
},
},
])
expect(yield* SessionInbox.list(database.db, child)).toMatchObject([
{
type: "synthetic",
payload: {
metadata: {
source: "shell",
jobID: "call-child-shell",
shellID: "sh_child_orphan",
state: "cancelled",
},
},
},
])
expect(drained).toEqual([])
expect(yield* claims(database)).toEqual({ [parent]: false, [child]: false })
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(drained).toEqual([])
const execution = Context.get(context, SessionExecution.Service)
yield* execution.resume(parent)
expect(yield* SessionInbox.list(database.db, parent)).toEqual([])
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toHaveLength(1)
yield* execution.resume(parent)
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toHaveLength(1)
}),
)
it.effect("preserves locally running background work", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_background_existing_parent")
yield* seedSessions(database, [parent])
yield* seedBackground(jobs, parent, [{ id: "call-running-shell", shellID: "sh_running", command: "sleep 60" }])
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () => Effect.void)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toEqual([])
expect(yield* jobs.get("call-running-shell")).toMatchObject({ status: "running" })
expect(yield* jobs.pendingBackground).toHaveLength(1)
}),
)
it.effect("preserves a silent shell failure persisted before its completion notification", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const sessionID = Session.ID.make("ses_background_completed_shell")
yield* seedSessions(database, [sessionID])
const complete = yield* Deferred.make<string>()
yield* jobs.start({
id: "call-completed-shell",
type: "shell",
recovery: {
kind: "shell",
sessionID,
shellID: "sh_completed",
command: "exit 7",
},
run: Deferred.await(complete),
})
yield* jobs.background("call-completed-shell")
yield* Deferred.succeed(complete, "(no output)\n\nCommand exited with code 7.")
yield* jobs.wait({ id: "call-completed-shell" })
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(yield* SessionInbox.list(database.db, sessionID)).toMatchObject([
{
type: "synthetic",
payload: {
text: expect.stringContaining("(no output)\n\nCommand exited with code 7."),
metadata: { source: "shell", shellID: "sh_completed", state: "completed" },
},
},
])
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
for (const delivered of [false, true]) {
it.effect(`does not duplicate a shell notification already ${delivered ? "delivered" : "admitted"}`, () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const sessionID = Session.ID.make("ses_shell_notification_retry")
yield* seedSessions(database, [sessionID])
yield* seedBackground(jobs, sessionID, [
{ id: "call-shell-notified", shellID: "sh_notified", command: "echo done" },
])
const background = (yield* jobs.pendingBackground)[0]
if (!background) return yield* Effect.die("background record missing")
yield* sessions.synthetic({
id: background.notificationID,
sessionID,
text: "Command already completed",
metadata: { source: "shell", shellID: "sh_notified", state: "completed" },
resume: false,
})
if (delivered) yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
expect(yield* sessions.messages({ sessionID })).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
text: "Command already completed",
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
it.effect("acknowledges recovery markers when their owning session is deleted", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const sessionID = Session.ID.make("ses_background_deleted")
yield* seedSessions(database, [sessionID])
yield* seedBackground(jobs, sessionID, [{ id: "call-deleted-shell", shellID: "sh_deleted", command: "sleep 60" }])
yield* database.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
it.effect("delivers cancellation at the resumed parent's next step", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const store = yield* SessionStore.Service
const bus = yield* Bus.Service
const parent = Session.ID.make("ses_background_claimed_parent")
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
yield* seedBackground(jobs, parent, [{ id: "call-claimed-shell", shellID: "sh_claimed", command: "sleep 60" }])
const observed = yield* Deferred.make<string[]>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
SessionInbox.promote(database.db, bus, sessionID, "steer").pipe(
Effect.andThen(store.context(sessionID)),
Effect.orDie,
Effect.flatMap((messages) =>
Deferred.succeed(
observed,
messages.filter((message) => message.type === "synthetic").map((message) => message.text),
),
),
Effect.asVoid,
),
undefined,
restarted,
)
const execution = Context.get(context, SessionExecution.Service)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(yield* Deferred.await(observed)).toEqual([
"The server restarted while you were working. Continue from where you left off without repeating completed work.",
expect.stringContaining("Command cancelled because the server restarted"),
])
yield* execution.awaitIdle(parent)
expect(yield* SessionInbox.list(database.db, parent)).toEqual([])
expect((yield* claims(database))[parent]).toBe(false)
}),
)
it.effect("resumes a background subagent and notifies its parent exactly once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_subagent_recovery_parent")
const child = Session.ID.make("ses_subagent_recovery_child")
const unrelated = Session.ID.make("ses_subagent_unrelated_child")
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: 1 })
yield* seedSessions(database, [child, unrelated], { parent_id: parent, time_suspended: Date.now() })
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "explore",
description: "Inspect recovery",
},
run: Effect.never,
})
yield* jobs.background(child)
const resumed = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const parentResumed = yield* Deferred.make<void>()
const parentWoken = yield* Deferred.make<void>()
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.gen(function* () {
drained.push(sessionID)
if (sessionID === child) {
yield* Deferred.succeed(resumed, undefined)
yield* Deferred.await(release)
return
}
yield* Deferred.succeed(
drained.filter((id) => id === parent).length === 1 ? parentResumed : parentWoken,
undefined,
)
}),
undefined,
restarted,
)
const restart = Context.get(context, SessionRestart.Service)
const execution = Context.get(context, SessionExecution.Service)
yield* restart.resumeSuspendedSessions
yield* Deferred.await(resumed)
yield* Deferred.await(parentResumed)
yield* execution.awaitIdle(parent)
yield* restart.resumeSuspendedSessions
expect(drained.toSorted()).toEqual([child, parent].toSorted())
expect(yield* claims(database)).toEqual({ [parent]: false, [child]: true, [unrelated]: false })
expect(yield* attempts(database, child)).toBe(1)
expect(yield* restarted.get(child)).toMatchObject({ status: "running" })
yield* Deferred.succeed(release, undefined)
yield* Deferred.await(parentWoken)
expect(drained.filter((id) => id === child)).toHaveLength(1)
expect(drained.filter((id) => id === parent)).toHaveLength(2)
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{
payload: {
description: "Inspect recovery",
metadata: { source: "subagent", childID: child, agent: "explore", state: "completed" },
},
},
])
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
}),
)
it.effect("delivers a subagent result persisted before restart without rerunning the child", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_subagent_completed_parent")
const child = Session.ID.make("ses_subagent_completed_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent })
const complete = yield* Deferred.make<string>()
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "explore",
description: "Completed inspection",
},
run: Deferred.await(complete),
})
yield* jobs.background(child)
yield* Deferred.succeed(complete, "Recovered result")
yield* jobs.wait({ id: child })
const parentWoken = yield* Deferred.make<void>()
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.sync(() => void drained.push(sessionID)).pipe(
Effect.andThen(Deferred.succeed(parentWoken, undefined)),
),
undefined,
restarted,
)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* Deferred.await(parentWoken)
expect(drained).toEqual([parent])
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{ payload: { text: expect.stringContaining("Recovered result"), metadata: { state: "completed" } } },
])
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
for (const resumeAttempts of [1, 2]) {
it.effect(`honors a suspended parent's restart budget after ${resumeAttempts} attempts before notifying it`, () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_subagent_budget_parent")
const children = [
Session.ID.make("ses_subagent_budget_child_1"),
Session.ID.make("ses_subagent_budget_child_2"),
]
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: resumeAttempts })
yield* seedSessions(database, children, { parent_id: parent })
const complete = yield* Deferred.make<string>()
for (const child of children) {
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "explore",
description: "Completed inspection",
},
run: Deferred.await(complete),
})
yield* jobs.background(child)
}
yield* Deferred.succeed(complete, "Recovered result")
yield* Effect.forEach(children, (id) => jobs.wait({ id }), { discard: true })
const draining = yield* Deferred.make<number | undefined>()
const release = yield* Deferred.make<void>()
const drained: Session.ID[] = []
const continued: Session.ID[] = []
yield* bus.project(SessionEvent.Synthetic, (event) =>
Effect.sync(() => void continued.push(event.data.sessionID)),
)
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Scope.provide(scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.gen(function* () {
drained.push(sessionID)
yield* Deferred.succeed(draining, yield* attempts(database, sessionID))
yield* Deferred.await(release)
}),
{ maxAttempts: 2 },
restarted,
)
const restart = Context.get(context, SessionRestart.Service)
const execution = Context.get(context, SessionExecution.Service)
yield* restart.resumeSuspendedSessions
if (resumeAttempts < 2) {
expect(yield* Deferred.await(draining)).toBe(2)
expect(drained).toEqual([parent])
expect(continued).toEqual([parent])
yield* Deferred.succeed(release, undefined)
yield* execution.awaitIdle(parent)
}
if (resumeAttempts === 2) {
expect(drained).toEqual([])
expect(continued).toEqual([])
}
expect((yield* claims(database))[parent]).toBe(false)
expect(yield* attempts(database, parent)).toBe(0)
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(2)
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(drained).toHaveLength(resumeAttempts < 2 ? 1 : 0)
}),
)
}
it.effect("terminalizes a recovered subagent that exhausts its resume budget", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_subagent_exhausted_parent")
const child = Session.ID.make("ses_subagent_exhausted_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now(), resume_attempts: 2 })
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "explore",
description: "Exhausted inspection",
},
run: Effect.never,
})
yield* jobs.background(child)
const parentWoken = yield* Deferred.make<void>()
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.sync(() => void drained.push(sessionID)).pipe(
Effect.andThen(Deferred.succeed(parentWoken, undefined)),
),
{ maxAttempts: 2 },
restarted,
)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* Deferred.await(parentWoken)
expect(drained).toEqual([parent])
expect((yield* claims(database))[child]).toBe(false)
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{
payload: {
text: expect.stringContaining("will not be resumed automatically"),
metadata: { source: "subagent", childID: child, state: "error" },
},
},
])
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
})
describe("SessionExecution interrupt continuation", () => {
it.effect("resumes only steering input after an interrupt with continue", () =>
Effect.gen(function* () {
@@ -1021,27 +446,6 @@ describe("SessionExecution interrupt continuation", () => {
)
})
function seedBackground(
jobs: Job.Interface,
sessionID: Session.ID,
background: ReadonlyArray<{ readonly id: string; readonly shellID: string; readonly command: string }>,
) {
return Effect.forEach(
background,
(job) =>
Effect.gen(function* () {
yield* jobs.start({
id: job.id,
type: "shell",
recovery: { kind: "shell", sessionID, shellID: job.shellID, command: job.command },
run: Effect.never,
})
yield* jobs.background(job.id)
}),
{ discard: true },
)
}
/** Plain deliveries seed user prompts; objects seed control items. */
function seedInbox(
database: Database.Service["Service"],
@@ -1127,32 +531,14 @@ function buildExecution(
scope: Scope.Closeable,
drain: (input: Parameters<SessionRunner.Interface["drain"]>[0]) => Effect.Effect<void, SessionRunner.RunError>,
options?: SessionRestart.Options,
overrideJobs?: Job.Interface,
) {
return Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const jobs = overrideJobs ?? (yield* Job.Service)
const sessions = yield* Session.Service
const sessionLayer = Layer.effect(
Session.Service,
Effect.gen(function* () {
const execution = yield* SessionExecution.Service
return Session.Service.of({
...sessions,
synthetic: (input) =>
sessions
.synthetic({ ...input, resume: false })
.pipe(Effect.tap(() => (input.resume === false ? Effect.void : execution.wake(input.sessionID)))),
})
}),
)
const runner = Layer.succeed(
SessionRunner.Service,
SessionRunner.Service.of({
drain: (input) => drain(input).pipe(Effect.as(SessionRunner.DrainResult.Complete())),
}),
SessionRunner.Service.of({ drain: (input) => drain(input).pipe(Effect.as({ type: "complete" as const })) }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
@@ -1165,12 +551,10 @@ function buildExecution(
)
return yield* Layer.buildWithScope(
SessionRestart.layer(options).pipe(
Layer.provideMerge(sessionLayer),
Layer.provideMerge(Layer.fresh(SessionExecution.layer)),
Layer.provideMerge(SessionExecution.layer),
Layer.provide(Layer.succeed(Database.Service, database)),
Layer.provide(Layer.succeed(Bus.Service, bus)),
Layer.provide(Layer.succeed(SessionStore.Service, store)),
Layer.provide(Layer.succeed(Job.Service, jobs)),
Layer.provide(locations),
),
scope,
@@ -128,35 +128,6 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
})
})
test("interrupted subagent failures expose their existing child session to the model", async () => {
const { published, publisher } = capture("anthropic", { interruptProgress: true })
const subagent = LLMEvent.toolCall({
id: "call-subagent",
name: "subagent",
input: { agent: "general", description: "Recover child", prompt: "Continue working" },
})
await Effect.runPromise(publisher.publish(subagent))
await Effect.runPromiseExit(publisher.progress(subagent.id, { sessionID: "ses_existing_child", status: "running" }))
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
error: { type: "aborted", message: "Tool execution interrupted (sessionID: ses_existing_child)" },
metadata: { sessionID: "ses_existing_child", status: "running" },
})
})
test("interrupted non-subagent failures do not expose their progress session IDs", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.progress(call.id, { sessionID: "ses_private", status: "running" }))
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
error: { type: "aborted", message: "Tool execution interrupted" },
metadata: { sessionID: "ses_private", status: "running" },
})
})
test("local failure metadata completes the progress snapshot", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
@@ -371,7 +342,7 @@ test("step finish records settlement without publishing step ended", async () =>
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" } })))
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
})
@@ -85,10 +85,8 @@ describe("Tool", () => {
it.effect("rejects invalid and colliding normalized names", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
for (const name of ["", "x".repeat(65)]) {
const invalid = yield* transform(service, { [name]: make() }, { codemode: false }).pipe(Effect.flip)
expect(invalid.message).toBe(`Invalid tool name: ${name}`)
}
const invalid = yield* transform(service, { "123": make() }, { codemode: false }).pipe(Effect.flip)
expect(invalid.message).toBe("Invalid tool name: 123")
const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }).pipe(
Effect.flip,
@@ -98,67 +96,6 @@ describe("Tool", () => {
}),
)
it.effect("executes native tools without requiring letter-leading names or namespace segments", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(
service,
{ "2d_get_scene": make(), "123": make(), _lookup: make(), "-lookup": make() },
{ codemode: false },
)
yield* transform(service, { "2d_get_scene": make() }, { namespace: "123._private.-tools", codemode: false })
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual([
"-lookup",
"123",
"123__private_-tools_2d_get_scene",
"2d_get_scene",
"_lookup",
"execute",
])
for (const name of ["2d_get_scene", "123", "_lookup", "-lookup", "123__private_-tools_2d_get_scene"]) {
expect((yield* snapshot.execute(call(name))).output).toEqual({ text: name })
}
}),
)
it.effect("executes Code Mode tools without requiring letter-leading names or namespace segments", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { "2d_get_scene": make(), "123": make(), _lookup: make(), "-lookup": make() })
yield* transform(service, { "2d_get_scene": make() }, { namespace: "123._private.-tools", codemode: true })
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual([
"-lookup",
"123",
"123._private.-tools.2d_get_scene",
"2d_get_scene",
"_lookup",
])
const result = yield* snapshot.execute({
...call("execute"),
call: {
type: "tool-call",
id: "call-nonletter-names",
name: "execute",
input: {
code: `const results = await Promise.all([
tools["2d_get_scene"]({ text: "digit" }),
tools["123"]({ text: "numeric" }),
tools._lookup({ text: "underscore" }),
tools["-lookup"]({ text: "hyphen" }),
tools["123"]._private["-tools"]["2d_get_scene"]({ text: "namespaced" }),
]); return results.map(result => result.text).join(",");`,
},
},
})
expect(result.content).toEqual([{ type: "text", text: "digit,numeric,underscore,hyphen,namespaced" }])
}),
)
it.effect("validates a registration batch before installing any tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+15 -184
View File
@@ -76,10 +76,10 @@ import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import { ID } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq, sql } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { agentHost, catalogHost, host } from "./plugin/host"
@@ -419,7 +419,7 @@ const execution = Layer.effect(
.drain({ sessionID, force, continuation })
.pipe(
Effect.flatMap((result) =>
result._tag === "Complete" ? Effect.void : drain(sessionID, false, result.continuation),
result.type === "complete" ? Effect.void : drain(sessionID, false, result.continuation),
),
)
}
@@ -2926,41 +2926,25 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("consumes the full provider stream before recording its boundary and settling local tools", () =>
it.effect("records the stream boundary before local tools complete", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Echo this")
const tail = yield* Deferred.make<void>()
const complete = yield* Deferred.make<void>()
const finished = yield* Deferred.make<void>()
yield* TestLLM.push(
Stream.fromIterable(TestLLM.tool("call-streamed", "echo", { text: "hello" })).pipe(
Stream.concat(
Stream.fromEffect(Deferred.succeed(tail, undefined).pipe(Effect.andThen(Deferred.await(complete)))).pipe(
Stream.drain,
),
),
Stream.onEnd(Deferred.succeed(finished, undefined)),
),
TestLLM.stop(),
)
yield* TestLLM.push(TestLLM.tool("call-streamed", "echo", { text: "hello" }), TestLLM.stop())
const tools = yield* blockTools()
const streamed = yield* bus.subscribe(SessionEvent.Step.Streamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const streamed = yield* bus
.subscribe(SessionEvent.Step.Streamed)
.pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runDrain,
Effect.forkScoped({ startImmediately: true }),
)
const run = yield* Effect.forkChild(session.resume(sessionID))
yield* tools.started
yield* Deferred.await(tail)
expect(requests).toHaveLength(1)
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.streamed.1")
expect(requireAssistant(yield* session.context(sessionID)).time.completed).toBeUndefined()
yield* Deferred.succeed(complete, undefined)
yield* Fiber.join(streamed)
expect(yield* Deferred.isDone(finished)).toBe(true)
const assistant = requireAssistant(yield* session.context(sessionID))
expect(assistant.time.streamed).toBeDefined()
expect(assistant.time.completed).toBeUndefined()
@@ -2968,10 +2952,6 @@ describe("SessionRunnerLLM", () => {
yield* tools.release
yield* Fiber.join(run)
const events = yield* recordedEventTypes(sessionID)
expect(events.indexOf("session.step.streamed.1")).toBeLessThan(events.indexOf("session.tool.success.2"))
expect(events.indexOf("session.tool.success.2")).toBeLessThan(events.indexOf("session.step.ended.1"))
expect(events.filter((type) => type === "session.step.streamed.1")).toHaveLength(2)
}),
)
@@ -3640,81 +3620,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("preserves a stale subagent child session in its model-visible failure", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const database = yield* Database.Service
yield* admit(session, "Recover interrupted subagent")
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: Agent.ID.make("build"),
model: { id: ID.make("fake-model"), providerID: Provider.ID.make("fake") },
})
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
id: "call-interrupted-subagent",
name: "subagent",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID,
id: "call-interrupted-subagent",
text: '{"agent":"general"}',
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
id: "call-interrupted-subagent",
input: { agent: "general" },
executed: false,
})
yield* database.db
.update(SessionMessageTable)
.set({
data: sql`json_set(
${SessionMessageTable.data},
'$.content[0].state.metadata',
json('{"sessionID":"ses_existing_child","status":"running","internal":"private"}')
)`,
})
.where(eq(SessionMessageTable.id, assistantMessageID))
.run()
.pipe(Effect.orDie)
requests.length = 0
yield* TestLLM.push([])
yield* session.resume(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Recover interrupted subagent" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-interrupted-subagent",
state: {
status: "error",
error: {
type: "aborted",
message: "Tool execution interrupted: subagent (sessionID: ses_existing_child)",
},
metadata: { sessionID: "ses_existing_child", status: "running", internal: "private" },
},
},
],
},
])
const modelResult = JSON.stringify(requests[0]?.messages.at(-1))
expect(modelResult).toContain("ses_existing_child")
expect(modelResult).not.toContain("private")
}),
)
it.effect("durably fails hosted tools left running by a prior process before continuing inline", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -4360,24 +4265,16 @@ describe("SessionRunnerLLM", () => {
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Interrupt tool settlement")
const tools = yield* blockTools()
yield* TestLLM.push(TestLLM.tool("call-await-interrupt", "echo", { text: "blocked" }))
const streamed = yield* bus.subscribe(SessionEvent.Step.Streamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const runner = yield* SessionRunner.Service
const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
yield* tools.started
yield* Fiber.join(streamed)
yield* Fiber.interrupt(run)
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Interrupt tool settlement" },
{
@@ -4394,11 +4291,8 @@ describe("SessionRunnerLLM", () => {
},
])
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes.filter((type) => type === "session.tool.failed.2")).toHaveLength(1)
expect(eventTypes.filter((type) => type === "session.step.failed.1")).toHaveLength(1)
expect(eventTypes).toContain("session.step.failed.1")
expect(eventTypes).not.toContain("session.step.ended.1")
expect(eventTypes).not.toContain("session.retry.scheduled.1")
expect(requests).toHaveLength(1)
}),
)
@@ -4678,30 +4572,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not start another physical attempt after interruption during retry backoff", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Interrupt retry backoff")
yield* TestLLM.push(Stream.fail(providerUnavailable()), TestLLM.text("Must not run", "unused-retry"))
const scheduled = yield* bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Fiber.join(scheduled)
yield* session.interrupt(sessionID)
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
yield* TestClock.adjust("1 minute")
expect(requests).toHaveLength(1)
const events = yield* recordedEventTypes(sessionID)
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1)
expect(events).not.toContain("session.synthetic.1")
}),
)
it.effect("immediately rebuilds once after explicit continuation rejection", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -5051,45 +4921,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("shares retry accounting and assistant identity across transparent retries and partial continuations", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const scheduled = yield* Queue.unbounded<SessionMessage.ID>()
yield* bus.subscribe(SessionEvent.RetryScheduled).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.runForEach((event) => Queue.offer(scheduled, event.data.assistantMessageID)),
Effect.forkScoped({ startImmediately: true }),
)
yield* admit(session, "Mix retry paths")
const failure = incompleteStream()
const partial = TestLLM.failAfter(
failure,
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "mixed-partial" }),
LLMEvent.textDelta({ id: "mixed-partial", text: "Partial" }),
)
yield* TestLLM.push(Stream.fail(failure), partial, Stream.fail(failure), partial, partial)
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
const identities: SessionMessage.ID[] = []
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
identities.push(yield* Queue.take(scheduled))
yield* TestClock.adjust(delay)
}
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
expect(requests).toHaveLength(5)
expect(identities[0]).toBe(identities[1])
expect(identities[2]).toBe(identities[3])
expect(identities[0]).not.toBe(identities[2])
const messages = yield* session.context(sessionID)
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(3)
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(2)
const events = yield* recordedEventTypes(sessionID)
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(4)
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(3)
}),
)
it.effect("stops incomplete stream continuations after five total attempts", () =>
Effect.gen(function* () {
const session = yield* setup
-140
View File
@@ -1,140 +0,0 @@
import { expect } from "bun:test"
import { LanguageModel, LLM, LLMClient, LLMEvent } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStep } from "@opencode-ai/core/session/runner/step"
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { asc, eq } from "drizzle-orm"
import { Effect, Exit, Layer } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(
Layer.merge(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, ToolOutput.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
TestLLM.layer(),
),
)
for (const finish of ["stop", "content-filter"] as const) {
it.effect(`settles ${finish} with snapshot files and nonzero usage after its tool`, () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const llm = yield* TestLLM.Service
const sessionID = Session.ID.create()
const assistantMessageID = SessionMessage.ID.create()
const start = Snapshot.ID.make("before")
const end = Snapshot.ID.make("after")
const files = [RelativePath.make("changed.ts")]
let captures = 0
const steps = yield* SessionStep.make.pipe(
Effect.provideService(LLMClient.Service, llm.client),
Effect.provide(
Layer.mock(Snapshot.Service)({
capture: () => Effect.sync(() => (captures++ === 0 ? start : end)),
files: (input) => {
expect(input).toEqual({ from: start, to: end })
return Effect.succeed(files)
},
}),
),
)
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({ id: sessionID, project_id: Project.ID.global, slug: "step", directory: "/project", version: "test" })
.run()
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "test-model", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 100_000, output: 1_000 },
cost: [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) },
},
],
},
)
yield* llm.push(
TestLLM.complete(
{
reason: { normalized: finish },
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
},
LLMEvent.toolCall({ id: "call-test", name: "test", input: {} }),
),
)
const result = yield* steps
.attempt({
sessionID,
assistantMessageID,
agent: Agent.defaultID,
model,
prepared: {
request: LLM.request({ model: model.model, prompt: "Run one tool" }),
options: {},
executeTool: () => Effect.succeed({ content: "Completed tool" }),
},
toolsDisabled: false,
recoverContinuation: true,
recoverOverflow: Effect.succeed(false),
})
.pipe(Effect.exit)
expect(Exit.isSuccess(result)).toBe(finish === "stop")
expect(llm.requests).toHaveLength(1)
expect(captures).toBe(2)
const message = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, assistantMessageID))
.get()
expect(message?.data).toMatchObject({
finish,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
snapshot: { start, end, files },
content: [{ type: "tool", state: { status: "completed" } }],
})
expect(message?.data).toHaveProperty("cost", expect.closeTo(0.0000233, 10))
const events = yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
const types = events.map((event) => event.type)
const terminal = finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
expect(types.filter((type) => type === terminal)).toHaveLength(1)
expect(types.indexOf("session.tool.success.2")).toBeLessThan(types.indexOf(terminal))
}),
)
}
-42
View File
@@ -790,48 +790,6 @@ describe("ShellTool", () => {
),
)
it.live("persists a silent command that finishes before backgrounding", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const shell = yield* Shell.Service
const persisted = yield* Deferred.make<readonly Job.Background[]>()
yield* bus.project(SessionEvent.InboxEnqueued, (event) =>
event.data.sessionID === sessionID && event.data.item.type === "synthetic"
? jobs.pendingBackground.pipe(
Effect.flatMap((background) => Deferred.succeed(persisted, background)),
Effect.asVoid,
)
: Effect.void,
)
yield* executeTool(registry, {
...call({ command: "exit 7", background: true }, "call-background-silent-nonzero"),
// The command can finish while its initial progress update is being published.
progress: (update) =>
typeof update.shellID === "string"
? shell.wait(ShellSchema.ID.make(update.shellID)).pipe(Effect.orDie, Effect.asVoid)
: Effect.void,
})
expect(yield* Deferred.await(persisted)).toMatchObject([
{
id: "call-background-silent-nonzero",
status: "completed",
output: "(no output)\n\nCommand exited with code 7.",
},
])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live(
"updates and clears a running shell timeout",
() =>
+3 -3
View File
@@ -38,9 +38,9 @@ try {
const archive = join(temporary, `${name}.tgz`)
if (pkg.dependencies) {
const unpacked = Object.entries(pkg.dependencies)
.filter(([dependency, version]) => version.startsWith("workspace:") && !archives.has(dependency))
.map(([dependency]) => dependency)
const unpacked = Object.keys(pkg.dependencies).filter(
(dependency) => dependency.startsWith("@opencode-ai/") && !archives.has(dependency),
)
if (unpacked.length > 0)
throw new Error(`${pkg.name} has unpacked workspace dependencies: ${unpacked.join(", ")}`)
pkg.dependencies = Object.fromEntries(
-2
View File
@@ -1,2 +0,0 @@
component-tests/test-results
component-tests/playwright-report
@@ -1,127 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
for (const expanded of [false, true]) {
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { expanded } })
const trigger = expanded
? timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]')
: timeline.getByRole("button", { name: "Used Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Update output" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Append sibling" }).click()
await expect(timeline.getByText("Sibling content", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Mark session busy" }).click()
await timeline.getByRole("button", { name: "Mark session idle" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("transitions a streaming shell from writing through command execution", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { streaming: true } })
const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]')
const title = tool.locator('[data-slot="basic-tool-tool-title"]')
const shimmer = title.locator('[data-component="text-shimmer"]')
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shimmer).toHaveAttribute("aria-label", "Shell")
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("Writing command...")
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", /^Inter,/)
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", /^Inter,/)
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
await timeline.getByRole("button", { name: "Complete input" }).click()
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("printf ready")
await expect(tool).not.toContainText("Writing command...")
await timeline.getByRole("button", { name: "Run command" }).click()
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("printf ready")
await expect(tool).not.toContainText("Writing command...")
await timeline.getByRole("button", { name: "Complete command" }).click()
const summary = timeline.getByRole("button", { name: "Used Shell", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await summary.click()
await expect(subtitle).toHaveText("printf ready")
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("shimmers and expands a running shell command", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { streaming: true } })
await timeline.getByRole("button", { name: "Run command" }).click()
const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]')
const trigger = tool.locator('[data-slot="collapsible-trigger"]')
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool).not.toContainText("Writing command...")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText("printf ready")
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await expect(trigger).toHaveCSS("height", "28px")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("transitions thinking and hidden reasoning through busy to idle", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } })
const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]')
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(reasoning).toHaveCount(0)
await timeline.getByRole("button", { name: "Start shell" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
await timeline.getByRole("button", { name: "Finish session" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(reasoning).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("moves busy through retry and recovery to final idle content", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "retry" } })
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.getByRole("button", { name: "Retry request" }).click()
await expect(timeline.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.getByRole("button", { name: "Recover request" }).click()
await expect(timeline.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.getByRole("button", { name: "Finish response" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-part-id="msg_retry_recovery_lifecycle:text:0"]')).toContainText(
"Recovered response",
)
})
for (const locale of ["de", "ar"] as const) {
// Moved from packages/app/e2e/regression/session-timeline-locale-projection.spec.ts
story(`projects localized tool names with an English fallback in ${locale}`, async ({ mount, page }) => {
const timeline = await mount("current-session-research-agents--agent-research", {
args: { scenario: "exploration" },
globals: { locale },
})
await timeline.getByRole("button", { name: "Complete read" }).click()
await timeline.getByRole("button", { name: "Complete glob" }).click()
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -1,61 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-collapse-state.spec.ts
story("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "streaming" } })
const tool = timeline.locator('[data-timeline-part-id="tool_edit_status"]')
const trigger = tool.locator('[data-scope="apply-patch"] button')
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await tool.evaluate((element) => ((element as HTMLElement).dataset.regressionMarker = "before-stream"))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Stream sibling content" }).click()
await expect(timeline.getByText("Streaming added a later assistant text part.", { exact: true })).toBeVisible()
await expect(tool).toHaveAttribute("data-regression-marker", "before-stream")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(tool.locator("xpath=ancestor::*[@data-timeline-row]")).toHaveAttribute(
"data-timeline-row",
"AssistantPart",
)
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders interruption independently when the turn is not compacted", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "interruption" } })
await expect(timeline.getByText("Interrupted", { exact: true })).toBeVisible()
await expect(timeline.getByText("Before", { exact: true })).toBeVisible()
await expect(timeline.getByText("After", { exact: true })).toBeVisible()
const rows = await timeline
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders aliased and long custom model notices", async ({ mount, page }) => {
await page.setViewportSize({ width: 420, height: 700 })
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "models" } })
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
const short = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const long = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(short).toBeVisible()
await expect(short.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(short.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(timeline.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(short.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long).toBeVisible()
await expect(long.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(long.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => long.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders user image, file attachment, file reference, and agent reference", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "attachments" } })
await expect(timeline.getByAltText("pixel.png")).toBeVisible()
await expect(timeline.getByText("tsconfig.json")).toBeVisible()
await expect(timeline.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(timeline.getByText("@explore", { exact: true })).toBeVisible()
})
@@ -1,72 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders current protocol notices in CLI order", async ({ mount, page }) => {
const warnings: string[] = []
page.on("console", (message) => {
if (message.text().includes("computations created outside a `createRoot` or `render`"))
warnings.push(message.text())
})
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "notices" } })
const notices = timeline.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
await expect(notices).toHaveClass([/text-text-weak/, /text-text-weak/, /text-text-weak/, /text-text-weak/])
await expect(notices.locator(".text-text-strong")).toHaveCount(0)
expect(warnings).toEqual([])
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders a compaction summary while it streams and after completion", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "compaction" } })
const compaction = timeline.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.getByRole("button", { name: "Stream summary" }).click()
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await timeline.getByRole("button", { name: "Complete summary" }).click()
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("updates running compactions to failed and cancelled boundaries", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "compaction" } })
await timeline.getByRole("button", { name: "Stream summary" }).click()
await timeline.getByRole("button", { name: "Fail compaction" }).click()
const compactions = timeline.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Streamed implementation details.")
await timeline.getByRole("button", { name: "Cancel next compaction" }).click()
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("shows a delegating row while subagent input streams", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "delegation" } })
const delegating = timeline.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
await expect(timeline.locator('[data-component="task-tool-card"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("waits for completion before labeling requested background work", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "background" } })
await expect(timeline.locator('[data-component="task-tool-card"]')).toContainText("Inspect code")
await expect(timeline.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
})
@@ -1,50 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor when code is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor when a line number is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await expect(review.getByText("export const first = 1", { exact: true })).toBeVisible()
const number = review.locator('[data-column-number="1"]')
await expect(number).toHaveCount(1)
await number.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor for a line number range", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
const first = review.locator('[data-column-number="1"]')
const last = review.locator('[data-column-number="3"]')
await expect(first).toHaveCount(1)
await expect(last).toHaveCount(1)
await first.dragTo(last)
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("shows a comment button when a diff line is hovered", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
const line = review.getByText("export const first = 1", { exact: true })
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.hover()
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
@@ -1,42 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "location" } })
const notice = timeline.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
@@ -1,74 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
const profiles = [
{ name: "summaries off no reasoning", summaries: false, reasoning: "none", tool: false, thinking: true, body: false },
{
name: "summaries off reasoning heading",
summaries: false,
reasoning: "heading",
tool: false,
thinking: true,
body: false,
heading: true,
},
{
name: "summaries off with visible tool",
summaries: false,
reasoning: "heading",
tool: true,
thinking: true,
body: false,
heading: true,
},
{ name: "summaries on no content", summaries: true, reasoning: "none", tool: false, thinking: true, body: false },
{
name: "summaries on blank reasoning",
summaries: true,
reasoning: "blank",
tool: false,
thinking: true,
body: false,
},
{
name: "summaries on visible reasoning",
summaries: true,
reasoning: "heading",
tool: false,
thinking: false,
body: true,
},
{
name: "summaries on visible tool no reasoning",
summaries: true,
reasoning: "none",
tool: true,
thinking: false,
body: false,
},
] as const
for (const profile of profiles) {
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", {
args: { scenario: "reasoning", summaries: profile.summaries, reasoning: profile.reasoning, tool: profile.tool },
})
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount(
profile.body ? 1 : 0,
)
if ("heading" in profile) {
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
})
}
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
story("does not infer reasoning visibility from provider identity", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", {
args: { scenario: "reasoning", reasoning: "none", text: "No reasoning payload" },
})
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
await expect(timeline.getByText("No reasoning payload", { exact: true })).toBeVisible()
})
@@ -1,65 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
story("renders streamed reasoning without starting the app", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text")
await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible()
await expect(timeline.getByText("Checking the current contract", { exact: true })).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts
story("preserves a collapsed context group through count and status updates", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "exploration" } })
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
const trigger = group.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Complete read" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Complete glob" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
// Moved from packages/app/e2e/regression/session-timeline-accessibility.spec.ts
story("space activates a focused timeline button instead of scrolling", async ({ mount, page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
await page.setViewportSize({ width: 800, height: 240 })
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } })
await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBeGreaterThan(0)
const trigger = timeline.getByRole("button", { name: "Used Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.focus()
const before = await page.evaluate(() => window.scrollY)
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(await page.evaluate(() => window.scrollY)).toBe(before)
})
// Moved from packages/app/e2e/regression/session-timeline-file-projection.spec.ts
story("renders a completed write through the production file component", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "write" } })
await expect(
timeline.locator('[data-timeline-part-id="prt_file_projection_write"] [data-component="write-content"]'),
).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-file-state.spec.ts
story("keeps patch file disclosures independent", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "patch" } })
const wrapper = timeline.locator('[data-timeline-part-id="prt_nested_patch"]')
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"] button')
const added = wrapper.locator('[data-scope="apply-patch"] [data-type="add"] button')
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"] button')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.click()
await expect(deleted).toHaveAttribute("aria-expanded", "true")
await expect(modified).toHaveAttribute("aria-expanded", "false")
await modified.click()
await expect(modified).toHaveAttribute("aria-expanded", "true")
await deleted.click()
await expect(deleted).toHaveAttribute("aria-expanded", "false")
await expect(modified).toHaveAttribute("aria-expanded", "true")
await expect(added).toHaveAttribute("aria-expanded", "false")
await added.click()
await expect(added).toHaveAttribute("aria-expanded", "true")
await expect(modified).toHaveAttribute("aria-expanded", "true")
await expect(deleted).toHaveAttribute("aria-expanded", "false")
})
@@ -1,142 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders every admitted tool family and hides timeline-only exclusions", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "workflow" } })
const first = timeline.locator(
'[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list,tool_family_webfetch,tool_family_websearch,tool_family_subagent,tool_family_shell,tool_family_edit,tool_family_write,tool_family_patch"]',
)
const second = timeline.locator('[data-timeline-part-ids="tool_family_skill,tool_family_custom"]')
await expect(first).toBeVisible()
await expect(second).toBeVisible()
await first.getByRole("button").click()
await second.getByRole("button").click()
for (const id of [
"webfetch",
"websearch",
"subagent",
"shell",
"edit",
"write",
"patch",
"question",
"skill",
"custom",
]) {
await expect(timeline.locator(`[data-timeline-part-id="tool_family_${id}"]`), id).toBeVisible()
}
const patch = timeline.locator('[data-timeline-part-id="tool_family_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = timeline.locator('[data-timeline-part-id="tool_family_edit"]')
await expect(edit).toContainText("Edit")
await expect(timeline.locator('[data-timeline-part-id="tool_family_todo"]')).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("renders every tool error outcome without leaking hidden tools", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "failures" } })
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(names.length))
await group.getByRole("button").click()
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1)
const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')
await expect(dismissed.getByText(/dismissed/i)).toBeVisible()
await expect(dismissed).toContainText(/dismissed/i)
await expect(timeline.locator('[data-timeline-part-id="tool_error_todo"]')).toHaveCount(0)
for (const name of names) await expect(timeline.locator(`[data-timeline-part-id="tool_error_${name}"]`)).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("transitions shell and question through running error outcomes", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "transition" } })
const shell = timeline.locator('[data-timeline-part-id="tool_transition_shell"]')
const question = timeline.locator('[data-timeline-part-id="tool_transition_question"]')
await expect(shell).toBeVisible()
await expect(question).toHaveCount(0)
await timeline.getByRole("button", { name: "Fail running tools" }).click()
await expect(shell.locator('[data-kind="tool-error-card"]')).toBeVisible()
await expect(shell).toContainText("Command exited 1")
await expect(question).toContainText(/dismissed/i)
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels all web search provider variants", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } })
await timeline.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
const tools = timeline.locator('[data-component="context-tool-group-list"]')
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels completed searches with result counts", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "results" } })
const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]')
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels read tools from their path input", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "read" } })
const group = timeline.locator('[data-timeline-part-ids="prt_read_path"]')
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels skill tools from IDs and result metadata", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } })
const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]')
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
const names = loaded.locator('[data-component="text-shimmer"]')
await expect(names).toHaveCount(2)
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
})
// Moved from packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts
story("groups every collapsed tool until visible text separates the stack", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "steps" } })
await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_read"]')).toBeVisible()
const group = timeline.locator(
'[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]',
)
await expect(group).toBeVisible()
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
await expect(timeline.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
await expect(timeline.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("combines adjacent edit calls and repeated files into one group", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "repeated" } })
const group = timeline.locator('[data-timeline-part-ids="tool_grouped_edit_first,tool_grouped_edit_second"]')
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
+1 -4
View File
@@ -47,13 +47,10 @@
"scripts": {
"generate:progress-indicator": "bun script/generate-session-progress-indicator.ts",
"typecheck": "tsgo -b",
"test": "bun test src --only-failures",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui"
"test": "bun test src --only-failures"
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -1,4 +0,0 @@
import { fileURLToPath } from "node:url"
import { componentConfig } from "../storybook/playwright/config"
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)))
@@ -1,7 +1,6 @@
import { createStore } from "solid-js/store"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { SessionReview, type SessionReviewComment } from "./session-review"
import { SessionReview } from "./session-review"
function ReviewStory(props: { split?: boolean }) {
return (
@@ -45,35 +44,3 @@ export const UnifiedDark = {
globals: { theme: "dark" },
render: () => <ReviewStory />,
}
function InteractiveCommentsStory() {
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
const file = "src/review.ts"
const diffs = [
{
file,
additions: 1,
deletions: 1,
status: "modified" as const,
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
},
]
return (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[900px] overflow-hidden bg-background-base">
<SessionReview
title="Changes in this Session"
diffs={diffs}
open={[file]}
comments={state.comments}
onLineComment={(comment) =>
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
}
/>
</div>
</CurrentSessionProviders>
)
}
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
@@ -1,75 +0,0 @@
import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import type { SessionDocument } from "../document"
import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "./current-session-fixtures"
export function storyTool(
id: string,
name: string,
status: "streaming" | "running" | "completed" | "error",
input: Record<string, JsonValue>,
options: { metadata?: Record<string, JsonValue>; output?: string; error?: string; raw?: string } = {},
): SessionMessageAssistantTool {
const state =
status === "streaming"
? { status, input: options.raw ?? JSON.stringify(input) }
: status === "running"
? { status, input, metadata: { ...options.metadata, ...(options.output ? { output: options.output } : {}) } }
: status === "error"
? {
status,
input,
error: { type: "ToolExecutionError", message: options.error ?? `${name} failed visibly` },
metadata: options.metadata,
}
: {
status,
input,
content: [{ type: "text" as const, text: options.output ?? "Complete" }] as [
{ type: "text"; text: string },
],
metadata: options.metadata,
}
return {
type: "tool",
id,
name,
state,
time: {
created: STORY_TIME,
...(status === "streaming" ? {} : { ran: STORY_TIME + 100 }),
...(status === "completed" || status === "error" ? { completed: STORY_TIME + 200 } : {}),
},
}
}
export function storyDocument(content: SessionMessageAssistant["content"], busy = false): SessionDocument {
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_tool_projection_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content,
time: { created: STORY_TIME, ...(busy ? {} : { completed: STORY_TIME + 300 }) },
},
],
status: { type: busy ? "busy" : "idle" },
diffs: [],
}
}
export function storyPatchFile(file: string, status: "modified" | "added" = "modified") {
return {
file,
status,
patch:
status === "added"
? "@@ -0,0 +1 @@\n+export const after = true"
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
additions: 1,
deletions: status === "added" ? 0 : 1,
}
}
@@ -21,19 +21,8 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil
{ name: "test", color: "green" },
],
provider: {
all: new Map<string, { models: Record<string, { name: string }> }>([
["anthropic", { models: { "claude-sonnet-4": { name: "Claude Sonnet 4" } } }],
[
"company-gateway",
{
models: {
"fast-nano": { name: "GPT-5.4 nano" },
"long-context": { name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts" },
},
},
],
]),
connected: ["anthropic", "company-gateway"],
all: new Map([["anthropic", { models: { "claude-sonnet-4": { name: "Claude Sonnet 4" } } }]]),
connected: ["anthropic"],
default: { anthropic: "claude-sonnet-4" },
},
session: [
@@ -1,7 +1,4 @@
import { createTwoFilesPatch } from "diff"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
editThenTestDocument,
fileChangeLoadingDocument,
@@ -9,7 +6,6 @@ import {
multiFilePatchDocument,
writeFileDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -74,137 +70,6 @@ export const PatchedTwoFiles = {
),
}
const RepeatedEdits = {
render: () => (
<CurrentSessionTimelineStory
title="Repeated edits of one file"
description="Consecutive improvements to the same source file remain together in one expanded change."
document={storyDocument([
storyTool(
"tool_grouped_edit_first",
"edit",
"completed",
{ path: "src/first.ts", oldString: "one", newString: "two" },
{
metadata: { files: [storyPatchFile("src/first.ts")] },
},
),
storyTool(
"tool_grouped_edit_second",
"edit",
"completed",
{ path: "src/first.ts", oldString: "two", newString: "three" },
{
metadata: { files: [storyPatchFile("src/first.ts")] },
},
),
])}
editToolDefaultOpen
/>
),
}
function EditSiblingUpdateStory() {
const [state, setState] = createStore({ sibling: false })
const document = createMemo(() => ({
...editThenTestDocument,
status: { type: "busy" as const },
messages: editThenTestDocument.messages
.filter((message) => message.id === "msg_user_edit" || message.id === "msg_assistant_edit")
.map((message) => {
if (message.type !== "assistant") return message
return {
...message,
time: { created: message.time.created },
content: [
...message.content,
...(state.sibling ? [{ type: "text" as const, text: "Streaming added a later assistant text part." }] : []),
],
}
}),
}))
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("sibling", true)}>
Stream sibling content
</button>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} editToolDefaultOpen />
</CurrentSessionProviders>
</section>
)
}
const EditWithStreamedSibling = { render: () => <EditSiblingUpdateStory /> }
const ThreeFilePatch = {
render: () => {
const source = (changed: boolean) =>
Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join("")
const files = [
{ file: "src/a.ts", status: "modified" },
{ file: "src/b.ts", status: "added" },
{ file: "src/old.ts", status: "deleted" },
].map(({ file, status }) => ({
file,
status,
patch: createTwoFilesPatch(
`a/${file}`,
`b/${file}`,
status === "added" ? "" : source(false),
status === "deleted" ? "" : source(true),
),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
}))
return (
<CurrentSessionTimelineStory
title="Update, create, and remove files"
description="Each changed file can be opened and closed independently."
document={storyDocument([
storyTool(
"prt_nested_patch",
"patch",
"completed",
{ patchText: "Update three files" },
{ metadata: { files } },
),
])}
editToolDefaultOpen
/>
)
},
}
const WrittenSource = {
render: () => (
<CurrentSessionTimelineStory
title="Create a source file"
description="A completed TypeScript write renders its generated source."
document={storyDocument([
storyTool("prt_file_projection_write", "write", "completed", {
path: "src/write.ts",
content: "export const written = true\n",
}),
])}
editToolDefaultOpen
/>
),
}
const fileScenarios = {
repeated: RepeatedEdits,
streaming: EditWithStreamedSibling,
patch: ThreeFilePatch,
write: WrittenSource,
}
export const ChangingFiles = {
args: { scenario: "streaming" },
argTypes: { scenario: { control: "select", options: Object.keys(fileScenarios) } },
render: (args: { scenario: string }) => fileScenarios[args.scenario as keyof typeof fileScenarios].render(),
}
export const CreatedANewFile = {
render: () => (
<CurrentSessionTimelineStory
@@ -1,19 +1,10 @@
import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
CURRENT_SESSION_ID,
STORY_MODEL,
STORY_TIME,
inspectAndExplainDocument,
loadedResourcesDocument,
subagentDocument,
thinkingDocument,
webResearchDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -74,318 +65,3 @@ export const DelegateFocusedTasks = {
/>
),
}
function CodebaseExplorationStory() {
const [state, setState] = createStore({ read: false, glob: false })
const tool = (name: "read" | "glob", completed: boolean) => {
const input = name === "read" ? { path: "src/a.ts", offset: 0, limit: 120 } : { path: ".", pattern: "**/*.ts" }
return {
type: "tool",
id: `tool_context_${name}`,
name,
state: completed
? { status: "completed", input, content: [{ type: "text", text: "Complete" }], metadata: {} }
: { status: "running", input, metadata: {} },
time: {
created: STORY_TIME,
ran: STORY_TIME + 100,
...(completed ? { completed: STORY_TIME + 200 } : {}),
},
} satisfies SessionMessageAssistantTool
}
const document = createMemo(
() =>
({
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_codebase_exploration_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [tool("read", state.read), tool("glob", state.glob)],
time: { created: STORY_TIME, ...(state.read && state.glob ? { completed: STORY_TIME + 300 } : {}) },
} satisfies SessionMessageAssistant,
],
status: { type: state.read && state.glob ? "idle" : "busy" },
diffs: [],
}) satisfies SessionDocument,
)
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-2">
<button type="button" onClick={() => setState("read", true)}>
Complete read
</button>
<button type="button" onClick={() => setState("glob", true)}>
Complete glob
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
const ExploreTheCodebase = { render: () => <CodebaseExplorationStory /> }
const CompareSearchProviders = {
render: () => (
<CurrentSessionTimelineStory
title="Compare web search providers"
description="Search results identify Parallel, Exa, and the generic provider clearly."
document={storyDocument([
storyTool(
"tool_search_parallel",
"websearch",
"completed",
{ query: "parallel" },
{ metadata: { provider: "parallel" } },
),
storyTool("tool_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
storyTool("tool_search_generic", "websearch", "completed", { query: "generic" }),
])}
/>
),
}
const SearchResultsAndFiles = {
render: () => (
<CurrentSessionTimelineStory
title="Search results and opened files"
description="Grouped file searches show their match counts and the filename that was inspected."
document={storyDocument([
storyTool(
"tool_label_glob",
"glob",
"completed",
{ path: ".", pattern: "**/*.ts" },
{ metadata: { count: 1 } },
),
storyTool(
"tool_label_grep",
"grep",
"completed",
{ path: ".", pattern: "value" },
{ metadata: { matches: 12 } },
),
storyTool("tool_label_read", "read", "completed", { path: "src/a.ts" }),
])}
/>
),
}
const ReadOneFile = {
render: () => (
<CurrentSessionTimelineStory
title="Read one source file"
description="A single read opens its own file label without neighboring tool calls."
document={storyDocument([storyTool("prt_read_path", "read", "completed", { path: "src/a.ts" })])}
/>
),
}
const LoadingSpecializedSkills = {
render: () => (
<CurrentSessionTimelineStory
title="Loading specialized skills"
description="Active and completed skills display their identifier or resolved name."
document={storyDocument([
storyTool("tool_skill_id", "skill", "running", { id: "frontend-design" }),
storyTool("tool_skill_name", "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
])}
/>
),
}
const ResearchAcrossSteps = {
render: () => (
<CurrentSessionTimelineStory
title="Research across separate steps"
description="An explanation and command naturally separate independent file-investigation groups."
document={storyDocument([
storyTool("tool_boundary_read", "read", "completed", { path: "src/a.ts" }),
{ type: "text", text: "Boundary text" },
storyTool("tool_boundary_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
storyTool("tool_boundary_grep", "grep", "completed", { path: ".", pattern: "stable" }),
storyTool("tool_boundary_shell", "shell", "completed", { command: "printf done" }, { output: "done" }),
storyTool("tool_boundary_list", "list", "completed", { path: "src" }),
])}
/>
),
}
const questions = { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
const CompleteAgentWorkflow = {
render: () => (
<CurrentSessionTimelineStory
title="Complete agent workflow"
description="A realistic investigation combines file research, web sources, delegation, commands, edits, and specialist guidance."
document={storyDocument([
storyTool("tool_family_read", "read", "completed", { path: "src/a.ts" }),
storyTool("tool_family_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
storyTool("tool_family_grep", "grep", "completed", { path: ".", pattern: "value" }),
storyTool("tool_family_list", "list", "completed", { path: "src" }),
storyTool("tool_family_webfetch", "webfetch", "completed", { url: "https://example.com" }),
storyTool("tool_family_websearch", "websearch", "completed", { query: "timeline stability" }),
storyTool("tool_family_subagent", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
storyTool("tool_family_shell", "shell", "completed", { command: "printf stable" }, { output: "stable" }),
storyTool(
"tool_family_edit",
"edit",
"completed",
{ path: "src/a.ts", oldString: "before", newString: "after" },
{
metadata: { files: [storyPatchFile("src/a.ts")] },
},
),
storyTool("tool_family_write", "write", "completed", {
path: "src/new.ts",
content: "export const stable = true",
}),
storyTool(
"tool_family_patch",
"patch",
"completed",
{ patchText: "Update the projected files" },
{
metadata: { files: [storyPatchFile("src/a.ts")] },
},
),
storyTool("tool_family_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
storyTool("tool_family_question", "question", "completed", questions, { metadata: { answers: [["Yes"]] } }),
storyTool("tool_family_skill", "skill", "completed", { name: "stability" }),
storyTool("tool_family_custom", "custom_mcp_tool", "completed", { target: "timeline" }),
])}
width="860px"
/>
),
}
const RecoverFromToolFailures = {
render: () => {
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const input = (name: string): Record<string, JsonValue> => {
if (name === "shell") return { command: "exit 1" }
if (name === "edit" || name === "write") return { path: "src/error.ts", content: "" }
if (name === "patch") return { patchText: "Update src/error.ts" }
if (name === "webfetch") return { url: "https://example.com" }
if (name === "websearch") return { query: "failure" }
if (name === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect." }
if (name === "skill") return { name: "failure" }
return { target: "failure" }
}
return (
<CurrentSessionTimelineStory
title="Recover from failed work"
description="Commands, edits, searches, and delegated work explain their failures while dismissed questions stay recognizable."
document={storyDocument([
...names.map((name) => storyTool(`tool_error_${name}`, name, "error", input(name))),
storyTool("tool_error_question_dismissed", "question", "error", questions, {
error: "The user dismissed this question",
}),
storyTool("tool_error_question_transport", "question", "error", questions, {
error: "Question transport failed",
}),
storyTool("tool_error_todo", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
])}
width="860px"
/>
)
},
}
function FailedCommandAndQuestionStory() {
const [state, setState] = createStore({ failed: false })
const document = createMemo(() =>
storyDocument(
[
storyTool(
"tool_transition_shell",
"shell",
state.failed ? "error" : "running",
{ command: "exit 1" },
{
error: "Command exited 1",
},
),
storyTool("tool_transition_question", "question", state.failed ? "error" : "running", questions, {
error: "The user dismissed this question",
}),
],
!state.failed,
),
)
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("failed", true)}>
Fail running tools
</button>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} shellToolDefaultOpen />
</CurrentSessionProviders>
</section>
)
}
const FailedCommandAndQuestion = { render: () => <FailedCommandAndQuestionStory /> }
const DelegatingAnAgent = {
render: () => (
<CurrentSessionTimelineStory
title="Delegating an agent"
description="The assistant shows its compact delegation status while preparing a focused task."
document={storyDocument([storyTool("tool_notice_delegation", "subagent", "streaming", {}, { raw: "" })], true)}
/>
),
}
const StartingBackgroundWork = {
render: () => (
<CurrentSessionTimelineStory
title="Starting background work"
description="A delegated task keeps its ordinary label until the background request completes."
document={storyDocument(
[
storyTool(
"tool_notice_background",
"subagent",
"running",
{ description: "Inspect code", background: true },
{
metadata: { status: "running" },
},
),
],
true,
)}
/>
),
}
const researchScenarios = {
workflow: CompleteAgentWorkflow,
exploration: ExploreTheCodebase,
providers: CompareSearchProviders,
results: SearchResultsAndFiles,
read: ReadOneFile,
skills: LoadingSpecializedSkills,
steps: ResearchAcrossSteps,
failures: RecoverFromToolFailures,
transition: FailedCommandAndQuestion,
delegation: DelegatingAnAgent,
background: StartingBackgroundWork,
}
export const AgentResearch = {
args: { scenario: "workflow" },
argTypes: { scenario: { control: "select", options: Object.keys(researchScenarios) } },
render: (args: { scenario: string }) => researchScenarios[args.scenario as keyof typeof researchScenarios].render(),
}
@@ -1,7 +1,4 @@
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
executeCodeDocument,
expandedShellDocument,
@@ -12,7 +9,6 @@ import {
terminalPassedDocument,
terminalRunningDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -66,17 +62,6 @@ export const UserCommandCompleted = {
),
}
const CollapsedShell = {
render: () => (
<CurrentSessionTimelineStory
title="Collapsed completed shell"
description="A focused shell disclosure responds to the keyboard without scrolling its containing surface."
document={terminalPassedDocument}
width="720px"
/>
),
}
export const TestsPassed = {
render: () => (
<CurrentSessionTimelineStory
@@ -125,75 +110,6 @@ export const TestFailed = {
),
}
function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolean }) {
const [state, setState] = createStore({
phase: props.streaming ? "streaming" : "completed",
lines: 3,
sibling: false,
busy: false,
})
const document = createMemo(() => {
const phase = state.phase as "streaming" | "input" | "running" | "completed"
const command = phase === "streaming" ? "" : "printf ready"
const content: SessionMessageAssistant["content"] = [
storyTool("tool_shell_lifecycle", "shell", phase === "input" ? "streaming" : phase, command ? { command } : {}, {
output:
phase === "running"
? "still running"
: Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"),
...(phase === "streaming" ? { raw: "" } : {}),
}),
...(state.sibling ? [{ type: "text" as const, text: "Sibling content" }] : []),
]
return {
...storyDocument(content, phase !== "completed"),
status: { type: phase !== "completed" || state.busy ? ("busy" as const) : ("idle" as const) },
}
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "input")}>
Complete input
</button>
<button type="button" onClick={() => setState("phase", "running")}>
Run command
</button>
<button type="button" onClick={() => setState("phase", "completed")}>
Complete command
</button>
<button type="button" onClick={() => setState("lines", 6)}>
Update output
</button>
<button type="button" onClick={() => setState("sibling", true)}>
Append sibling
</button>
<button type="button" onClick={() => setState("busy", true)}>
Mark session busy
</button>
<button type="button" onClick={() => setState("busy", false)}>
Mark session idle
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} shellToolDefaultOpen={props.expanded} />
</CurrentSessionProviders>
</section>
)
}
const RunACommand = {
args: { expanded: false, streaming: false },
render: (args: { expanded: boolean; streaming: boolean }) => <InteractiveCommandStory {...args} />,
}
export const TerminalCommands = {
args: { scenario: "command", expanded: false, streaming: false },
argTypes: { scenario: { control: "select", options: ["command", "collapsed"] } },
render: (args: { scenario: string; expanded: boolean; streaming: boolean }) =>
args.scenario === "collapsed" ? CollapsedShell.render() : RunACommand.render(args),
}
export const FixedAndPassed = {
render: () => (
<CurrentSessionTimelineStory
@@ -1,13 +1,6 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { SessionTimeline } from "./session-timeline"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
CURRENT_SESSION_ID,
STORY_MODEL,
STORY_TIME,
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionCancelledDocument,
@@ -60,181 +53,6 @@ export const StreamingReasoningAndText = {
),
}
function AgentReasoningStory(props: { summaries: boolean; reasoning: string; tool: boolean; text: string }) {
const content = [
...(props.reasoning === "none"
? []
: [
{
type: "reasoning" as const,
text: props.reasoning === "blank" ? " " : "## Inspecting stability",
time: { created: STORY_TIME + 100 },
},
]),
...(props.tool
? [
{
type: "tool" as const,
id: "tool_reasoning_projection_skill",
name: "skill",
state: { status: "running" as const, input: { name: "inspect" }, metadata: {} },
time: { created: STORY_TIME + 200, ran: STORY_TIME + 250 },
},
]
: []),
...(props.text ? [{ type: "text" as const, text: props.text }] : []),
] satisfies SessionMessageAssistant["content"]
const document = {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_projection_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content,
time: { created: STORY_TIME },
},
],
status: { type: "busy" },
diffs: [],
} satisfies SessionDocument
return (
<section class="mx-auto w-full max-w-[720px] p-6">
<CurrentSessionProviders document={document}>
<SessionTimeline document={document} showReasoningSummaries={props.summaries} />
</CurrentSessionProviders>
</section>
)
}
const AgentReasoning = {
args: { summaries: true, reasoning: "heading", tool: false, text: "" },
argTypes: { reasoning: { control: "select", options: ["none", "blank", "heading"] } },
render: (args: { summaries: boolean; reasoning: string; tool: boolean; text: string }) => (
<AgentReasoningStory {...args} />
),
}
function HiddenReasoningStory() {
const [state, setState] = createStore({ phase: "thinking" })
const document = createMemo(() => {
const finished = state.phase === "idle"
const running = state.phase === "running"
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_hidden_reasoning_lifecycle",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [
{ type: "reasoning", text: "## Inspecting stability", time: { created: STORY_TIME + 100 } },
...(running || finished
? [
{
type: "tool" as const,
id: "tool_hidden_reasoning_shell",
name: "shell",
state: finished
? {
status: "completed" as const,
input: { command: "printf done" },
content: [{ type: "text" as const, text: "done" }],
metadata: {},
}
: { status: "running" as const, input: { command: "printf done" }, metadata: {} },
time: {
created: STORY_TIME + 200,
ran: STORY_TIME + 250,
...(finished ? { completed: STORY_TIME + 300 } : {}),
},
},
]
: []),
],
time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 400 } : {}) },
},
],
status: { type: finished ? "idle" : "busy" },
diffs: [],
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "running")}>
Start shell
</button>
<button type="button" onClick={() => setState("phase", "idle")}>
Finish session
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} showReasoningSummaries={false} />
</CurrentSessionProviders>
</section>
)
}
const WorkingWithoutReasoningDetails = { render: () => <HiddenReasoningStory /> }
function RetryAndRecoverStory() {
const [state, setState] = createStore({ phase: "thinking" })
const document = createMemo(() => {
const retry = state.phase === "retry"
const finished = state.phase === "idle"
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_retry_recovery_lifecycle",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: finished ? [{ type: "text" as const, text: "Recovered response" }] : [],
...(retry
? {
retry: {
attempt: 2,
at: 1_900_000_000_000,
error: { type: "ProviderRateLimitError", message: "Rate limit reached. Retrying with backoff." },
},
}
: {}),
time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 300 } : {}) },
},
],
status: { type: finished ? "idle" : "busy" },
diffs: [],
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "retry")}>
Retry request
</button>
<button type="button" onClick={() => setState("phase", "thinking")}>
Recover request
</button>
<button type="button" onClick={() => setState("phase", "idle")}>
Finish response
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
const RetryAndRecover = { render: () => <RetryAndRecoverStory /> }
export const ProviderRetry = {
render: () => (
<CurrentSessionTimelineStory
@@ -246,122 +64,6 @@ export const ProviderRetry = {
),
}
const noticeUser = { id: "msg_notice_user", type: "user", text: "Run it", time: { created: STORY_TIME } } as const
const noticeAssistant = {
id: "msg_notice_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [{ type: "text", text: "Working" }],
time: { created: STORY_TIME + 1, completed: STORY_TIME + 2 },
} satisfies SessionMessageInfo
const AgentActivityNotices = {
render: () => (
<CurrentSessionTimelineStory
title="Agent activity and Session notices"
description="Agent changes, delegated work, restarted Sessions, and loaded skills appear in their original order."
document={{
sessionID: CURRENT_SESSION_ID,
status: { type: "idle" },
diffs: [],
messages: [
noticeUser,
{ id: "msg_notice_agent", type: "agent-switched", agent: "explore", time: { created: STORY_TIME + 1 } },
noticeAssistant,
{
id: "msg_notice_subagent",
type: "synthetic",
text: "done",
description: "Search code",
metadata: { source: "subagent", agent: "explore", state: "completed" },
time: { created: STORY_TIME + 3 },
},
{
id: "msg_notice_restart",
type: "synthetic",
text: "continue",
description: "Continuing after restart",
time: { created: STORY_TIME + 4 },
},
{
id: "msg_notice_skill",
type: "skill",
skill: "review",
name: "Review",
text: "instructions",
time: { created: STORY_TIME + 5 },
},
],
}}
width="720px"
/>
),
}
function CompactSessionStory() {
const [state, setState] = createStore({ phase: "running", summary: "", second: false })
const document = createMemo(() => {
const failed = state.phase === "failed"
const completed = state.phase === "completed"
const message = {
id: "msg_notice_compaction",
type: "compaction" as const,
status: failed ? ("failed" as const) : completed ? ("completed" as const) : ("running" as const),
reason: "auto" as const,
...(failed
? {
error: {
type: "compaction.failed",
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
},
}
: { summary: state.summary, recent: "" }),
time: { created: STORY_TIME + 10 },
}
const cancelled = {
id: "msg_notice_compaction_cancelled",
type: "compaction" as const,
status: "failed" as const,
reason: "manual" as const,
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
time: { created: STORY_TIME + 20 },
}
return {
sessionID: CURRENT_SESSION_ID,
messages: [noticeUser, noticeAssistant, message, ...(state.second ? [cancelled] : [])],
status: { type: completed || failed ? "idle" : "busy" },
diffs: [],
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[760px] flex-col gap-4 p-6">
<div class="flex flex-wrap gap-3">
<button type="button" onClick={() => setState("summary", "## Checkpoint\n\nStreamed implementation details.")}>
Stream summary
</button>
<button
type="button"
onClick={() => setState({ phase: "completed", summary: "## Checkpoint\n\nFinal implementation details." })}
>
Complete summary
</button>
<button type="button" onClick={() => setState("phase", "failed")}>
Fail compaction
</button>
<button type="button" onClick={() => setState("second", true)}>
Cancel next compaction
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
const CompactSession = { render: () => <CompactSessionStory /> }
export const CompactionInProgress = {
render: () => (
<CurrentSessionTimelineStory
@@ -454,155 +156,6 @@ export const MixedDirectionRtl = {
),
}
const MovedLocation = {
render: () => (
<CurrentSessionTimelineStory
title="Moved Session location"
description="A changed working directory stays compact, truncates, and exposes its tooltip."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
...thinkingDocument.messages,
{
id: "msg_story_location",
type: "location-switched",
location: { directory: `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session` },
time: { created: 1_735_689_633_000 },
},
],
}}
width="480px"
/>
),
}
const InterruptedTurn = {
render: () => (
<CurrentSessionTimelineStory
title="Interrupted assistant turn"
description="The interruption divider stays between the original response and its continuation."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
...thinkingDocument.messages,
{
id: "msg_story_interrupted_before",
type: "assistant",
agent: "build",
model: { id: "claude-sonnet-4", providerID: "anthropic" },
content: [{ type: "text", text: "Before" }],
error: { type: "MessageAbortedError", message: "Stopped" },
time: { created: 1_735_689_633_000, completed: 1_735_689_634_000 },
},
{
id: "msg_story_interrupted_after",
type: "assistant",
agent: "build",
model: { id: "claude-sonnet-4", providerID: "anthropic" },
content: [{ type: "text", text: "After" }],
time: { created: 1_735_689_635_000, completed: 1_735_689_636_000 },
},
],
}}
width="560px"
/>
),
}
const AliasedModelNotices = {
render: () => (
<CurrentSessionTimelineStory
title="Aliased model notices"
description="Provider display names, model variants, and long-name truncation use the production notice component."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
{
id: "msg_story_fast_nano",
type: "model-switched",
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
time: { created: 1_735_689_590_000 },
},
{
id: "msg_story_long_context",
type: "model-switched",
model: { providerID: "company-gateway", id: "long-context" },
time: { created: 1_735_689_591_000 },
},
...thinkingDocument.messages,
],
}}
width="420px"
/>
),
}
const RichUserAttachments = {
render: () => (
<CurrentSessionTimelineStory
title="Prompt with rich attachments"
description="An image, JSON attachment, source-file reference, and agent mention remain individually visible."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
{
id: "msg_story_rich_user",
type: "user",
text: "Use @explore with @src/a.ts and inspect the attachments",
agents: [{ name: "explore", mention: { text: "@explore", start: 4, end: 12 } }],
files: [
{
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
mime: "image/png",
name: "pixel.png",
source: { type: "inline" },
},
{ data: "e30=", mime: "application/json", name: "tsconfig.json", source: { type: "inline" } },
{
data: "",
mime: "text/plain",
name: "a.ts",
source: { type: "uri", uri: "src/a.ts" },
mention: { text: "@src/a.ts", start: 18, end: 27 },
},
],
time: { created: 1_735_689_633_000 },
},
],
}}
width="620px"
/>
),
}
const conversationScenarios = {
reasoning: AgentReasoning,
hidden: WorkingWithoutReasoningDetails,
retry: RetryAndRecover,
notices: AgentActivityNotices,
compaction: CompactSession,
location: MovedLocation,
interruption: InterruptedTurn,
models: AliasedModelNotices,
attachments: RichUserAttachments,
}
export const Conversation = {
args: { scenario: "notices", summaries: true, reasoning: "heading", tool: false, text: "" },
argTypes: {
scenario: { control: "select", options: Object.keys(conversationScenarios) },
reasoning: { control: "select", options: ["none", "blank", "heading"] },
},
render: (args: { scenario: string; summaries: boolean; reasoning: string; tool: boolean; text: string }) => {
if (args.scenario === "reasoning") return <AgentReasoningStory {...args} />
return conversationScenarios[args.scenario as Exclude<keyof typeof conversationScenarios, "reasoning">].render()
},
}
export const InstructionsUpdatedSingle = {
render: () => (
<CurrentSessionTimelineStory
+1 -6
View File
@@ -69,7 +69,7 @@ const frame = createJSXDecorator((Story, context) => {
<MetaProvider>
<Font />
<ThemeProvider>
<LanguageProvider locale={typeof context.globals?.locale === "string" ? context.globals.locale : "en"}>
<LanguageProvider locale="en">
<UiI18nBridge>
<Scheme value={scheme} />
<Direction value={context.globals?.direction} />
@@ -109,11 +109,6 @@ export default definePreview({
description: "Interface direction",
defaultValue: "ltr",
},
locale: {
name: "Locale",
description: "Interface language",
defaultValue: "en",
},
},
parameters: {
actions: {
-1
View File
@@ -12,7 +12,6 @@
"@tailwindcss/vite": "catalog:",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@playwright/test": "catalog:",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "10.4.4",
"@storybook/addon-docs": "10.4.4",
-61
View File
@@ -1,61 +0,0 @@
# Component browser tests
Production Solid components are tested through their existing Storybook stories without booting the app, configuring a server, seeding browser storage, or navigating unrelated routes.
Keep each spec in the package that owns its production component:
- `packages/session-ui/component-tests/` owns timeline, tool, notice, reasoning, lifecycle, and review coverage.
- `packages/app/component-tests/` owns Composer and other app-only component coverage.
- `packages/storybook/playwright/` owns the shared Storybook startup configuration and `story` mount fixture.
Run a package's isolated browser suite from that package:
```sh
# Session UI components.
cd packages/session-ui
bun run test:components
bun run test:components -- component-tests/session-timeline.spec.ts
bun run test:components:ui
# App-owned components.
cd packages/app
bun run test:components
bun run test:components -- component-tests/composer.spec.ts
```
Both suites are separately filterable Turbo tasks:
```sh
bun turbo test:components --filter=@opencode-ai/session-ui
bun turbo test:components --filter=@opencode-ai/app
```
Component browser coverage deliberately remains separate from each package's default `test` script and from `packages/app`'s `test:e2e`, so expensive Storybook checks can be scheduled independently from required unit and full-app journey CI. Set `PLAYWRIGHT_STORYBOOK_URL` to reuse an existing Storybook instance or `PLAYWRIGHT_STORYBOOK_PORT` to choose its port.
## Adding a test
Keep inspectable scenarios next to the production component in a `*.stories.tsx` file. A story owns its fixtures, providers, state, and callbacks; its package-local spec owns user-visible interactions and assertions.
```ts
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts
story("preserves collapsed state while a tool completes", async ({ mount }) => {
const component = await mount("current-session-research-agents--agent-research", {
args: { scenario: "exploration" },
})
const trigger = component.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await component.getByRole("button", { name: "Complete read" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
```
The story ID is the Storybook component ID followed by `--` and the kebab-cased story export. Open the same story in Storybook to inspect exactly the scenario covered by the browser test. Preserve an original-source-path comment for every migrated E2E case.
Keep cross-route navigation, remote-server ownership, persistent session state, full-app virtualization, and workflows spanning independent surfaces in `packages/app/e2e/`.
Component rendering and integration coverage can be complementary. A local story control that installs a completed message does not test event delivery, production reducer cleanup, or a live stream. Keep those original checks in E2E, including stream/chunk identity, compaction and retry events, independent lifecycle transitions, and the real app scroll owner. A provenance comment records the source of a component assertion; it is not evidence that its integration counterpart can be deleted.
When moving an assertion, preserve its discriminating fixture: file status kinds, empty/single-variant inputs, singleton groups, live message state, and the order of intermediate updates. Verify the actual scroll container overflows before asserting that keyboard activation does not scroll it.

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