Compare commits

...
Author SHA1 Message Date
Kit Langton 68b7440a0e fix(cli): fail closed acp coordination 2026-08-29 00:40:52 -04:00
Kit Langton afb107143b fix(cli): coordinate acp location transactions 2026-08-29 00:16:43 -04:00
Kit Langton 0ca23f8e47 fix(cli): harden acp attachment handoff 2026-08-28 23:54:27 -04:00
Kit Langton 3789bf5482 fix(cli): coordinate acp prompt lifecycle 2026-08-28 23:37:51 -04:00
Kit Langton d7f2e119fc fix(cli): coordinate acp config lifecycle 2026-08-28 23:25:29 -04:00
Kit Langton fa283c38f7 fix(cli): serialize acp config switches 2026-08-28 23:08:24 -04:00
Kit Langton d374929e70 fix(cli): commit acp config after switch 2026-08-28 22:58:22 -04:00
Kit Langton e3bda5e2d0 feat(tui): add branch review scopes 2026-08-28 22:46:23 -04:00
Kit Langton ad9117b107 fix(session): run user shells immediately in the background
Run user shell commands concurrently with model execution, retain their output in one shell entry, and admit non-waking completion messages. Share result capture and notification formatting across shell callers.
2026-08-28 22:46:20 -04:00
Kit Langton 6b7a1d419c fix(core): preserve Responses tool identities (#45658) 2026-08-28 22:14:13 -04:00
Brendan Allan c0d0f5f4bc feat(cli): configure server CORS origins (#45544) 2026-08-29 09:41:52 +08:00
opencode-agent[bot]andHona 095ed63ea0 fix(app): keep workspace submit buttons neutral (#46052)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-29 11:20:41 +10:00
Kit Langton fa5ccac707 refactor(core): share subagent completion delivery (#46054) 2026-08-29 01:20:26 +00:00
Kit Langton 6809be2d0a fix(core): reuse permission policy for pending approvals (#46050) 2026-08-28 21:03:52 -04:00
Aiden Cline a5f8869b35 fix(ai): reject unknown chat finishes (#46036) 2026-08-28 18:22:47 -05:00
Aiden Cline 1c9c5305a6 fix(ai): classify retryable server failures (#46038) 2026-08-28 18:11:52 -05:00
Aiden Cline cf014bf2c1 fix(ai): avoid truncated tool calls (#46040) 2026-08-28 18:11:40 -05:00
Aiden Cline 8e7190f795 fix(ai): respect completed response item text (#45854) 2026-08-28 17:32:19 -05:00
Kit Langton 82b6e0e316 chore: enforce mapError simplifications (#46028) 2026-08-28 17:59:18 -04:00
Kit Langton 5b39f5184f test(core): share selector sentinels (#45748) 2026-08-28 17:59:12 -04:00
Kit Langton 1da25727b2 refactor(core): remove unused insert state (#45738) 2026-08-28 17:59:07 -04:00
Kit Langton 51d53f45c1 test(ci): register omitted V2 unit suites (#45970) 2026-08-28 21:58:54 +00:00
Kit Langton f779b2748a test(core): align runner service style (#45753) 2026-08-28 17:47:29 -04:00
Kit Langton f2fb191f53 test(core): inject vertex auth transport (#45751) 2026-08-28 17:47:24 -04:00
Kit Langton 0b32bdf1e5 test(core): use disposable session fixtures (#45745) 2026-08-28 17:47:18 -04:00
Kit Langton 2751813454 test(core): simplify provider test fixtures (#45743) 2026-08-28 17:47:13 -04:00
Aiden Cline f61858e683 fix(ai): avoid filtered tool calls (#46029) 2026-08-28 16:44:05 -05:00
Kit Langton 87525e00b9 test(core): synchronize retry tests on scheduled events (#46027) 2026-08-28 21:37:04 +00:00
Kit Langton 803b7718b8 refactor(core): import canonical schema contracts (#45746) 2026-08-28 17:29:07 -04:00
Kit Langton 8a24a01bff test(core): repair runner fixture claims (#45737) 2026-08-28 17:29:03 -04:00
Kit Langton 9e39a4fbdf test(core): simplify skill test setup (#45721) 2026-08-28 17:29:00 -04:00
Kit Langton d0baff184b fix(core): make provider publication cancellation-safe (#46026) 2026-08-28 17:24:27 -04:00
Kit Langton d82a0b28a9 docs: clarify release workflow (#46025) 2026-08-28 17:18:47 -04:00
Kit Langton bd379e13cb refactor(core): remove unused instruction import (#45747) 2026-08-28 17:18:44 -04:00
Kit Langton 6e1f783aec refactor(core): expose pty layer directly (#45733) 2026-08-28 17:18:41 -04:00
Kit Langton edef6a4b15 refactor(core): flatten git runner arguments (#45731) 2026-08-28 17:18:38 -04:00
Kit Langton 5990679ebd refactor: use typed error mapping operators (#45727) 2026-08-28 17:18:35 -04:00
Kit Langton b0c8a8c827 fix(util): refresh flock heartbeat time (#45720) 2026-08-28 17:18:32 -04:00
Aiden Cline d837ffe70f feat(plugin): add session retry hook (#45999) 2026-08-28 16:16:19 -05:00
Kit Langton b1d7dd82fc refactor(core): use nonempty array guards (#45718) 2026-08-28 17:12:03 -04:00
Kit Langton e409567428 refactor(core): express operation policies as transforms (#45713) 2026-08-28 17:11:59 -04:00
Kit Langton 9538c2171f refactor(core): derive config recognition fields (#45701) 2026-08-28 17:11:55 -04:00
Kit Langton 0a718be0d9 refactor(plugin): normalize web search API type (#45717) 2026-08-28 16:55:32 -04:00
Kit Langton 67845091ba fix(util): share streamed stderr consumption (#45716) 2026-08-28 16:55:28 -04:00
Kit Langton fe788b7842 fix(core): use runtime update columns (#45700) 2026-08-28 16:55:24 -04:00
Kit Langton ee42eb3ca3 fix(core): render built-in command arguments (#45697) 2026-08-28 16:55:21 -04:00
Kit Langton 80323a4deb test(core): name acquired test services (#45696) 2026-08-28 16:55:17 -04:00
Kit Langton a35f96f427 fix(core): preserve late opaque reasoning (#45694) 2026-08-28 16:55:13 -04:00
Kit Langton d354c3d640 fix(server): avoid loading locations for pending reads (#45994) 2026-08-28 20:44:48 +00:00
Kit Langton ce005ce002 refactor(core): remove unused result plumbing (#45695) 2026-08-28 16:44:27 -04:00
Kit Langton 964245bc2a test(core): isolate project adoption fixture (#46009) 2026-08-28 16:18:36 -04:00
Kit Langton aea3e7c1d2 fix(core): refresh plugins in background (#45993) 2026-08-28 19:43:12 +00:00
Kit Langton 3625942952 feat(core): pass session context to MCP tools (#46008) 2026-08-28 15:43:06 -04:00
Kit Langton 0593a6b8eb fix(core): isolate models seed replay (#45686) 2026-08-28 15:26:22 -04:00
Kit Langton 426e5c6389 fix(tui): open recent picker before server reads (#45977)
Open the recent-session and project picker synchronously with selectable cached rows and independent refreshes. Reconcile committed moves and deletions without restoring stale rows, preserve dismissal and selection through delayed reads, and keep filtered selections visible after asynchronous results arrive.
2026-08-28 15:18:24 -04:00
Kit Langton ebdfcf4866 test(tui): remove brittle animation sampling (#46003) 2026-08-28 19:16:47 +00:00
Kit Langton 000d0882c3 docs(core): record adapter fork boundaries (#45691) 2026-08-28 15:14:46 -04:00
Kit Langton 6062e30cb9 test(core): assert typed failures directly (#45690) 2026-08-28 15:14:42 -04:00
Kit Langton 3badee1a3c test(core): simplify config test setup (#45689) 2026-08-28 15:14:39 -04:00
Kit Langton 4a0256d374 test(core): use effect search harness (#45682) 2026-08-28 15:14:36 -04:00
Kit Langton 52ec62bef0 docs: correct config discovery boundary (#45681) 2026-08-28 15:14:32 -04:00
Kit Langton 31af9858fd test(core): assert pending prompt identity (#45680) 2026-08-28 15:14:28 -04:00
Kit Langton 3151660fbb refactor(core): name shell records as commands (#45693) 2026-08-28 15:00:37 -04:00
Kit Langton 0362ef48ff test(core): isolate transport metrics (#45688) 2026-08-28 15:00:33 -04:00
Kit Langton facd7ff452 refactor(core): simplify V1 migration effects (#45685) 2026-08-28 15:00:29 -04:00
Kit Langton 134cdda333 fix(core): normalize SDK file data (#45679) 2026-08-28 14:56:46 -04:00
Kit Langton 5634ef1bb6 refactor(core): simplify manual compaction (#45678) 2026-08-28 14:56:40 -04:00
Kit Langton 2379ab3d51 fix(core): defer memory filesystem observation (#45675) 2026-08-28 14:56:36 -04:00
Kit Langton 5c908ebba5 refactor(core): simplify reference config precedence (#45673) 2026-08-28 14:56:31 -04:00
Kit Langton ba0755d933 refactor(core): reuse platform contract types (#45666) 2026-08-28 14:56:27 -04:00
Kit Langton f7d6b00c1e test(core): use collected arrays directly (#45665) 2026-08-28 14:56:21 -04:00
250 changed files with 11606 additions and 3307 deletions
+1
View File
@@ -2,3 +2,4 @@ packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
packages/core/src/models-dev/snapshot.txt linguist-generated
packages/core/src/**/*.txt text eol=lf
packages/httpapi-codegen/test/generated/*.ts text eol=lf
+1
View File
@@ -1,6 +1,7 @@
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
+80 -42
View File
@@ -1,4 +1,4 @@
import { Effect, Schema } from "effect"
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode-ai/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -845,6 +845,21 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
const decodeMessagePart = Schema.decodeUnknownOption(
Schema.Union([OpenResponsesOutputText, Schema.Struct({ type: Schema.tag("refusal"), refusal: Schema.String })]),
)
const decodeSummaryPart = Schema.decodeUnknownOption(OpenResponsesReasoningSummaryText)
const decodeReasoningPart = Schema.decodeUnknownOption(
Schema.Struct({ type: Schema.tag("reasoning_text"), text: Schema.String }),
)
const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
if (!parts.some((part) => part !== undefined && part.length > 0)) return undefined
return parts.filter((part) => part !== undefined).join("\n\n")
}
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
@@ -1065,24 +1080,33 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
})
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
const item = event.item
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: Event["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
const message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined && state.message?.id === item.id ? state.message.phase : itemPhase
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
const content: string[] = []
for (const part of parts) {
const decoded = Option.getOrUndefined(decodeMessagePart(part))
if (!decoded) continue
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
}
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(
state.lifecycle,
events,
item.id,
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
),
message: state.message?.id === item.id ? undefined : state.message,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
message: message ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1137,17 +1161,33 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (isReasoningItem(item)) {
const events: LLMEvent[] = []
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
const summary: Array<string | undefined> = []
for (const part of summaryParts) {
const decoded = Option.getOrUndefined(decodeSummaryPart(part))
// Keep missing entries so the array still matches the provider's summary indexes.
summary.push(decoded?.text)
}
const reasoningParts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
const content: string[] = []
for (const part of reasoningParts) {
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
if (decoded) content.push(decoded.text)
}
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
const events: LLMEvent[] = []
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
if (!reasoningItem.open) return [state, NO_EVENTS] satisfies StepResult
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
)
const fragments = Object.entries(reasoningItem.summaryParts)
let lifecycle = state.lifecycle
for (const [index, status] of fragments) {
if (status === "concluded") continue
// Do not repeat earlier summaries that were already emitted as separate fragments.
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
}
return [
{
...state,
@@ -1167,7 +1207,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
events.push(
LLMEvent.reasoningEnd({
id: item.id,
providerMetadata: metadata,
text: itemText,
}),
)
return [
{
...state,
@@ -1195,32 +1241,24 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
})
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
const reconciled =
event.type === "response.completed"
? yield* Effect.reduce(
event.response?.output ?? [],
() => [state, NO_EVENTS] satisfies StepResult,
([current, events], item) => {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (
id === undefined ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]?.open))
)
return Effect.succeed([current, events] satisfies StepResult)
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
)
},
)
: ([state, NO_EVENTS] satisfies StepResult)
const current = reconciled[0]
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (id === undefined) continue
if (item.type !== "function_call" || !current.tools[id]) continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
}
}
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
events.push(...pending.events)
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
current.hasFunctionCall
@@ -1346,7 +1384,7 @@ export const step = (state: ParserState, input: Event) => {
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
+18 -5
View File
@@ -815,7 +815,12 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
case "tool_calls":
return "tool-calls" as const
default:
return "unknown" as const
return yield* new AIError({
reason: new UnknownProviderError({
message: `Provider finish_reason: ${reason}`,
body: ProviderShared.encodeJson(event),
}),
})
}
})
@@ -1054,17 +1059,25 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
events.push(...result.events)
}
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
const incompleteTools = finishReason?.normalized === "content-filter" || finishReason?.normalized === "length"
if (
finishReason !== undefined &&
!incompleteTools &&
state.finishReason === undefined &&
Object.keys(pendingTools).length
)
return yield* ProviderShared.eventError(
ADAPTER,
"OpenAI Chat tool call delta is missing id or name",
ProviderShared.encodeJson(event),
)
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
// valid calls and malformed local calls settle independently.
// Filtering or truncation terminates the response without confirming pending tool calls.
const finished =
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
finishReason !== undefined &&
!incompleteTools &&
state.finishReason === undefined &&
Object.keys(tools).length > 0
? yield* ToolStream.finishAll(ADAPTER, tools)
: undefined
+5
View File
@@ -76,6 +76,8 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const SERVER_ERROR_TEXT =
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
export interface ProviderFailure {
readonly message: string
@@ -139,6 +141,9 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
((input.status === undefined || input.status < 400) &&
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
)
return new ProviderInternalError({
+40 -1
View File
@@ -81,6 +81,46 @@ describe("provider error classification", () => {
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
test("classifies retryable server messages as provider internal", () => {
const message =
"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing."
expect(
[
message,
"Try again",
"Please retry your request shortly.",
"You can retry the request.",
"Try your request again.",
"The service is temporarily at capacity.",
"The model is overloaded.",
"Service unavailable",
"Internal server error",
"The server is busy.",
"Provider returned error",
"Provider returned an error",
"ResourceExhausted",
"Upstream connection failed",
"Exceeded request buffer limit while retrying upstream",
].map((message) => classifyProviderFailure({ message })._tag),
).toEqual(Array(15).fill("ProviderInternal"))
expect(
classifyProviderFailure({ message: "Provider request failed", rawBody: "Please try again later." })._tag,
).toBe("ProviderInternal")
})
test("prioritizes specific failures over retryable server text", () => {
expect(
[
classifyProviderFailure({ message: "Invalid credentials, try again", status: 401 }),
classifyProviderFailure({ message: "Quota exceeded, try again", status: 429 }),
classifyProviderFailure({ message: "Rate limit exceeded, try again" }),
classifyProviderFailure({ message: "Upstream request failed: validation failed", status: 400 }),
classifyProviderFailure({ message: "Try again", status: 200 }),
].map((failure) => failure._tag),
).toEqual(["Authentication", "QuotaExceeded", "RateLimit", "InvalidRequest", "ProviderInternal"])
})
test("classifies transient client statuses as provider internal", () => {
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
"ProviderInternal",
@@ -111,7 +151,6 @@ describe("provider error classification", () => {
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "network error" })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "Provider returned error" })._tag).toBe("UnknownProvider")
})
})
@@ -0,0 +1,209 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { LLMClient } from "../../src/route.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const request = LLM.request({
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
prompt: "Respond.",
})
const completed = { type: "response.completed", response: { id: "resp_1" } }
const generate = (...events: OpenResponses.Event[]) =>
LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...events))))
describe("Open Responses completed item text", () => {
;["Draft expanded", "D", "Replacement", ""].forEach((text) => {
it.effect(`replaces streamed text with completed item text ${JSON.stringify(text)}`, () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Part final" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", phase: "final_answer", content: [{ type: "output_text", text }] },
},
completed,
)
expect(response.text).toBe(text)
expect(response.events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Draft"])
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
text,
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "final_answer" } },
},
])
}),
)
})
it.effect("joins completed text and refusal parts without streamed text", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [
{ type: "output_text", text: "Answer. " },
{ type: "refusal", refusal: "Cannot help." },
],
},
},
completed,
)
expect(response.text).toBe("Answer. Cannot help.")
expect(response.events.filter(LLMEvent.is.textStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
it.effect("does not create an empty text fragment for an empty completed message", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.done", item_id: "msg_1", text: "" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "" }] },
},
completed,
)
expect(response.message.content).toEqual([])
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
})
describe("Open Responses completed item reasoning", () => {
;[
{
name: "summary",
summary: [
{ type: "summary_text", text: "Final" },
{ type: "summary_text", text: "summary" },
],
content: [{ type: "reasoning_text", text: "Raw" }],
text: "Final\n\nsummary",
},
{
name: "raw text",
summary: [
{ type: "summary_text", text: "" },
{ type: "summary_text", text: "" },
],
content: [{ type: "reasoning_text", text: "Raw" }],
text: "Raw",
},
{
name: "streamed fallback",
summary: [
{ type: "summary_text", text: "" },
{ type: "summary_text", text: "" },
],
content: [
{ type: "reasoning_text", text: "" },
{ type: "reasoning_text", text: "" },
],
text: "Draft",
},
].forEach((fixture) => {
it.effect(`uses ${fixture.name} at item completion`, () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: fixture.summary,
content: fixture.content,
encrypted_content: "encrypted",
},
},
completed,
)
expect(response.reasoning).toBe(fixture.text)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
})
}),
)
})
it.effect("replaces only the still-open summary without repeating earlier text", () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First " },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "draft" },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [
{ type: "summary_text", text: "First " },
{ type: "summary_text", text: "final" },
],
},
},
completed,
)
expect(response.reasoning).toBe("First final")
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
}),
)
})
;["response.completed", "response.incomplete"].forEach((type) => {
it.effect(`keeps streamed text when part finals are followed by ${type} without item completion`, () =>
Effect.gen(function* () {
const response = yield* generate(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 0, delta: "Hel" },
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 1, delta: "world" },
{ type: "response.output_text.done", item_id: "msg_1", content_index: 0, text: "Hello " },
{
type: "response.content_part.done",
item_id: "msg_1",
content_index: 0,
part: { type: "output_text", text: "Hello " },
},
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
{
type: "response.reasoning_summary_part.done",
item_id: "rs_1",
summary_index: 0,
part: { type: "summary_text", text: "Part final" },
},
{
type,
response: {
id: "resp_1",
incomplete_details: type === "response.incomplete" ? { reason: "max_output_tokens" } : undefined,
},
},
)
expect(response.text).toBe("Helworld")
expect(response.reasoning).toBe("Draft")
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.text)).toEqual([undefined])
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined])
}),
)
})
@@ -129,7 +129,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves done-only encrypted reasoning without replaying its summary or late events", () =>
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
@@ -157,6 +157,7 @@ describe("Open Responses basic-item lifecycles", () => {
{
type: "reasoning-end",
id: "rs_1",
text: "Not streamed",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
@@ -301,7 +302,7 @@ describe("Open Responses basic-item lifecycles", () => {
)
})
it.effect("recovers pending items in completed output order with terminal encrypted metadata", () =>
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
const events = yield* collect(
{
@@ -325,11 +326,6 @@ describe("Open Responses basic-item lifecycles", () => {
},
)
expect(events.slice(5, -2)).toEqual([
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
},
{
type: "tool-input-end",
id: "call_1",
@@ -341,8 +337,10 @@ describe("Open Responses basic-item lifecycles", () => {
id: "call_1",
name: "lookup",
input: { query: "final" },
providerExecuted: undefined,
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
{ type: "reasoning-end", id: "rs_1:0" },
])
}),
)
@@ -1460,6 +1460,162 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("does not finalize streamed tool calls when content is filtered", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
}),
deltaChunk({}, "content_filter"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{
type: "tool-input-start",
id: "call_1",
name: "lookup",
providerExecuted: undefined,
providerMetadata: undefined,
},
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"',
input: { query: "weather" },
},
{
type: "step-finish",
index: 0,
reason: { normalized: "content-filter", raw: "content_filter" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "content-filter", raw: "content_filter" }, usage: undefined },
])
expect(response.toolCalls).toEqual([])
const missingIdentity = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
deltaChunk({}, "content_filter"),
),
),
),
)
expect(missingIdentity.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
expect(missingIdentity.toolCalls).toEqual([])
}),
)
it.effect("does not finalize streamed tool calls when output is truncated", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } }],
}),
deltaChunk({}, "length"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{
type: "tool-input-start",
id: "call_1",
name: "lookup",
providerExecuted: undefined,
providerMetadata: undefined,
},
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{
type: "step-finish",
index: 0,
reason: { normalized: "length", raw: "length" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "length", raw: "length" }, usage: undefined },
])
expect(response.toolCalls).toEqual([])
const missingIdentity = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
deltaChunk({}, "length"),
),
),
),
)
expect(missingIdentity.finishReason).toEqual({ normalized: "length", raw: "length" })
expect(missingIdentity.toolCalls).toEqual([])
}),
)
it.effect("rejects unknown finish reasons without finalizing streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
}),
deltaChunk({}, "future_reason"),
)
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
const error = yield* LLMClient.stream(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
Stream.runDrain,
Effect.provide(fixedResponse(body)),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "UnknownProvider" },
message: "Provider finish_reason: future_reason",
})
expect(yield* Ref.get(events)).toEqual([
{ type: "step-start", index: 0 },
{
type: "tool-input-start",
id: "call_1",
name: "lookup",
providerExecuted: undefined,
providerMetadata: undefined,
},
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"',
input: { query: "weather" },
},
])
}),
)
it.effect("ignores empty identity fields on later tool call deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -554,6 +554,19 @@ describe("OpenAI-compatible Chat route", () => {
reason: { _tag: "UnknownProvider" },
message: "Provider reported an error (finish_reason: error)",
})
const unknown = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
Effect.flip,
)
expect(unknown).toMatchObject({
reason: { _tag: "UnknownProvider" },
message: "Provider finish_reason: future_reason",
})
expect(decodeJson(unknown.reason.body ?? "")).toMatchObject({
id: "chatcmpl_fixture",
choices: [{ finish_reason: "future_reason" }],
})
}),
)
@@ -581,17 +594,13 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("preserves provider finish outcomes in the common reason algebra", () =>
it.effect("preserves content-filter finishes in the common reason algebra", () =>
Effect.gen(function* () {
const filtered = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "content_filter")))),
)
const future = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
)
expect(filtered.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
expect(future.finishReason).toEqual({ normalized: "unknown", raw: "future_reason" })
}),
)
@@ -406,7 +406,7 @@ describe("Open Responses-compatible route", () => {
})
routings.forEach((routing) => {
it.effect(`preserves reasoning summary boundaries and terminal metadata with ${routing.name}`, () =>
it.effect(`preserves reasoning summary boundaries without terminal reconciliation 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(
@@ -444,21 +444,18 @@ describe("Open Responses-compatible route", () => {
type: "reasoning",
text: "Second.",
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
},
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
expect.objectContaining({
{
type: "reasoning-end",
id: `${routing.id}:0`,
text: undefined,
providerMetadata: { "openai-compatible": { itemId: routing.id } },
}),
expect.objectContaining({
id: `${routing.id}:1`,
providerMetadata: {
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
},
}),
},
{ type: "reasoning-end", id: `${routing.id}:1` },
])
}),
)
@@ -671,7 +668,7 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
it.effect("ignores terminal reasoning output when item completion is missing", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
@@ -697,8 +694,9 @@ describe("Open Responses-compatible route", () => {
),
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "rs_raw:0",
})
}),
)
@@ -2554,7 +2554,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves terminal reasoning metadata when output item completion is missing", () =>
it.effect("ignores terminal reasoning output when item completion is missing", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
@@ -2595,29 +2595,13 @@ describe("OpenAI Responses route", () => {
expect(response.reasoning).toBe("Checked the diff.")
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
},
{ type: "reasoning-end", id: "rs_1:0" },
])
expect(response.message.content).toContainEqual({
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
})
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message], providerOptions: { store: false } }),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "terminal-state",
},
])
}),
)
@@ -2644,7 +2628,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles pending reasoning and function calls in completed output order", () =>
it.effect("recovers pending function calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
@@ -2682,14 +2666,15 @@ describe("OpenAI Responses route", () => {
),
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "rs_1:0",
})
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
])
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex(LLMEvent.is.toolCall),
expect(response.events.findIndex(LLMEvent.is.toolCall)).toBeLessThan(
response.events.findIndex((event) => event.type === "reasoning-end"),
)
expect(response.finishReason.normalized).toBe("tool-calls")
}),
@@ -3019,6 +3004,7 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning-end",
id: "rs_1:0",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
@@ -20,95 +20,134 @@ const inventory: WorktreeDirectory[] = [
test.use({ serviceWorkers: "block" })
for (const scenario of [
{ name: "managed Git worktree", directory: workspace, accent: true },
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
{
name: "linked Git worktree on a narrow screen",
directory: "C:/OpenCode/LinkedWorkspace",
accent: true,
viewport: { width: 390, height: 844 },
},
{ name: "main root with Windows case and separators", directory: "c:\\OPENCODE\\workspaceaccent\\", accent: false },
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
{
name: "workspace with Windows case and separators",
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
accent: true,
},
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
]) {
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
const view = await openSession(page, scenario.directory)
await view.input.fill("Inspect this fixture workspace.")
await expect(view.send).toBeEnabled()
for (const theme of ["light", "dark"] as const) {
test.describe(theme, () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((theme) => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", theme)
}, theme)
})
if (scenario.name === "managed Git worktree") {
// Capture before the color assertion so both red and green runs have evidence.
const path = testInfo.outputPath("workspace-accent.png")
await view.composer.screenshot({ path })
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
for (const scenario of [
{ name: "managed Git worktree", directory: workspace, accent: true },
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
{
name: "linked Git worktree on a narrow screen",
directory: "C:/OpenCode/LinkedWorkspace",
accent: true,
viewport: { width: 390, height: 844 },
},
{
name: "main root with Windows case and separators",
directory: "c:\\OPENCODE\\workspaceaccent\\",
accent: false,
},
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
{
name: "workspace with Windows case and separators",
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
accent: true,
},
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
]) {
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
const view = await openSession(page, scenario.directory)
await view.input.fill("Inspect this fixture workspace.")
await expect(view.send).toBeEnabled()
if (scenario.name === "managed Git worktree") {
// Capture before the color assertion so both red and green runs have evidence.
const path = testInfo.outputPath("workspace-accent.png")
await view.composer.screenshot({ path })
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
}
await expectBackground(view.send, "contrast")
await view.send.hover()
await expectBackground(view.send, "contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectBackground(
message,
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
"background-color",
)
})
}
await expectBackground(view.send, scenario.accent ? "accent" : "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectBackground(message, scenario.accent ? "accent" : "layer-02", "background-color")
test("inventory updates leave send neutral; disabled and stop stay neutral", async ({ page }) => {
const view = await openSession(page, workspace, [{ directory: root }])
await view.input.fill("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
const url = page.url()
const refreshed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
)
view.worktrees.push({ directory: workspace, strategy: "git" })
view.events.push({
id: "evt_workspace_accent_inventory",
created: 1700000001000,
type: "worktree.updated",
data: { projectID },
})
expect((await refreshed).ok()).toBe(true)
await expectBackground(view.send, "contrast")
await expect(page).toHaveURL(url)
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await view.input.fill("")
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
view.events.push({
id: "evt_workspace_accent_running",
created: 1700000002000,
type: "session.execution.started",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID },
})
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
await expect(stop).toBeEnabled()
await expectBackground(stop, "contrast")
await view.input.fill("Send a follow-up instead of stopping.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await expect(page).toHaveURL(url)
})
test("new workspace send button stays neutral", async ({ page }) => {
const view = await openSession(page, root, [...inventory], true)
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await view.input.fill("Inspect this fixture workspace.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
await view.send.hover()
await expectBackground(view.send, "contrast")
await view.composer.locator('[data-action="composer-model"]').press("Tab")
await expect(view.send).toBeFocused()
await expectBackground(view.send, "contrast")
})
})
}
test("inventory updates recolor the send button without navigation; disabled and stop stay neutral", async ({
page,
}) => {
const view = await openSession(page, workspace, [{ directory: root }])
await view.input.fill("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "contrast")
const url = page.url()
const refreshed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
)
view.worktrees.push({ directory: workspace, strategy: "git" })
view.events.push({
id: "evt_workspace_accent_inventory",
created: 1700000001000,
type: "worktree.updated",
data: { projectID },
})
expect((await refreshed).ok()).toBe(true)
await expectBackground(view.send, "accent")
await expect(page).toHaveURL(url)
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
await expect(view.send).toBeEnabled()
await view.input.fill("")
await expect(view.send).toBeDisabled()
await expectBackground(view.send, "contrast")
view.events.push({
id: "evt_workspace_accent_running",
created: 1700000002000,
type: "session.execution.started",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID },
})
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
await expect(stop).toBeEnabled()
await expectBackground(stop, "contrast")
await view.input.fill("Send a follow-up instead of stopping.")
await expect(view.send).toBeEnabled()
await expectBackground(view.send, "accent")
await expect(page).toHaveURL(url)
})
async function openSession(page: Page, directory: string, worktrees = [...inventory]) {
async function openSession(page: Page, directory: string, worktrees = [...inventory], draft = false) {
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
directory,
@@ -159,18 +198,32 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
})
await page.addInitScript(() => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", "light")
})
if (draft)
await page.addInitScript(
({ root, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: root, expanded: true }] },
lastProject: { local: root },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID: "draft_workspace_accent", server, directory: root }]),
)
},
{ root, server },
)
const loaded = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await page.goto(
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
)
expect((await loaded).ok()).toBe(true)
await expectSessionReady(page, { server, sessionID, title })
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light")
if (!draft) await expectSessionReady(page, { server, sessionID, title })
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
+1 -7
View File
@@ -12,12 +12,7 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
accentSubmit?: boolean
}) {
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
@@ -26,7 +21,6 @@ export function Composer(props: {
<div class="flex flex-col gap-3">
<ComposerEditor
controller={props.model}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay}
class={props.class}
modelControlsVisible={!props.model.model.loading}
+2 -11
View File
@@ -37,7 +37,6 @@ export type ComposerMode = "normal" | "shell"
export type ComposerEditorProps = {
controller: ComposerEditorModel
accentSubmit?: boolean
disabled?: boolean
readOnly?: boolean
borderUnderlay?: boolean
@@ -265,7 +264,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
mode={state.mode}
stopping={view.submit.stopping()}
disabled={!props.controller.canSubmit()}
accent={props.accentSubmit}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
@@ -751,7 +749,6 @@ export function ComposerEditorSubmitButton(props: {
mode: ComposerMode
stopping: boolean
disabled: boolean
accent?: boolean
sendLabel: string
stopLabel: string
onSubmit: () => void
@@ -770,16 +767,10 @@ export function ComposerEditorSubmitButton(props: {
tabIndex={props.mode === "normal" ? undefined : -1}
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
variant="contrast"
class="size-7 rounded-md p-[6px] shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
classList={{
"text-v2-text-text-contrast": !!props.accent && !props.stopping && !props.disabled,
"text-v2-icon-icon-muted": !props.accent || props.stopping || props.disabled,
}}
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
props.accent && !props.stopping && !props.disabled
? "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-accent) 0%,var(--v2-background-bg-accent) 100%)"
: "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
onClick={(event) => {
+1 -1
View File
@@ -48,7 +48,7 @@ export function NewSessionView(props: {
<div class={NEW_SESSION_CONTENT_WIDTH}>
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8 flex flex-col gap-8">
<Composer model={props.composer} accentSubmit={props.workspace.selection.workspace()} />
<Composer model={props.composer} />
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
+1 -2
View File
@@ -216,7 +216,6 @@ export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegi
export function ActiveSessionComposerRegion(props: {
model: ActiveSessionRegionModel
session: SessionModel
accentSubmit: boolean
onResponseSubmit: () => void
}) {
const settings = useSettings()
@@ -251,7 +250,7 @@ export function ActiveSessionComposerRegion(props: {
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
<Composer model={composer} borderUnderlay />
</div>
</div>
}
+1 -6
View File
@@ -145,12 +145,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<Show when={!review.mobile.changes() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<ActiveSessionComposerRegion
model={composer}
session={session}
accentSubmit={session.workspace.current()}
onResponseSubmit={timeline.actions.resume}
/>
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
)}
</Show>
<Show when={!!session.identity.params.id && mobileTabsBottom()}>
+81 -31
View File
@@ -22,6 +22,9 @@ type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission
export type TurnControl = {
cancelled: boolean
admitted?: boolean
interrupting?: boolean
stream?: AbortController
readonly admission: AbortController
}
@@ -69,6 +72,44 @@ function emptyToolState(): ToolState {
return { name: "tool", input: {}, metadata: {}, content: [] }
}
async function openEventStream(input: {
readonly client: OpenCodeClient
readonly streamController: AbortController
readonly admission: AbortController
readonly connectionSignal?: AbortSignal
readonly sessionSignal?: AbortSignal
}) {
const connectionAbort = () => {
input.streamController.abort()
input.admission.abort()
}
const sessionAbort = () => {
input.streamController.abort()
input.admission.abort()
}
let stream: AsyncIterator<EventSubscribeOutput> | undefined
let opened = false
const close = async () => {
input.streamController.abort()
input.connectionSignal?.removeEventListener("abort", connectionAbort)
input.sessionSignal?.removeEventListener("abort", sessionAbort)
await stream?.return?.(undefined).catch(() => {})
}
try {
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
input.sessionSignal?.addEventListener("abort", sessionAbort, { once: true })
if (input.connectionSignal?.aborted) connectionAbort()
if (input.sessionSignal?.aborted) sessionAbort()
stream = input.client.event.subscribe({ signal: input.streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission")
opened = true
return { stream, close }
} finally {
if (!opened) await close()
}
}
export async function streamTurn(input: {
readonly client: OpenCodeClient
readonly connection: Connection
@@ -83,12 +124,17 @@ export async function streamTurn(input: {
readonly connectionSignal?: AbortSignal
readonly sessionSignal?: AbortSignal
}): Promise<PromptResponse> {
const streamController = new AbortController()
const connectionAbort = () => streamController.abort()
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission")
const streamController = input.control.stream ?? new AbortController()
input.control.stream = streamController
const opened = await openEventStream({
client: input.client,
streamController,
admission: input.control.admission,
connectionSignal: input.connectionSignal,
sessionSignal: input.sessionSignal,
})
const stream = opened.stream
const closeStream = opened.close
const control = input.control
let started = false
@@ -173,6 +219,7 @@ export async function streamTurn(input: {
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
if (matchesStart(event, input.start)) {
started = true
control.admitted = true
continue
}
if (!started) continue
@@ -335,34 +382,38 @@ export async function streamTurn(input: {
return "interrupted" as const
}
const completed = consume("turn")
const closeStream = async () => {
streamController.abort()
input.connectionSignal?.removeEventListener("abort", connectionAbort)
input.sessionSignal?.removeEventListener("abort", connectionAbort)
await stream.return?.(undefined).catch(() => {})
}
const completed = consume("turn").then(
(value) => ({ success: true as const, value }),
(error) => ({ success: false as const, error }),
)
const submitted = input.submit(control.admission.signal).then(
() => ({ success: true as const }),
(error) => ({ success: false as const, error }),
)
try {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
})
if (input.action) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
}
if (control.cancelled) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (!started) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "interrupted", true, undefined)
const first = await Promise.race([
submitted.then((result) => ({ source: "admission" as const, result })),
completed.then((result) => ({ source: "stream" as const, result })),
])
const completion = await (async () => {
if (first.source === "stream") {
control.admission.abort()
return first.result
}
}
const terminal = await completed
if (!first.result.success && !control.cancelled) throw first.result.error
if (first.result.success) control.admitted = true
if (input.action) {
streamController.abort()
await completed
return { success: true as const, value: "succeeded" as const }
}
if (control.cancelled && !started) streamController.abort()
return completed
})()
if (!completion.success && !control.cancelled) throw completion.error
const terminal = completion.success ? completion.value : "interrupted"
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
handedOff = true
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
void consume("background")
.catch(() => {})
.finally(closeStream)
@@ -381,7 +432,6 @@ export async function streamTurn(input: {
)
} catch (error) {
streamController.abort()
await completed.catch(() => {})
throw error
} finally {
if (!handedOff) await closeStream()
+337 -126
View File
@@ -9,6 +9,7 @@ import {
type SkillInfo,
} from "@opencode-ai/client/promise"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { Effect, Semaphore } from "effect"
import type {
AgentSideConnection,
AuthenticateRequest,
@@ -78,6 +79,27 @@ type Attached = {
modeID: string
}
type ActiveTurn = {
readonly state: Attached
readonly control: TurnControl
readonly stopped: Promise<void>
readonly resolveStopped: () => void
}
type RegisteredMcp = {
readonly server: string
readonly config: ReturnType<typeof mcpConfig>
}
class McpRollbackError extends AggregateError {
readonly servers: ReadonlyArray<string>
constructor(primary: unknown, rollback: unknown, servers: ReadonlyArray<string>) {
super([primary, rollback], "ACP attachment failed and MCP rollback did not complete", { cause: primary })
this.servers = servers
}
}
type PreparedPrompt = {
readonly start: TurnStart
readonly text: string
@@ -104,11 +126,42 @@ export interface Interface {
cancel(input: CancelNotification): Promise<void>
}
type KeyedLock<Key> = <A>(key: Key, operation: () => Promise<A>) => Promise<A>
function makeKeyedLock<Key>(): KeyedLock<Key> {
const entries = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()
return async <A>(key: Key, operation: () => Promise<A>) => {
const current = entries.get(key)
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
if (!current) entries.set(key, entry)
// Count holders and waiters so cleanup cannot split one key across two locks.
entry.users++
const result = await Effect.runPromise(
entry.semaphore.withPermit(
Effect.promise(() =>
operation().then(
(value) => ({ success: true as const, value }),
(error) => ({ success: false as const, error }),
),
),
),
).finally(() => {
entry.users--
if (entry.users === 0) entries.delete(key)
})
if (!result.success) throw result.error
return result.value
}
}
export function make(input: { readonly client: OpenCodeClient; readonly connection: Connection }): Interface {
const sessions = new Map<string, Attached>()
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
const registeredMcp = new Map<string, Map<string, RegisteredMcp>>()
const uncertainMcp = new Map<string, Set<string>>()
const active = new Map<string, ActiveTurn>()
const withSessionLock = makeKeyedLock<string>()
const withLocationLock = makeKeyedLock<string>()
const capabilities = { writeTextFile: false, childSessionUpdates: false }
const catalog = (cwd: string) => {
@@ -128,15 +181,50 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
}
const detach = (sessionID: string) => {
sessions.get(sessionID)?.abort.abort()
sessions.delete(sessionID)
registeredMcp.delete(sessionID)
const retire = (state: Attached) => {
state.abort.abort()
const turn = active.get(state.id)
if (turn?.state !== state) return
turn.control.admission.abort()
active.delete(state.id)
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const detach = (sessionID: string) => {
const state = sessions.get(sessionID)
if (state) retire(state)
sessions.delete(sessionID)
}
const invalidateLocation = (cwd: string, servers: ReadonlyArray<string>) => {
sessions.forEach((state, sessionID) => {
if (state.cwd !== cwd) return
retire(state)
sessions.delete(sessionID)
})
const uncertain = new Set([...(registeredMcp.get(cwd)?.keys() ?? []), ...servers])
if (uncertain.size > 0) uncertainMcp.set(cwd, uncertain)
registeredMcp.delete(cwd)
}
const reconcileLocation = async (cwd: string) => {
const uncertain = uncertainMcp.get(cwd)
if (!uncertain) return
const removed = await Promise.allSettled(
[...uncertain].map((server) => input.client.mcp.remove({ server, location: { directory: cwd } })),
)
const failures = removed.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))
if (failures.length > 0) throw new AggregateError(failures, "Failed to reconcile uncertain MCP configuration")
uncertainMcp.delete(cwd)
}
// Lifecycle operations acquire Session ID before entering this Location transaction.
const attach = async (
session: SessionInfo,
cwd: string,
mcpServers: readonly McpServer[],
replayHistory: boolean,
) => {
const currentCatalog = await catalog(cwd)
sessions.get(session.id)?.abort.abort()
const state: Attached = {
id: session.id,
cwd,
@@ -145,25 +233,56 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
model: session.model ?? currentCatalog.defaultModel,
modeID: session.agent ?? currentCatalog.defaultModeID,
}
sessions.set(session.id, state)
await registerMcpServers(input.client, registeredMcp, state, mcpServers)
await input.connection.sessionUpdate({
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
...state.catalog.commands,
...state.catalog.skills.filter(
(skill) => !state.catalog.commands.some((command) => command.name === skill.name),
),
].map((command) => ({ name: command.name, description: command.description ?? "" })),
},
return withLocationLock(cwd, async () => {
const history = replayHistory ? await messages(input.client, state.id) : undefined
await reconcileLocation(cwd)
const registration = await registerMcpServers(
input.client,
registeredMcp.get(cwd) ?? new Map(),
state,
mcpServers,
).catch((error) => {
state.abort.abort()
if (error instanceof McpRollbackError) invalidateLocation(cwd, error.servers)
throw error
})
return input.connection
.sessionUpdate({
sessionId: state.id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
...state.catalog.commands,
...state.catalog.skills.filter(
(skill) => !state.catalog.commands.some((command) => command.name === skill.name),
),
].map((command) => ({ name: command.name, description: command.description ?? "" })),
},
})
.then(async () => {
if (history) await replayMessages(input.connection, state.id, state.cwd, history)
const previous = sessions.get(session.id)
if (previous) retire(previous)
sessions.set(session.id, state)
registeredMcp.set(cwd, registration.registered)
return state
})
.catch(async (error) => {
state.abort.abort()
const rollback = await registration.rollback().then(
() => ({ success: true as const }),
(failure) => ({ success: false as const, failure }),
)
if (!rollback.success) {
invalidateLocation(cwd, registration.changed)
throw new McpRollbackError(error, rollback.failure, registration.changed)
}
throw error
})
}).catch((error) => {
state.abort.abort()
throw error
})
return state
}
const replay = async (state: Attached) => {
await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
}
const configOptions = (state: Attached) =>
@@ -213,15 +332,15 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
agent: currentCatalog.defaultModeID,
model: currentCatalog.defaultModel,
})
const state = await attach(created, params.cwd, params.mcpServers)
const state = await withSessionLock(created.id, () => attach(created, params.cwd, params.mcpServers, false))
return { sessionId: state.id, configOptions: configOptions(state) }
},
loadSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers)
await replay(state)
return { configOptions: configOptions(state) }
},
loadSession: (params) =>
withSessionLock(params.sessionId, async () => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers, true)
return { configOptions: configOptions(state) }
}),
listSessions: async (params) => {
const page = await input.client.session.list({
...(params.cwd ? { directory: params.cwd } : {}),
@@ -239,86 +358,109 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
},
deleteSession: async (params) => {
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
detach(params.sessionId)
return {}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
detach(params.sessionId)
const turn = active.get(params.sessionId)
if (turn) {
turn.cancelled = true
turn.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
return {}
},
deleteSession: (params) =>
withSessionLock(params.sessionId, async () => {
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
detach(params.sessionId)
return {}
}),
resumeSession: (params) =>
withSessionLock(params.sessionId, async () => {
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers ?? [], false)
return { configOptions: configOptions(state) }
}),
closeSession: (params) =>
withSessionLock(params.sessionId, async () => {
const turn = active.get(params.sessionId)
if (turn) {
turn.control.cancelled = true
turn.control.interrupting = true
}
detach(params.sessionId)
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
return {}
}),
forkSession: async (params) => {
const forked = await input.client.session.fork({
sessionID: params.sessionId,
boundary: { type: "through" },
const forked = await withSessionLock(params.sessionId, () =>
input.client.session.fork({
sessionID: params.sessionId,
boundary: { type: "through" },
}),
)
const state = await withSessionLock(forked.id, async () => {
return attach(forked, forked.location.directory, params.mcpServers ?? [], true)
})
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state)
return { sessionId: state.id, configOptions: configOptions(state) }
},
setSessionConfigOption: async (params) => {
const state = await requireSession(params.sessionId)
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
return withSessionLock(params.sessionId, async () => {
const state = await requireSession(params.sessionId)
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value)
await input.client.session.switchModel({ sessionID: state.id, model: selected })
state.model = selected
break
}
case "effort": {
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (!model?.variants.some((variant) => variant.id === params.value))
throw new ACPError.InvalidEffortError({ effort: params.value })
const selected = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: selected })
state.model = selected
break
}
case "mode":
await selectMode(input.client, state, params.value)
break
default:
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
case "effort": {
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (!model?.variants.some((variant) => variant.id === params.value))
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
break
}
case "mode":
await selectMode(input.client, state, params.value)
break
default:
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
}
return { configOptions: configOptions(state) }
return { configOptions: configOptions(state) }
})
},
setSessionMode: async (params) => {
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
await withSessionLock(params.sessionId, async () => {
const state = await requireSession(params.sessionId)
await selectMode(input.client, state, params.modeId)
})
return {}
},
prompt: async (params) => {
const state = await requireSession(params.sessionId)
if (active.has(state.id)) {
throw new ACPError.ServiceFailureError({
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
service: "session",
})
}
const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const acquired = await withSessionLock(params.sessionId, async () => {
const state = await requireSession(params.sessionId)
if (active.has(state.id)) {
throw new ACPError.ServiceFailureError({
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
service: "session",
})
}
const prepared = preparePrompt(state.catalog, params.prompt, SessionMessage.ID.create())
const control: TurnControl = {
cancelled: false,
admission: new AbortController(),
stream: new AbortController(),
}
const stopped = Promise.withResolvers<void>()
const turn = { state, control, stopped: stopped.promise, resolveStopped: () => stopped.resolve() }
active.set(state.id, turn)
return { state, control, prepared, turn }
})
const state = acquired.state
const control = acquired.control
const prepared = acquired.prepared
const extNotification = input.connection.extNotification
const childSessionUpdate =
capabilities.childSessionUpdates && extNotification
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
: undefined
active.set(state.id, control)
const response = await streamTurn({
client: input.client,
connection: input.connection,
@@ -333,18 +475,49 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
...(childSessionUpdate ? { childSessionUpdate } : {}),
}).finally(() => {
if (active.get(state.id) === control) active.delete(state.id)
if (active.get(state.id) === acquired.turn) active.delete(state.id)
acquired.turn.resolveStopped()
})
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
return response
},
cancel: async (params) => {
const current = active.get(params.sessionId)
if (current) {
current.cancelled = true
current.admission.abort()
const cancellation = await withSessionLock(params.sessionId, async () => {
const current = active.get(params.sessionId)
if (!current) return undefined
current.control.cancelled = true
current.control.admission.abort()
if (!current.control.admitted) {
current.control.stream?.abort()
return { current, interrupt: false as const }
}
if (current.control.interrupting) return { current, interrupt: false as const }
current.control.interrupting = true
return { current, interrupt: true as const }
})
if (!cancellation) return
const current = cancellation.current
if (!cancellation.interrupt) {
await current.stopped
return
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
const interrupted = await input.client.session.interrupt({ sessionID: params.sessionId }).catch(async (error) => {
await withSessionLock(params.sessionId, async () => {
if (active.get(params.sessionId) === current) current.control.interrupting = false
})
throw error
})
if (!interrupted.interrupted) {
await withSessionLock(params.sessionId, async () => {
if (active.get(params.sessionId) === current) current.control.interrupting = false
})
throw new ACPError.ServiceFailureError({
safeMessage: `Failed to interrupt active ACP prompt: ${params.sessionId}`,
service: "session",
})
}
current.control.stream?.abort()
await current.stopped
},
}
}
@@ -364,16 +537,21 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
if (prompt.synthetic.length > 0) {
await client.session.synthetic({
sessionID: session.id,
text: prompt.synthetic.join("\n\n"),
description: "ACP embedded context",
delivery: "steer",
resume: false,
})
await client.session.synthetic(
{
sessionID: session.id,
text: prompt.synthetic.join("\n\n"),
description: "ACP embedded context",
delivery: "steer",
resume: false,
},
{ signal },
)
}
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
if (prompt.skill) return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id })
if (prompt.start.type === "compaction")
return client.session.compact({ sessionID: session.id, id: prompt.start.id }, { signal })
if (prompt.skill)
return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id }, { signal })
if (prompt.command) {
return client.session.command(
{
@@ -461,8 +639,8 @@ function requireModel(catalog: Catalog, modelID: string): ModelRef {
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
state.modeID = modeID
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
state.modeID = modeID
}
async function getSession(client: OpenCodeClient, sessionID: string) {
@@ -487,26 +665,59 @@ async function messages(client: OpenCodeClient, sessionID: string) {
async function registerMcpServers(
client: OpenCodeClient,
registered: Map<string, Set<string>>,
current: Map<string, RegisteredMcp>,
session: Attached,
servers: readonly McpServer[],
) {
const current = registered.get(session.id) ?? new Set<string>()
registered.set(session.id, current)
await Promise.all(
servers.flatMap((server) => {
const requested = new Map(
servers.map((server) => {
const config = mcpConfig(server)
const key = `${server.name}:${stableStringify(config)}`
if (current.has(key)) return []
current.add(key)
return [
client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => {
current.delete(key)
throw error
}),
]
return [server.name, { server: server.name, config }] as const
}),
)
const changed = [...requested.values()].filter(
(entry) => stableStringify(current.get(entry.server)?.config) !== stableStringify(entry.config),
)
const registered = new Map(current)
changed.forEach((entry) => registered.set(entry.server, entry))
const rollback = async () => {
const results = await Promise.allSettled(
changed.map((entry) => {
const previous = current.get(entry.server)
if (previous) {
return client.mcp.add({
server: previous.server,
location: { directory: session.cwd },
config: previous.config,
})
}
return client.mcp.remove({ server: entry.server, location: { directory: session.cwd } })
}),
)
const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))
if (failures.length > 0) throw new AggregateError(failures, "Failed to roll back MCP configuration")
}
const additions = await Promise.allSettled(
changed.map((entry) =>
client.mcp.add({ server: entry.server, location: { directory: session.cwd }, config: entry.config }),
),
)
const failure = additions.find((result): result is PromiseRejectedResult => result.status === "rejected")
if (failure) {
const rollbackResult = await rollback().then(
() => ({ success: true as const }),
(error) => ({ success: false as const, error }),
)
if (!rollbackResult.success) {
throw new McpRollbackError(
failure.reason,
rollbackResult.error,
changed.map((entry) => entry.server),
)
}
throw failure.reason
}
return { registered, rollback, changed: changed.map((entry) => entry.server) }
}
function mcpConfig(server: McpServer) {
+5
View File
@@ -375,6 +375,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
cors: Flag.string("cors").pipe(
Flag.withSchema(Schema.NonEmptyString),
Flag.withDescription("Additional allowed CORS origin (repeat for multiple origins)"),
Flag.atLeast(0),
),
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
},
@@ -11,6 +11,7 @@ export default Runtime.handler(
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
hostname: Option.getOrUndefined(input.hostname),
port: Option.getOrUndefined(input.port),
cors: input.cors.length > 0 ? input.cors : undefined,
})
}),
)
+2
View File
@@ -22,6 +22,7 @@ export type Options = {
readonly mode: Mode
readonly hostname?: string
readonly port?: number
readonly cors?: readonly string[]
}
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
@@ -88,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
},
hostname,
port,
cors: options.cors ?? config.cors,
password,
pty: { handoff },
simulation: truthy(process.env.OPENCODE_SIMULATE),
+25 -2
View File
@@ -15,11 +15,12 @@ export const Info = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
cors: Schema.optional(Schema.Array(Schema.String)),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type Info = typeof Info.Type
const keys = ["hostname", "port", "password", "env"] as const
const keys = ["hostname", "port", "password", "cors", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -77,7 +78,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
throw new Error(`Unknown service config key: ${key}`)
}
@@ -160,6 +161,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
case "password": {
return yield* password()
}
case "cors": {
return JSON.stringify((yield* read()).cors ?? [], null, 2)
}
case "env": {
const env = (yield* read()).env ?? {}
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
@@ -197,6 +201,19 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
return
}
case "cors": {
const cors = value.split(",").map((origin) => origin.trim())
if (
cors.some((origin) => {
const url = URL.parse(origin)
return !url || (url.protocol !== "http:" && url.protocol !== "https:") || url.origin !== origin
})
)
throw new Error("CORS must be a comma-separated list of HTTP(S) origins without paths or trailing slashes")
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), cors })
return
}
}
})
@@ -231,6 +248,12 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
return
}
case "cors": {
yield* Service.stop(yield* options())
const { cors: _cors, ...next } = yield* read()
yield* write(next)
return
}
}
})
+165 -1
View File
@@ -624,6 +624,7 @@ describe("acp event behavior", () => {
try {
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
control.cancelled = true
control.interrupting = true
control.admission.abort()
expect(await fixture.client.session.interrupt({ sessionID: "ses_cancel" })).toEqual({ interrupted: true })
@@ -669,7 +670,7 @@ describe("acp event behavior", () => {
const response = await withTimeout(result, "pre-admission cancellation did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(0)
} finally {
control.cancelled = true
control.admission.abort()
@@ -678,6 +679,169 @@ describe("acp event behavior", () => {
}
})
test("observes stream failure while cancelled admission settles", async () => {
const fixture = createSseFixture()
try {
for (let index = 0; index < 20; index++) {
const submitted = Promise.withResolvers<void>()
const session = new AbortController()
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionID: `ses_abort_stress_${index}`,
cwd: "/workspace",
start: { type: "input", id: `input_abort_stress_${index}` },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
sessionSignal: session.signal,
submit: (signal) =>
new Promise<void>((resolve) => {
submitted.resolve()
signal.addEventListener(
"abort",
() => {
void Bun.sleep(5).then(resolve)
},
{ once: true },
)
}),
})
const observed = result.catch((error: unknown) => error)
await submitted.promise
session.abort()
expect(await withTimeout(observed, "cancelled stress turn did not settle")).toBeInstanceOf(Error)
}
} finally {
await fixture.stop()
}
})
test("connection abort stops a turn with hanging admission", async () => {
const fixture = createSseFixture()
const submitted = Promise.withResolvers<void>()
const admissionAborted = Promise.withResolvers<void>()
const connection = new AbortController()
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
connectionSignal: connection.signal,
sessionID: "ses_connection_abort",
cwd: "/workspace",
start: { type: "input", id: "input_connection_abort" },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) =>
new Promise<void>(() => {
submitted.resolve()
signal.addEventListener("abort", () => admissionAborted.resolve(), { once: true })
}),
})
const observed = result.catch((error: unknown) => error)
try {
await submitted.promise
connection.abort()
await withTimeout(admissionAborted.promise, "connection abort did not cancel admission")
expect(await withTimeout(observed, "connection-aborted turn did not settle")).toBeInstanceOf(Error)
} finally {
await fixture.stop()
}
})
test("cleans stream setup when attachment is already aborted", async () => {
const fixture = createSseFixture()
const session = new AbortController()
let submitted = false
session.abort()
try {
const failure = await streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionSignal: session.signal,
sessionID: "ses_preaborted",
cwd: "/workspace",
start: { type: "input", id: "input_preaborted" },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: async () => {
submitted = true
},
}).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(Error)
expect(submitted).toBe(false)
expect(fixture.streamCount()).toBe(0)
} finally {
await fixture.stop()
}
})
test("preserves admission failure when admission settles first", async () => {
const fixture = createSseFixture()
const expected = new Error("admission failed first")
try {
const failure = await streamTurn({
client: fixture.client,
connection: recordingConnection([]),
sessionID: "ses_admission_first",
cwd: "/workspace",
start: { type: "input", id: "input_admission_first" },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: () => Promise.reject(expected),
}).catch((error: unknown) => error)
expect(failure).toBe(expected)
} finally {
await fixture.stop()
}
})
test("preserves stream failure when stream settles before admission", async () => {
const fixture = createSseFixture()
const connection = new AbortController()
const submitted = Promise.withResolvers<void>()
const lateAdmission = new Error("admission failed later")
const result = streamTurn({
client: fixture.client,
connection: recordingConnection([]),
connectionSignal: connection.signal,
sessionID: "ses_stream_first",
cwd: "/workspace",
start: { type: "input", id: "input_stream_first" },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) =>
new Promise<void>((_, reject) => {
submitted.resolve()
signal.addEventListener(
"abort",
() => {
void Bun.sleep(20).then(() => reject(lateAdmission))
},
{ once: true },
)
}),
})
const observed = result.catch((error: unknown) => error)
try {
await submitted.promise
connection.abort()
const failure = await withTimeout(observed, "stream-first failure did not settle")
expect(failure).toBeInstanceOf(Error)
expect(failure).not.toBe(lateAdmission)
} finally {
await fixture.stop()
}
})
test("cancels unsupported session forms so execution can continue", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
@@ -181,6 +181,180 @@ describe("acp service directory behavior", () => {
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
})
test("keeps the last confirmed config after switch requests are rejected", async () => {
let rejected: "model" | "effort" | "config-mode" | "mode" | undefined
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_rejected_config") })
}
if (request.method === "POST" && request.path === "/api/session/ses_rejected_config/model") {
if (rejected === "model" || rejected === "effort") return new Response(null, { status: 409 })
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_rejected_config/agent") {
if (rejected === "config-mode" || rejected === "mode") return new Response(null, { status: 409 })
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
rejected = "model"
const modelFailure = await fixture.service
.setSessionConfigOption({ sessionId: session.sessionId, configId: "model", value: "test/second-model" })
.catch((error: unknown) => error)
rejected = undefined
const afterModelFailure = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
})
rejected = "effort"
const effortFailure = await fixture.service
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "default" })
.catch((error: unknown) => error)
rejected = undefined
const afterEffortFailure = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "plan",
})
rejected = "config-mode"
const configModeFailure = await fixture.service
.setSessionConfigOption({ sessionId: session.sessionId, configId: "mode", value: "build" })
.catch((error: unknown) => error)
rejected = undefined
const afterConfigModeFailure = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/test-model",
})
rejected = "mode"
const modeFailure = await fixture.service
.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
.catch((error: unknown) => error)
rejected = undefined
const afterModeFailure = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
})
expect([modelFailure, effortFailure, configModeFailure, modeFailure]).toEqual([
expect.any(Error),
expect.any(Error),
expect.any(Error),
expect.any(Error),
])
expect(currentValue(afterModelFailure, "model")).toBe("test/test-model")
expect(currentValue(afterModelFailure, "effort")).toBe("high")
expect(currentValue(afterEffortFailure, "effort")).toBe("high")
expect(currentValue(afterConfigModeFailure, "mode")).toBe("plan")
expect(currentValue(afterModeFailure, "mode")).toBe("plan")
})
test("orders overlapping model and effort switches for one session", async () => {
const modelStarted = Promise.withResolvers<void>()
const releaseModel = Promise.withResolvers<void>()
let serverModel = "test/test-model/default"
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_ordered_model") })
}
if (request.method !== "POST" || request.path !== "/api/session/ses_ordered_model/model") return undefined
if (serverModel === "test/test-model/default") {
serverModel = "test/second-model"
modelStarted.resolve()
return releaseModel.promise.then(() => new Response(null, { status: 204 }))
}
serverModel = "test/second-model/medium"
return new Response(null, { status: 204 })
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const model = fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
await modelStarted.promise
const effort = fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "medium",
})
releaseModel.resolve()
await model
const result = await effort
expect(serverModel).toBe("test/second-model/medium")
expect(currentValue(result, "model")).toBe("test/second-model")
expect(currentValue(result, "effort")).toBe("medium")
expect(
fixture.requests
.filter((request) => request.path === "/api/session/ses_ordered_model/model")
.map((request) => request.body),
).toEqual([
{ model: { providerID: "test", id: "second-model" } },
{ model: { providerID: "test", id: "second-model", variant: "medium" } },
])
})
test("orders overlapping config-mode and setSessionMode switches for one session", async () => {
const configModeStarted = Promise.withResolvers<void>()
const releaseConfigMode = Promise.withResolvers<void>()
let serverMode = "build"
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_ordered_mode") })
}
if (request.method === "POST" && request.path === "/api/session/ses_ordered_mode/model") {
return new Response(null, { status: 204 })
}
if (request.method !== "POST" || request.path !== "/api/session/ses_ordered_mode/agent") return undefined
if (serverMode !== "build") {
serverMode = "build"
return new Response(null, { status: 204 })
}
serverMode = "plan"
configModeStarted.resolve()
return releaseConfigMode.promise.then(() => new Response(null, { status: 204 }))
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const configMode = fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "plan",
})
await configModeStarted.promise
const mode = fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
releaseConfigMode.resolve()
await Promise.all([configMode, mode])
const result = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/test-model",
})
expect(serverMode).toBe("build")
expect(currentValue(result, "mode")).toBe("build")
expect(
fixture.requests
.filter((request) => request.path === "/api/session/ses_ordered_mode/agent")
.map((request) => request.body),
).toEqual([{ agent: "plan" }, { agent: "build" }])
})
test("converts MCP configs and deduplicates registrations per session and config", async () => {
const local: McpServer = {
name: "tools",
+17 -7
View File
@@ -30,6 +30,8 @@ type FixtureHandler = (
type FixtureOptions = {
readonly fetch?: FixtureHandler
readonly clientFetch?: (...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>
readonly sessionUpdate?: AgentSideConnection["sessionUpdate"]
readonly models?: readonly ModelInfo[]
readonly defaultModel?: ModelInfo
readonly agents?: readonly AgentInfo[]
@@ -127,13 +129,15 @@ export function makeACPFixture(options: FixtureOptions = {}) {
const requests: FixtureRequest[] = []
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const encoder = new TextEncoder()
let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
const eventControllers = new Set<ReadableStreamDefaultController<Uint8Array>>()
const models = options.models ?? [testModel, secondModel]
const context: FixtureContext = {
requests,
send(event) {
if (!eventController) throw new Error("ACP fixture has no active event stream")
eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
if (eventControllers.size === 0) throw new Error("ACP fixture has no active event stream")
eventControllers.forEach((controller) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
})
},
}
const server = Bun.serve({
@@ -158,11 +162,11 @@ export function makeACPFixture(options: FixtureOptions = {}) {
new ReadableStream<Uint8Array>({
start(value) {
controller = value
eventController = value
eventControllers.add(value)
context.send({ id: "evt_connected", type: "server.connected", data: {} })
},
cancel() {
if (eventController === controller) eventController = undefined
if (controller) eventControllers.delete(controller)
},
}),
{ headers: { "content-type": "text/event-stream" } },
@@ -185,10 +189,14 @@ export function makeACPFixture(options: FixtureOptions = {}) {
},
})
const service = ACPService.make({
client: OpenCode.make({ baseUrl: server.url.toString() }),
client: OpenCode.make({
baseUrl: server.url.toString(),
...(options.clientFetch ? { fetch: Object.assign(options.clientFetch, { preconnect: fetch.preconnect }) } : {}),
}),
connection: {
sessionUpdate: async (update) => {
updates.push(update)
await options.sessionUpdate?.(update)
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
@@ -198,8 +206,10 @@ export function makeACPFixture(options: FixtureOptions = {}) {
service,
requests,
updates,
send: (event: unknown) => context.send(event),
async [Symbol.asyncDispose]() {
eventController?.close()
eventControllers.forEach((controller) => controller.close())
eventControllers.clear()
await server.stop(true)
},
}
+836 -5
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
import { withTimeout } from "./sse-fixture"
describe("acp service lifecycle", () => {
test("does not persist the first catalog variant when no explicit default exists", async () => {
@@ -172,6 +173,833 @@ describe("acp service lifecycle", () => {
})
})
test("loads authoritative config after an overlapping switch completes", async () => {
const switchStarted = Promise.withResolvers<void>()
const releaseSwitch = Promise.withResolvers<void>()
const nativeFetch = fetch
let switching = true
let serverModel = makeSession("server").model
await using fixture = makeACPFixture({
clientFetch(input, init) {
const url = new URL(input instanceof Request ? input.url : input.toString())
if (init?.method !== "GET" || url.pathname !== "/api/session/ses_load_during_switch") {
return nativeFetch(input, init)
}
return Promise.resolve(Response.json({ data: makeSession("ses_load_during_switch", { model: serverModel }) }))
},
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_load_during_switch", { model: serverModel }) })
}
if (request.method === "POST" && request.path === "/api/session/ses_load_during_switch/model") {
if (!switching) return new Response(null, { status: 204 })
switching = false
switchStarted.resolve()
return releaseSwitch.promise.then(() => {
serverModel = { providerID: "test", id: secondModel.id }
return new Response(null, { status: 204 })
})
}
if (request.method === "GET" && request.path === "/api/session/ses_load_during_switch/message") {
return Response.json({ data: [], cursor: {} })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const switched = fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
await switchStarted.promise
const loaded = fixture.service.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
releaseSwitch.resolve()
await switched
const result = await loaded
const effort = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "medium",
})
expect(currentValue(result, "model")).toBe("test/second-model")
expect(currentValue(effort, "effort")).toBe("medium")
})
test("detaches and resumes after an overlapping switch completes", async () => {
const switchStarted = Promise.withResolvers<void>()
const releaseSwitch = Promise.withResolvers<void>()
const nativeFetch = fetch
let switching = true
let serverModel = makeSession("server").model
await using fixture = makeACPFixture({
clientFetch(input, init) {
const url = new URL(input instanceof Request ? input.url : input.toString())
if (init?.method !== "GET" || url.pathname !== "/api/session/ses_resume_during_switch") {
return nativeFetch(input, init)
}
return Promise.resolve(Response.json({ data: makeSession("ses_resume_during_switch", { model: serverModel }) }))
},
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_resume_during_switch", { model: serverModel }) })
}
if (request.method === "POST" && request.path === "/api/session/ses_resume_during_switch/model") {
if (!switching) return new Response(null, { status: 204 })
switching = false
switchStarted.resolve()
return releaseSwitch.promise.then(() => {
serverModel = { providerID: "test", id: secondModel.id }
return new Response(null, { status: 204 })
})
}
if (request.method === "POST" && request.path === "/api/session/ses_resume_during_switch/interrupt") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const switched = fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
await switchStarted.promise
const closed = fixture.service.closeSession({ sessionId: session.sessionId })
const resumed = fixture.service.resumeSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
releaseSwitch.resolve()
await Promise.all([switched, closed])
const result = await resumed
const effort = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "medium",
})
expect(currentValue(result, "model")).toBe("test/second-model")
expect(currentValue(effort, "effort")).toBe("medium")
})
test("forks from authoritative parent config after an overlapping switch completes", async () => {
const switchStarted = Promise.withResolvers<void>()
const releaseSwitch = Promise.withResolvers<void>()
const nativeFetch = fetch
let serverModel = makeSession("server").model
await using fixture = makeACPFixture({
clientFetch(input, init) {
const url = new URL(input instanceof Request ? input.url : input.toString())
if (init?.method !== "POST" || url.pathname !== "/api/session/ses_fork_parent/fork") {
return nativeFetch(input, init)
}
return Promise.resolve(
Response.json({ data: makeSession("ses_fork_child", { model: serverModel, agent: "build" }) }),
)
},
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_fork_parent", { model: serverModel }) })
}
if (request.method === "POST" && request.path === "/api/session/ses_fork_parent/model") {
switchStarted.resolve()
return releaseSwitch.promise.then(() => {
serverModel = { providerID: "test", id: secondModel.id }
return new Response(null, { status: 204 })
})
}
if (request.method === "GET" && request.path === "/api/session/ses_fork_child/message") {
return Response.json({ data: [], cursor: {} })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const switched = fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
})
await switchStarted.promise
const forked = fixture.service.forkSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
releaseSwitch.resolve()
await switched
const result = await forked
expect(result.sessionId).toBe("ses_fork_child")
expect(currentValue(result, "model")).toBe("test/second-model")
})
test("keeps the prior attachment when staged MCP, command, or replay setup fails", async () => {
let phase: "mcp" | "commands" | "replay" | "success" = "mcp"
await using fixture = makeACPFixture({
sessionUpdate: async () => {
if (phase === "commands") throw new Error("command publication failed")
},
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_attach_transaction") })
}
if (request.method === "GET" && request.path === "/api/session/ses_attach_transaction") {
return Response.json({
data: makeSession("ses_attach_transaction", {
model: { providerID: secondModel.providerID, id: secondModel.id },
}),
})
}
if (request.method === "POST" && request.path === "/api/session/ses_attach_transaction/model") {
return new Response(null, { status: 204 })
}
if (request.method === "GET" && request.path === "/api/session/ses_attach_transaction/message") {
if (phase === "replay") return new Response(null, { status: 500 })
return Response.json({ data: [], cursor: {} })
}
if (request.method === "PUT" && request.path === "/api/mcp/docs" && phase === "mcp") {
return new Response(null, { status: 500 })
}
if (request.method === "PUT" && request.path.startsWith("/api/mcp/")) {
return new Response(null, { status: 204 })
}
if (request.method === "DELETE" && request.path.startsWith("/api/mcp/")) {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const tools = { name: "tools", command: "bun", args: ["tools.ts"], env: [] }
const docs = { name: "docs", command: "bun", args: ["docs.ts"], env: [] }
const mcpFailure = await fixture.service
.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [tools, docs] })
.catch((error: unknown) => error)
const afterMcpFailure = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
})
phase = "commands"
const commandFailure = await fixture.service
.resumeSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [tools] })
.catch((error: unknown) => error)
const afterCommandFailure = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "default",
})
phase = "replay"
const replayFailure = await fixture.service
.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [tools] })
.catch((error: unknown) => error)
const afterReplayFailure = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
})
phase = "success"
const attached = await fixture.service.resumeSession({
cwd: "/workspace",
sessionId: session.sessionId,
mcpServers: [tools],
})
expect([mcpFailure, commandFailure, replayFailure]).toEqual([
expect.any(Error),
expect.any(Error),
expect.any(Error),
])
expect(currentValue(afterMcpFailure, "model")).toBe("test/test-model")
expect(currentValue(afterCommandFailure, "model")).toBe("test/test-model")
expect(currentValue(afterReplayFailure, "model")).toBe("test/test-model")
expect(currentValue(attached, "model")).toBe("test/second-model")
expect(
fixture.requests
.filter((request) => request.path.startsWith("/api/mcp/"))
.map((request) => `${request.method} ${request.path}`),
).toEqual([
"PUT /api/mcp/tools",
"PUT /api/mcp/docs",
"DELETE /api/mcp/tools",
"DELETE /api/mcp/docs",
"PUT /api/mcp/tools",
"DELETE /api/mcp/tools",
"PUT /api/mcp/tools",
])
})
test("serializes MCP attachment transactions for sessions in the same location", async () => {
const firstStaged = Promise.withResolvers<void>()
const releaseFirst = Promise.withResolvers<void>()
let racing = false
let created = 0
let installed: unknown
await using fixture = makeACPFixture({
sessionUpdate: async (update) => {
if (!racing || update.sessionId !== "ses_location_1") return
firstStaged.resolve()
await releaseFirst.promise
throw new Error("first publication failed")
},
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
created++
return Response.json({ data: makeSession(`ses_location_${created}`) })
}
if (request.method === "GET" && request.path.startsWith("/api/session/ses_location_")) {
return Response.json({ data: makeSession(request.path.split("/").at(-1) ?? "missing") })
}
if (request.method === "PUT" && request.path === "/api/mcp/shared") {
installed = request.body
return new Response(null, { status: 204 })
}
if (request.method === "DELETE" && request.path === "/api/mcp/shared") {
installed = undefined
return new Response(null, { status: 204 })
}
return undefined
},
})
const first = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const second = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
racing = true
const failed = fixture.service
.resumeSession({
cwd: "/workspace",
sessionId: first.sessionId,
mcpServers: [{ name: "shared", command: "bun", args: ["first.ts"], env: [] }],
})
.catch((error: unknown) => error)
await firstStaged.promise
const succeeded = fixture.service.resumeSession({
cwd: "/workspace",
sessionId: second.sessionId,
mcpServers: [{ name: "shared", command: "bun", args: ["second.ts"], env: [] }],
})
releaseFirst.resolve()
expect(await failed).toBeInstanceOf(Error)
await succeeded
expect(installed).toEqual({
config: { type: "local", command: ["bun", "second.ts"], environment: {} },
})
expect(
fixture.requests
.filter((request) => request.path === "/api/mcp/shared")
.map((request) => ({ method: request.method, body: request.body })),
).toEqual([
{
method: "PUT",
body: { config: { type: "local", command: ["bun", "first.ts"], environment: {} } },
},
{ method: "DELETE", body: undefined },
{
method: "PUT",
body: { config: { type: "local", command: ["bun", "second.ts"], environment: {} } },
},
])
})
test("invalidates a location when MCP rollback fails", async () => {
let failing = false
let created = 0
await using fixture = makeACPFixture({
sessionUpdate: async (update) => {
if (failing && update.sessionId === "ses_rollback_1") throw new Error("command publication failed")
},
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
created++
return Response.json({ data: makeSession(`ses_rollback_${created}`) })
}
if (request.method === "GET" && request.path.startsWith("/api/session/ses_rollback_")) {
return Response.json({ data: makeSession(request.path.split("/").at(-1) ?? "missing") })
}
if (request.method === "PUT" && request.path === "/api/mcp/tools") {
return new Response(null, { status: 204 })
}
if (request.method === "DELETE" && request.path === "/api/mcp/tools") {
return new Response(null, { status: failing ? 500 : 204 })
}
if (request.method === "POST" && request.path.endsWith("/model")) {
return new Response(null, { status: 204 })
}
return undefined
},
})
const first = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const second = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
failing = true
const failure = await fixture.service
.resumeSession({
cwd: "/workspace",
sessionId: first.sessionId,
mcpServers: [{ name: "tools", command: "bun", args: ["tools.ts"], env: [] }],
})
.catch((error: unknown) => error)
const missing = await Promise.all(
[first, second].map((session) =>
fixture.service
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
.catch((error: unknown) => error),
),
)
expect(failure).toBeInstanceOf(AggregateError)
expect(failure).toMatchObject({ message: "ACP attachment failed and MCP rollback did not complete" })
expect(missing).toEqual([
expect.objectContaining({ _tag: "ACPSessionNotFoundError" }),
expect.objectContaining({ _tag: "ACPSessionNotFoundError" }),
])
failing = false
const recovered = await fixture.service.resumeSession({
cwd: "/workspace",
sessionId: first.sessionId,
mcpServers: [],
})
expect(currentValue(recovered, "model")).toBe("test/test-model")
expect(
fixture.requests.filter((request) => request.path === "/api/mcp/tools").map((request) => request.method),
).toEqual(["PUT", "DELETE", "DELETE"])
})
test("allows MCP attachment transactions in distinct locations to proceed concurrently", async () => {
const firstStaged = Promise.withResolvers<void>()
const releaseFirst = Promise.withResolvers<void>()
let racing = false
let created = 0
await using fixture = makeACPFixture({
sessionUpdate: async (update) => {
if (!racing || update.sessionId !== "ses_distinct_1") return
firstStaged.resolve()
await releaseFirst.promise
},
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
created++
const cwd = created === 1 ? "/first" : "/second"
return Response.json({ data: makeSession(`ses_distinct_${created}`, { cwd }) })
}
if (request.method === "GET" && request.path.startsWith("/api/session/ses_distinct_")) {
const id = request.path.split("/").at(-1) ?? "missing"
return Response.json({ data: makeSession(id, { cwd: id.endsWith("1") ? "/first" : "/second" }) })
}
if (request.method === "PUT" && request.path === "/api/mcp/shared") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const first = await fixture.service.newSession({ cwd: "/first", mcpServers: [] })
const second = await fixture.service.newSession({ cwd: "/second", mcpServers: [] })
racing = true
const blocked = fixture.service.resumeSession({
cwd: "/first",
sessionId: first.sessionId,
mcpServers: [{ name: "shared", command: "bun", args: ["first.ts"], env: [] }],
})
await firstStaged.promise
const concurrent = fixture.service.resumeSession({
cwd: "/second",
sessionId: second.sessionId,
mcpServers: [{ name: "shared", command: "bun", args: ["second.ts"], env: [] }],
})
await withTimeout(concurrent, "distinct location transaction was blocked")
releaseFirst.resolve()
await blocked
})
test("allows config switches for distinct sessions to proceed concurrently", async () => {
const firstStarted = Promise.withResolvers<void>()
const releaseFirst = Promise.withResolvers<void>()
let created = 0
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
created++
return Response.json({ data: makeSession(`ses_concurrent_${created}`) })
}
if (request.method === "POST" && request.path === "/api/session/ses_concurrent_1/model") {
firstStarted.resolve()
return releaseFirst.promise.then(() => new Response(null, { status: 204 }))
}
if (request.method === "POST" && request.path === "/api/session/ses_concurrent_2/model") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const first = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const second = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const blocked = fixture.service.setSessionConfigOption({
sessionId: first.sessionId,
configId: "effort",
value: "high",
})
await firstStarted.promise
const concurrent = await fixture.service.setSessionConfigOption({
sessionId: second.sessionId,
configId: "effort",
value: "high",
})
releaseFirst.resolve()
await blocked
expect(currentValue(concurrent, "effort")).toBe("high")
})
test("load replacement cancels prompt ownership acquired before streaming", async () => {
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_prompt_load") })
}
if (request.method === "GET" && request.path === "/api/session/ses_prompt_load") {
return Response.json({ data: makeSession("ses_prompt_load") })
}
if (request.method === "GET" && request.path === "/api/session/ses_prompt_load/message") {
return Response.json({ data: [], cursor: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_prompt_load/prompt") {
const id = requestField(request.body, "id")
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_prompt_load", inboxID: id },
})
if (requestField(request.body, "text") === "second") {
context.send({
id: "evt_second_complete",
type: "session.execution.succeeded",
data: { sessionID: "ses_prompt_load" },
})
}
return Response.json({ data: {} })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const prompt = fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "first" }],
})
const stoppedPrompt = prompt.then(
() => "resolved",
() => "rejected",
)
const loaded = fixture.service.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
await loaded
const stopped = await withTimeout(stoppedPrompt, "replaced prompt did not stop")
const second = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "second" }],
})
expect(stopped).toBe("rejected")
expect(second.stopReason).toBe("end_turn")
})
test("replacement permits a new prompt while the retired request does not settle", async () => {
const firstStarted = Promise.withResolvers<void>()
const firstAborted = Promise.withResolvers<void>()
const nativeFetch = fetch
let prompts = 0
await using fixture = makeACPFixture({
clientFetch(input, init) {
const url = new URL(input instanceof Request ? input.url : input.toString())
if (init?.method !== "POST" || url.pathname !== "/api/session/ses_prompt_handoff/prompt") {
return nativeFetch(input, init)
}
prompts++
if (prompts > 1) return nativeFetch(input, init)
firstStarted.resolve()
init.signal?.addEventListener("abort", () => firstAborted.resolve(), { once: true })
return new Promise<Response>(() => {})
},
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_prompt_handoff") })
}
if (request.method === "GET" && request.path === "/api/session/ses_prompt_handoff") {
return Response.json({ data: makeSession("ses_prompt_handoff") })
}
if (request.method === "GET" && request.path === "/api/session/ses_prompt_handoff/message") {
return Response.json({ data: [], cursor: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_prompt_handoff/prompt") {
const id = requestField(request.body, "id")
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_prompt_handoff", inboxID: id },
})
context.send({
id: "evt_handoff_complete",
type: "session.execution.succeeded",
data: { sessionID: "ses_prompt_handoff" },
})
return Response.json({ data: {} })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
void fixture.service
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "never settles" }] })
.catch(() => undefined)
await firstStarted.promise
await fixture.service.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
await firstAborted.promise
const current = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "new owner" }],
})
expect(current.stopReason).toBe("end_turn")
expect(prompts).toBe(2)
})
test("cancel permits a new prompt while the cancelled request does not settle", async () => {
const firstStarted = Promise.withResolvers<void>()
const firstAborted = Promise.withResolvers<void>()
const nativeFetch = fetch
let prompts = 0
await using fixture = makeACPFixture({
clientFetch(input, init) {
const url = new URL(input instanceof Request ? input.url : input.toString())
if (init?.method !== "POST" || url.pathname !== "/api/session/ses_prompt_cancel_handoff/prompt") {
return nativeFetch(input, init)
}
prompts++
if (prompts > 1) return nativeFetch(input, init)
firstStarted.resolve()
init.signal?.addEventListener("abort", () => firstAborted.resolve(), { once: true })
return new Promise<Response>(() => {})
},
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_prompt_cancel_handoff") })
}
if (request.method === "POST" && request.path === "/api/session/ses_prompt_cancel_handoff/interrupt") {
return Response.json({ interrupted: true })
}
if (request.method === "POST" && request.path === "/api/session/ses_prompt_cancel_handoff/prompt") {
const id = requestField(request.body, "id")
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_prompt_cancel_handoff", inboxID: id },
})
context.send({
id: "evt_cancel_handoff_complete",
type: "session.execution.succeeded",
data: { sessionID: "ses_prompt_cancel_handoff" },
})
return Response.json({ data: {} })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
void fixture.service
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "never settles" }] })
.catch(() => undefined)
await firstStarted.promise
await fixture.service.cancel({ sessionId: session.sessionId })
await firstAborted.promise
const current = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "new owner" }],
})
expect(current.stopReason).toBe("end_turn")
expect(prompts).toBe(2)
})
test("failed authoritative interruption retains sole prompt ownership", async () => {
const admitted = Promise.withResolvers<void>()
const afterFailure = Promise.withResolvers<void>()
let interruptFails = true
let prompts = 0
await using fixture = makeACPFixture({
sessionUpdate: async (update) => {
if (
update.update.sessionUpdate === "agent_message_chunk" &&
update.update.content.type === "text" &&
update.update.content.text === "before cancel"
) {
admitted.resolve()
}
if (
update.update.sessionUpdate === "agent_message_chunk" &&
update.update.content.type === "text" &&
update.update.content.text === "after failure"
) {
afterFailure.resolve()
}
},
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_interrupt_owner") })
}
if (request.method === "POST" && request.path === "/api/session/ses_interrupt_owner/prompt") {
prompts++
const id = requestField(request.body, "id")
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_interrupt_owner", inboxID: id },
})
context.send({
id: `evt_text_${prompts}`,
type: "session.text.delta",
data: {
sessionID: "ses_interrupt_owner",
assistantMessageID: `msg_assistant_${prompts}`,
ordinal: 0,
delta: prompts === 1 ? "before cancel" : "replacement",
},
})
if (prompts > 1) {
context.send({
id: "evt_replacement_complete",
type: "session.execution.succeeded",
data: { sessionID: "ses_interrupt_owner" },
})
}
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_interrupt_owner/interrupt") {
if (interruptFails) return new Response(null, { status: 500 })
context.send({
id: "evt_owner_interrupted",
type: "session.execution.interrupted",
data: { sessionID: "ses_interrupt_owner", reason: "user" },
})
return Response.json({ interrupted: true })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const first = fixture.service
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "first" }] })
.catch((error: unknown) => error)
await admitted.promise
const interruptionFailure = await fixture.service.cancel({ sessionId: session.sessionId }).catch((error) => error)
const replacementFailure = await fixture.service
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "must wait" }] })
.catch((error: unknown) => error)
fixture.updates.length = 0
fixture.send({
id: "evt_after_failed_interrupt",
type: "session.text.delta",
data: {
sessionID: "ses_interrupt_owner",
assistantMessageID: "msg_assistant_1",
ordinal: 1,
delta: "after failure",
},
})
await afterFailure.promise
const activeStream = fixture.requests.filter((request) => request.path === "/api/event").length
expect(interruptionFailure).toBeInstanceOf(Error)
expect(replacementFailure).toMatchObject({ _tag: "ACPServiceFailureError" })
expect(activeStream).toBe(1)
expect(
fixture.updates.flatMap((update) =>
update.update.sessionUpdate === "agent_message_chunk" && update.update.content.type === "text"
? [update.update.content.text]
: [],
),
).toEqual(["after failure"])
interruptFails = false
await fixture.service.cancel({ sessionId: session.sessionId })
await first
const replacement = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "replacement" }],
})
expect(replacement.stopReason).toBe("end_turn")
expect(prompts).toBe(2)
expect(
fixture.updates.flatMap((update) =>
update.update.sessionUpdate === "agent_message_chunk" && update.update.content.type === "text"
? [update.update.content.text]
: [],
),
).toEqual(["after failure", "replacement"])
})
test("delete detaches and cancels an active foreground prompt", async () => {
const promptStarted = Promise.withResolvers<void>()
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_prompt_delete") })
}
if (request.method === "POST" && request.path === "/api/session/ses_prompt_delete/prompt") {
const id = requestField(request.body, "id")
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_prompt_delete", inboxID: id },
})
promptStarted.resolve()
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_prompt_delete/model") {
return new Response(null, { status: 204 })
}
if (request.method === "DELETE" && request.path === "/api/session/ses_prompt_delete") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
const prompt = fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "running" }],
})
const stoppedPrompt = prompt.then(
() => "resolved",
() => "rejected",
)
await promptStarted.promise
const configured = await fixture.service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
})
await fixture.service.deleteSession({ sessionId: session.sessionId })
const stopped = await withTimeout(stoppedPrompt, "deleted session prompt did not stop")
expect(currentValue(configured, "effort")).toBe("high")
expect(stopped).toBe("rejected")
})
test("lists server-backed pages and forwards cwd and cursor", async () => {
const firstPage = Array.from({ length: 100 }, (_, index) =>
makeSession(`ses_${100 - index}`, {
@@ -251,11 +1079,7 @@ describe("acp service lifecycle", () => {
expect(await fixture.service.closeSession({ sessionId: "missing" })).toEqual({})
expect(
fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
).toEqual([
"/api/session/ses_lifecycle/interrupt",
"/api/session/ses_lifecycle/interrupt",
"/api/session/missing/interrupt",
])
).toEqual(["/api/session/ses_lifecycle/interrupt", "/api/session/missing/interrupt"])
})
test("deletes sessions from backing and local storage", async () => {
@@ -289,3 +1113,10 @@ describe("acp service lifecycle", () => {
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
return result.configOptions?.find((option) => option.id === id)?.currentValue
}
function requestField(value: unknown, key: string) {
if (!value || typeof value !== "object") throw new Error(`Missing request ${key}`)
const field = Reflect.get(value, key)
if (typeof field !== "string") throw new Error(`Missing request ${key}`)
return field
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
import { withTimeout } from "./sse-fixture"
describe("acp service prompt routing and usage", () => {
test("routes slash commands, skills, and compact through their session endpoints", async () => {
@@ -69,6 +70,87 @@ describe("acp service prompt routing and usage", () => {
expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
})
test("forwards admission signals through every prompt route", async () => {
const nativeFetch = fetch
const signalled: string[] = []
await using fixture = makeACPFixture({
clientFetch(input, init) {
const url = new URL(input instanceof Request ? input.url : input.toString())
if (
["synthetic", "prompt", "skill", "compact", "command"].some((suffix) => url.pathname.endsWith(`/${suffix}`))
) {
if (init?.signal instanceof AbortSignal) signalled.push(url.pathname.split("/").at(-1) ?? "")
}
return nativeFetch(input, init)
},
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({ data: makeSession("ses_signals") })
}
if (request.method === "POST" && request.path === "/api/session/ses_signals/synthetic") {
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_signals/prompt") {
const id = requestID(request)
completeTurn(context, "ses_signals", {
id: id.replace(/^msg_/, "evt_"),
type: "session.inbox.delivered",
data: { sessionID: "ses_signals", inboxID: id },
})
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_signals/skill") {
const id = requestID(request)
completeTurn(context, "ses_signals", {
id: id.replace(/^msg_/, "evt_"),
type: "session.skill.activated",
data: { sessionID: "ses_signals", skill: "verify" },
})
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_signals/compact") {
const id = requestID(request)
completeTurn(context, "ses_signals", {
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_signals", inboxID: id },
})
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_signals/command") {
return new Response(null, { status: 204 })
}
return undefined
},
})
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
await withTimeout(
fixture.service.prompt({
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "context", annotations: { audience: ["assistant"] } },
{ type: "text", text: "hello" },
],
}),
"synthetic prompt did not complete",
)
await withTimeout(
fixture.service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/verify" }] }),
"skill prompt did not complete",
)
await withTimeout(
fixture.service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }),
"compact prompt did not complete",
)
await withTimeout(
fixture.service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/review" }] }),
"command prompt did not complete",
)
expect(signalled).toEqual(["synthetic", "prompt", "skill", "compact", "command"])
})
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
const assistantTokens = {
input: 100,
+1
View File
@@ -166,6 +166,7 @@ export function createSseFixture(options: FixtureOptions = {}) {
messages,
requests,
send,
streamCount: () => streams.size,
async stop() {
for (const stream of streams) {
try {
+124
View File
@@ -0,0 +1,124 @@
import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { expect, test } from "bun:test"
import { Effect, Exit, FileSystem } from "effect"
import { Command } from "effect/unstable/cli"
import path from "node:path"
import { Commands } from "../src/commands/commands"
import { ServiceConfig } from "../src/services/service-config"
import { it } from "../../core/test/lib/effect"
it.live("service CORS config persists multiple origins and preserves other settings on set and unset", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-" })
const config = path.join(root, "config")
const state = path.join(root, "state")
const file = path.join(config, ServiceConfig.filename())
const existing = { hostname: "127.0.0.1", port: 4321, password: "test-secret", env: { TEST: "value" } }
yield* fs.makeDirectory(config)
yield* fs.makeDirectory(state)
yield* fs.writeFileString(file, JSON.stringify(existing))
yield* Effect.gen(function* () {
expect(yield* ServiceConfig.get("cors")).toBe("[]")
yield* ServiceConfig.set("cors", " http://192.0.2.10:3001, https://app.example.com ")
const cors = ["http://192.0.2.10:3001", "https://app.example.com"]
expect(yield* ServiceConfig.read()).toEqual({ ...existing, cors })
expect(yield* ServiceConfig.get("cors")).toBe(JSON.stringify(cors, null, 2))
expect(JSON.parse(yield* ServiceConfig.get())).toEqual({
hostname: existing.hostname,
port: existing.port,
env: existing.env,
cors,
})
expect(JSON.parse(yield* fs.readFileString(file))).toEqual({ ...existing, cors })
yield* ServiceConfig.set("cors", "https://replacement.example.com")
expect((yield* ServiceConfig.read()).cors).toEqual(["https://replacement.example.com"])
yield* ServiceConfig.unset("cors")
expect(yield* ServiceConfig.get("cors")).toBe("[]")
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
}).pipe(Effect.provide(NodeServices.layer)),
)
it.live("service CORS config rejects empty lists, invalid origins, and extra arguments without changing config", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-invalid-" })
const config = path.join(root, "config")
const state = path.join(root, "state")
const file = path.join(config, ServiceConfig.filename())
const existing = { port: 4321, cors: ["https://app.example.com"] }
yield* fs.makeDirectory(config)
yield* fs.makeDirectory(state)
yield* fs.writeFileString(file, JSON.stringify(existing))
yield* Effect.gen(function* () {
yield* Effect.forEach(
[
"",
" ",
",",
"https://app.example.com,",
",https://app.example.com",
"https://app.example.com,,https://other.example.com",
"not-a-url",
"*",
"null",
"ftp://app.example.com",
"https://app.example.com/",
"https://app.example.com/path",
"https://app.example.com?query=1",
"https://app.example.com#fragment",
"https://user:password@app.example.com",
],
(value) =>
Effect.gen(function* () {
expect(Exit.isFailure(yield* ServiceConfig.set("cors", value).pipe(Effect.exit))).toBe(true)
expect(yield* ServiceConfig.read()).toEqual(existing)
}),
)
yield* Effect.forEach(
[
ServiceConfig.get("cors", "extra"),
ServiceConfig.set("cors", "https://app.example.com", "extra"),
ServiceConfig.unset("cors", "extra"),
],
(operation) =>
Effect.gen(function* () {
expect(Exit.isFailure(yield* operation.pipe(Effect.exit))).toBe(true)
}),
)
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
}).pipe(Effect.provide(NodeServices.layer)),
)
test.each([
{ args: [], cors: [] },
{ args: ["--cors", "https://app.example.com"], cors: ["https://app.example.com"] },
{
args: ["--service", "--cors", "http://192.0.2.10:3001", "--cors", "https://app.example.com"],
cors: ["http://192.0.2.10:3001", "https://app.example.com"],
},
])("serve parses CORS flags: $args", async ({ args, cors }) => {
const received: (readonly string[])[] = []
const command = Commands.commands.serve.spec.pipe(
Command.withHandler((input) => Effect.sync(() => void received.push(input.cors))),
)
await Effect.runPromise(Command.runWith(command, { version: "test" })(args).pipe(Effect.provide(NodeServices.layer)))
expect(received).toEqual([cors])
})
test.each([{ args: ["--cors"] }, { args: ["--cors", ""] }])(
"serve rejects a missing or empty CORS flag value: $args",
async ({ args }) => {
const command = Commands.commands.serve.spec.pipe(Command.withHandler(() => Effect.void))
const result = await Effect.runPromise(
Command.runWith(command, { version: "test", renderErrors: false })(args).pipe(
Effect.exit,
Effect.provide(NodeServices.layer),
),
)
expect(Exit.isFailure(result)).toBe(true)
},
)
+43
View File
@@ -9,6 +9,7 @@ import os from "node:os"
import path from "node:path"
import { ServiceConfig } from "../src/services/service-config"
import { ServiceRegistration } from "../src/services/service-registration"
import { isolatedEnv } from "./fixture/environment"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
@@ -313,6 +314,48 @@ test("configured managed service port overrides the channel default", async () =
}
}, 30_000)
test.each([
{ args: [], origins: ["http://192.0.2.10:3001", "https://configured.example.com"] },
{
args: ["--cors", "http://192.0.2.20:3001", "--cors", "https://override.example.com"],
origins: ["http://192.0.2.20:3001", "https://override.example.com"],
},
])(
"managed service applies CORS configuration with flag overrides: $args",
async ({ args, origins }) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-cors-"))
const config = path.join(root, "config", ServiceConfig.filename())
const registration = path.join(root, "state", "opencode", ServiceConfig.filename())
const cors = ["http://192.0.2.10:3001", "https://configured.example.com"]
await fs.mkdir(path.dirname(config), { recursive: true })
await fs.writeFile(config, JSON.stringify({ cors }))
const owner = Bun.spawn(
[process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service", "--port", "0", ...args],
{ env: isolatedEnv(root), stderr: "pipe", stdout: "ignore" },
)
try {
const info = await waitForInfo(registration)
await Promise.all(
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
const response = await fetch(new URL("/api/health", info.url), {
method: "OPTIONS",
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
})
expect(response.headers.get("access-control-allow-origin")).toBe(
origins.some((value) => value === origin) ? origin : null,
)
}),
)
expect((await Bun.file(config).json()).cors).toEqual(cors)
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
},
30_000,
)
test("unrelated managed port occupancy reports an actionable conflict", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
+8
View File
@@ -1950,6 +1950,12 @@ export type VcsGetInput = {
export type VcsGetOutput = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
export type VcsBaseInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type VcsBaseOutput = { readonly location: Location.Info; readonly data: Vcs.Base | null }
export type VcsBaseOperation<E = never> = (input?: VcsBaseInput) => Effect.Effect<VcsBaseOutput, E>
export type VcsStatusInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1967,6 +1973,7 @@ export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effe
export type VcsDiffInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly base?: string | undefined
readonly context?: number | undefined
}
export type VcsDiffOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
@@ -1974,6 +1981,7 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
readonly branches: VcsBranchesOperation<E>
readonly diff: VcsDiffOperation<E>
+11 -3
View File
@@ -244,6 +244,8 @@ import type {
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
@@ -1468,6 +1470,11 @@ const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsBase = (raw: RawClient["server.vcs"]) => (input?: VcsBaseInput) =>
preserveEffect<VcsBaseOutput>()(
raw["vcs.base"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusInput) =>
preserveEffect<VcsStatusOutput>()(
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1482,13 +1489,14 @@ const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranch
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
preserveEffect<VcsDiffOutput>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
Effect.mapError(mapClientError),
),
raw["vcs.diff"]({
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
branches: EndpointVcsBranches(raw),
diff: EndpointVcsDiff(raw),
+1
View File
@@ -34,6 +34,7 @@ export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Vcs } from "@opencode-ai/schema/vcs"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
@@ -240,6 +240,8 @@ import type {
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
@@ -1367,7 +1369,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
@@ -1496,7 +1498,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
@@ -1996,6 +1998,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
base: (input?: VcsBaseInput, requestOptions?: RequestOptions) =>
request<VcsBaseOutput>(
{
method: "GET",
path: `/api/vcs/base`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
),
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
request<VcsStatusOutput>(
{
@@ -2025,9 +2039,9 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/vcs/diff`,
query: { location: input["location"], mode: input["mode"], context: input["context"] },
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
successStatus: 200,
declaredStatuses: [401, 400],
declaredStatuses: [503, 401, 400],
empty: false,
},
requestOptions,
+25 -3
View File
@@ -419,6 +419,8 @@ export type WorkspaceDestroyResult = { destroyed: boolean }
export type VcsBranch = { current?: string; default?: string }
export type VcsBase = { name: string; ref: string; source: "reflog" | "default" }
export type VcsFileStatus = {
file: string
additions: number
@@ -6073,6 +6075,17 @@ export type VcsGetOutput = {
data: VcsInfo
}
export type VcsBaseInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsBaseOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: VcsBase | null
}
export type VcsStatusInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -6110,17 +6123,26 @@ export type VcsBranchesOutput = {
export type VcsDiffInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["location"]
readonly mode: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["mode"]
readonly base?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["base"]
readonly context?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly mode: "working" | "branch" | "committed"
readonly base?: string | undefined
readonly context?: number | undefined
}["context"]
}
+2 -1
View File
@@ -750,7 +750,8 @@ export function createData(config: CreateDataInput) {
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata: event.metadata,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
})
})
@@ -6,6 +6,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Vcs } from "@opencode-ai/schema/vcs"
const Client = await import("../src/effect")
@@ -14,6 +15,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Config).toBe(Config)
expect(Client.Model).toBe(Model)
expect(Client.Session).toBe(Session)
expect(Client.Vcs.Base).toBe(Vcs.Base)
})
test("generated Effect API names canonical and composed outputs", async () => {
+20
View File
@@ -27,6 +27,26 @@ test("health.get decodes the readiness response", async () => {
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
})
test("vcs.base decodes nullable review-base metadata", async () => {
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
const base = {
name: "release",
ref: "refs/remotes/origin/release",
source: "reflog",
}
for (const data of [base, null]) {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ location, data }))),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.vcs.base({ location: { directory: AbsolutePath.make("/repo") } })
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.data).toEqual(data)
expect(result.location.directory).toBe("/repo")
}
})
test("session.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
+41 -1
View File
@@ -47,7 +47,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
expect(Object.keys(client.vcs)).toEqual(["get", "base", "status", "branches", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
@@ -84,6 +84,46 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("vcs.base and committed diffs preserve location and explicit base on the wire", async () => {
const requests: Request[] = []
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
const base = { name: "release", ref: "refs/remotes/origin/release", source: "reflog" }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
return Response.json({ location, data: new URL(request.url).pathname.endsWith("/base") ? base : [] })
},
})
expect(await client.vcs.base({ location: { directory: "/repo" } })).toEqual({ location, data: base })
expect(
await client.vcs.diff({ location: { directory: "/repo" }, mode: "committed", base: base.ref, context: 1 }),
).toEqual({ location, data: [] })
expect(new URL(requests[0].url).pathname).toBe("/api/vcs/base")
const query = new URL(requests[1].url).searchParams
expect(query.get("location[directory]")).toBe("/repo")
expect(query.get("mode")).toBe("committed")
expect(query.get("base")).toBe(base.ref)
expect(query.get("context")).toBe("1")
})
test("vcs.diff exposes unavailable comparisons as errors, not empty diffs", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "ServiceUnavailableError", service: "vcs", message: "No review base available" },
{ status: 503 },
),
})
await expect(client.vcs.diff({ mode: "committed" })).rejects.toMatchObject({
_tag: "ServiceUnavailableError",
service: "vcs",
message: "No review base available",
})
})
test("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
+30
View File
@@ -666,6 +666,36 @@ test("ignores activity snapshots from an older connection", async () => {
}
})
test("projects background user shell metadata from durable shell data", () => {
const setup = activityFixture(() => Response.json({ data: {} }))
try {
setup.emit({
id: "evt_user_shell",
created: 1,
type: "session.shell.started",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: {
sessionID: "ses_refresh",
shell: {
id: "sh_user",
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/project/shell.out",
metadata: { sessionID: "ses_refresh", background: true },
time: { started: 1 },
},
},
})
expect(setup.data.session.message.list("ses_refresh")).toMatchObject([
{ type: "shell", shellID: "sh_user", status: "running", metadata: { background: true } },
])
} finally {
setup.dispose()
}
})
function activityFixture(read: () => Response | Promise<Response>) {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
+2 -2
View File
@@ -24,8 +24,8 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unkno
const response = yield* client
.execute(request)
.pipe(
Effect.catch((cause) =>
Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
Effect.mapError((cause) =>
toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause),
),
)
const text = yield* readResponseBody(response, plan)
+2
View File
@@ -1,5 +1,7 @@
/// <reference types="@solidjs/start/env" />
import "@solidjs/start"
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
+12 -4
View File
@@ -482,8 +482,7 @@ function toolMessage(input: LLMRequest["messages"][number]) {
const value = part.result.value.filter((item) => {
if (item.type !== "file") return true
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
return false
})
return toolResultPart({
@@ -507,7 +506,7 @@ function text(part: ContentPart) {
function userPart(part: ContentPart): UserContent {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "media")
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
return []
}
@@ -516,7 +515,7 @@ function assistantPart(part: ContentPart): AssistantContent {
case "text":
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "media":
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
case "reasoning":
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "tool-call":
@@ -535,6 +534,15 @@ function assistantPart(part: ContentPart): AssistantContent {
}
}
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
if (typeof data !== "string") return data
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
if (base64 !== undefined) return base64
if (!URL.canParse(data)) return data
const url = new URL(data)
return url.protocol === "http:" || url.protocol === "https:" ? url : data
}
function toolResultPart(part: ContentPart): ToolResultContent[] {
if (part.type !== "tool-result") return []
return [
+1 -5
View File
@@ -859,16 +859,12 @@ export function configured(options?: Options) {
aggregateID: input.aggregateID,
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
}
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
Stream.map((event): LogItem => event),
Stream.concat(Stream.make(marker)),
)
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(Stream.concat(Stream.make(marker)))
if (!wakes) return replay
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
Stream.filter((target) => target > sequence),
Stream.flatMap((target) => readThrough(target)),
Stream.map((event): LogItem => event),
)
return Stream.concat(replay, live)
}),
+9 -10
View File
@@ -316,16 +316,15 @@ export const layer = (options?: Options) =>
}
})
const reload = Effect.fn("Config.reload")(() =>
reloadLock.withPermit(
Effect.gen(function* () {
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
}),
),
const reload = Effect.fn("Config.reload")(
function* () {
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
},
(effect) => reloadLock.withPermit(effect),
)
yield* Stream.fromPubSub(updates).pipe(
+3 -8
View File
@@ -292,18 +292,13 @@ function normalizeMcpTimeout(
invalid(path, diagnostics)
return
}
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
const recognized = Object.entries(ConfigMCP.Timeout.fields).filter(([key]) => own(value, key))
if (Object.keys(value).length && !recognized.length) {
invalid(path, diagnostics)
return
}
recognized.forEach((key) => {
const leaf = decodeEncoded(
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
value[key],
[...path, key],
diagnostics,
)
recognized.forEach(([key, field]) => {
const leaf = decodeEncoded(field, value[key], [...path, key], diagnostics)
if (leaf === undefined) return
overlay(timeout, key, leaf, [...path, key], diagnostics)
})
+1 -13
View File
@@ -32,19 +32,7 @@ type PathAction =
| typeof ReadTool.name
| typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
const agentKeys = new Set([
"model",
"variant",
"request",
"system",
"description",
"mode",
"hidden",
"color",
"steps",
"disabled",
"permissions",
])
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
export const Plugin = define({
id: "opencode.config.agent",
+19 -20
View File
@@ -83,26 +83,25 @@ export const Plugin = define({
),
)
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
file,
instructions:
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
})
}),
)
})
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(
function* (file?: string) {
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
file,
instructions:
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
})
},
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
+1 -3
View File
@@ -20,14 +20,13 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
draft.add(
name,
local(entry)
? Reference.LocalSource.make({
@@ -48,7 +47,6 @@ export const Plugin = define({
)
}
}
for (const [name, source] of entries) draft.add(name, source)
})
}),
})
+19 -20
View File
@@ -151,26 +151,25 @@ export const Plugin = define({
return skills
})
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
}),
)
})
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(
function* (file?: string) {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
},
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
@@ -0,0 +1,35 @@
# Effect Drizzle SQLite Adapter
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
builders over Effect's generic `SqlClient`. It is maintained source, not
generated output.
## Provenance
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
SQLite Effect query builders, and shared query-builder utilities. The
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
The exact upstream revision originally copied into this repository is unknown.
The currently pinned `drizzle-orm` version is a compatibility dependency, not
copy provenance.
## Local Boundary
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
facade from `database/database.ts`.
Material local adaptations include:
- a runtime-independent driver over Effect's generic `SqlClient`
- local cache, mapping, and runtime-inspection helpers
- suppressed statement tracing beneath the database operation boundary
- explicit SQLite transactions and savepoints
- native transaction delegation for Durable Object SQLite
- deliberate query-builder variance annotations
Preserve these adaptations when comparing or synchronizing upstream code.
Focused regression coverage is in `test/database-drizzle.test.ts` and
`test/sqlite-workerd.test.ts`.
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
*
* @example
* ```ts
* import { SqliteClient } from '@effect/sql-sqlite-node';
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
* import * as Effect from 'effect/Effect';
* import { SqliteClient } from "@effect/sql-sqlite-node"
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
* import { Effect } from "effect"
*
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
* Effect.provide(SQLiteDrizzle.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
* );
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
* )
* ```
*/
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
@@ -227,7 +227,7 @@ export class SQLiteEffectInsertBase<
config: SQLiteInsertConfig<TTable>
constructor(
private table: TTable,
table: TTable,
values: SQLiteInsertConfig["values"],
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
private effectDialect: SQLiteDialect,
@@ -277,11 +277,7 @@ export class SQLiteEffectPreparedQuery<
}
assertUnreachable(cacheStrat)
}).pipe(
Effect.catch((e) => {
return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
}),
)
}).pipe(Effect.mapError((e) => new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) })))
}
getQuery(): Query {
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
: undefined
on = on(
new Proxy(
this.config.table._.columns,
getTableColumnsRuntime(this.config.table),
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
+129 -132
View File
@@ -481,7 +481,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
})
}
export const layer = Layer.effectDiscard(
@@ -521,76 +521,75 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
@@ -605,81 +604,79 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
.run(),
)
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
}).pipe(Effect.orDie),
)
}
@@ -708,7 +705,7 @@ function countNextSessions(sourcePath: string | undefined) {
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
)
}
function importNextDatabase(
+55 -52
View File
@@ -61,21 +61,23 @@ export const makeMemoryDriver = (): MemoryDriver => {
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
stat: (value) =>
Effect.suspend(() => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
}),
read: (value, range) =>
Effect.gen(function* () {
const original = lookup(value)
if (!original) return yield* new NotFound({ path: value })
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return { info: info(node), bytes: bytes.slice() }
}),
write: (value, bytes) =>
Effect.try({
try: () => {
@@ -89,17 +91,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
list: (value) =>
Effect.gen(function* () {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
return [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
}),
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
@@ -107,32 +109,33 @@ export const makeMemoryDriver = (): MemoryDriver => {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
move: (from, to) =>
Effect.gen(function* () {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return yield* new NotFound({ path: from })
yield* Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
}),
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
+28 -33
View File
@@ -177,7 +177,7 @@ const layer = Layer.effect(
if (!dotgit) return undefined
const cwd = path.dirname(dotgit)
const result = yield* run(cwd, proc)(["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
const result = yield* run(cwd, proc, ["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
const [gitDir, commonDir, topLevel] = result.text.split(/\r?\n/)
if (!gitDir || !commonDir) return undefined
@@ -189,13 +189,13 @@ const layer = Layer.effect(
})
const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") {
const result = yield* run(repository.worktree, proc)(["remote", "get-url", name])
const result = yield* run(repository.worktree, proc, ["remote", "get-url", name])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) {
const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"])
const result = yield* run(repository.worktree, proc, ["rev-list", "--max-parents=0", "HEAD"])
if (result.exitCode !== 0) return []
return result.text
.split("\n")
@@ -205,13 +205,13 @@ const layer = Layer.effect(
})
const head = Effect.fn("Git.history.head")(function* (repository: Repository) {
const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"])
const result = yield* run(repository.worktree, proc, ["rev-parse", "HEAD"])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) {
const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
const result = yield* run(repository.worktree, proc, ["symbolic-ref", "--quiet", "--short", "HEAD"])
if (result.exitCode !== 0) return undefined
return result.text.trim() || undefined
})
@@ -220,7 +220,7 @@ const layer = Layer.effect(
repository: Repository,
remoteName = "origin",
) {
const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
const result = yield* run(repository.worktree, proc, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
if (result.exitCode !== 0) return undefined
return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined
})
@@ -230,10 +230,7 @@ const layer = Layer.effect(
directory: AbsolutePath,
args: string[],
) {
const result = yield* execute(
directory,
proc,
)(args).pipe(
const result = yield* execute(directory, proc, args).pipe(
Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })),
)
if (result.exitCode === 0) return
@@ -711,31 +708,29 @@ interface Result {
readonly stderr: string
}
function run(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
execute(cwd, proc)(args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
function run(cwd: string, proc: AppProcess.Interface, args: string[]) {
return execute(cwd, proc, args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
}
function execute(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
proc
.run(
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(
Effect.map(
(result) =>
({
exitCode: result.exitCode,
text: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}) satisfies Result,
),
)
function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
return proc
.run(
ChildProcess.make("git", args, {
cwd,
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(
Effect.map(
(result) =>
({
exitCode: result.exitCode,
text: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}) satisfies Result,
),
)
}
function resolvePath(cwd: string, value: string) {
+36 -3
View File
@@ -1,5 +1,38 @@
This is a temporary package used primarily for GitHub Copilot compatibility.
# GitHub Copilot AI SDK Adapters
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
This directory contains upstream-derived AI SDK implementations adapted for
GitHub Copilot. It is not a generic OpenAI-compatible provider.
Avoid making edits to these files
## Provenance
- `chat/` is derived from the Vercel AI SDK
`@ai-sdk/openai-compatible` chat implementation.
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
implementation.
- The exact upstream revisions originally copied into this repository are
unknown. Current dependency versions and the `VERSION` constant in
`copilot-provider.ts` are not copy provenance.
## Ownership
Keep `chat/` and `responses/` structurally close to their upstream modules, but
preserve the intentional Copilot adaptations: the `copilot` options and metadata
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
Responses requests with encrypted reasoning, rotating response item IDs, and
explicit function-tool strictness taking precedence over the global fallback.
`copilot-provider.ts` is the local adapter assembly entrypoint used by
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
reconciliation, not vendored SDK code. Authentication, request headers, model
routing, and integration lifecycle are also owned by the provider plugin.
When updating the upstream-shaped modules, compare against both source packages
and reapply the documented Copilot adaptations. Focused regression coverage is
in:
- `test/github-copilot/copilot-chat-model.test.ts`
- `test/github-copilot/convert-to-copilot-messages.test.ts`
- `test/github-copilot/openai-responses-language-model.test.ts`
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
- `test/github-copilot/models.test.ts`
- `test/plugin/provider-github-copilot.test.ts`
@@ -653,7 +653,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
}
if (isActiveText) {
controller.enqueue({ type: "text-end", id: "txt-0" })
controller.enqueue({
type: "text-end",
id: "txt-0",
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
}
// go through all tool calls and send the ones that are not finished
@@ -4,7 +4,6 @@ import {
type LanguageModelV3,
type LanguageModelV3CallOptions,
type LanguageModelV3Content,
type LanguageModelV3ProviderTool,
type LanguageModelV3StreamPart,
type SharedV3ProviderMetadata,
type SharedV3Warning,
@@ -27,7 +26,7 @@ import { imageGenerationOutputSchema } from "./tool/image-generation.js"
import { convertToOpenAIResponsesInput } from "./convert-to-openai-responses-input.js"
import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-reason.js"
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
import { prepareResponsesTools, type ResponsesHostedTool } from "./openai-responses-prepare-tools.js"
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
const webSearchCallItem = z.object({
@@ -221,15 +220,23 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
addInclude("message.output_text.logprobs")
}
// when a web search tool is present, automatically include the sources:
const webSearchToolName = (
tools?.find(
(tool) =>
tool.type === "provider" && (tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"),
) as LanguageModelV3ProviderTool | undefined
)?.name
const {
tools: openaiTools,
toolChoice: openaiToolChoice,
hostedTools,
selectedHostedTool,
toolWarnings,
} = prepareResponsesTools({
tools,
toolChoice,
strictJsonSchema,
})
const getHostedToolName = (responseType: ResponsesHostedTool["responseType"]) => {
if (selectedHostedTool?.responseType === responseType) return selectedHostedTool.name
return hostedTools.find((tool) => tool.responseType === responseType)?.name ?? responseType
}
if (webSearchToolName) {
if (hostedTools.some((tool) => tool.responseType === "web_search")) {
addInclude("web_search_call.action.sources")
}
@@ -357,18 +364,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
baseArgs.service_tier = undefined
}
const {
tools: openaiTools,
toolChoice: openaiToolChoice,
toolWarnings,
} = prepareResponsesTools({
tools,
toolChoice,
strictJsonSchema,
})
return {
webSearchToolName,
getHostedToolName,
args: {
...baseArgs,
tools: openaiTools,
@@ -379,7 +376,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
}
async doGenerate(options: LanguageModelV3CallOptions) {
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
const url = this.config.url({
path: "/responses",
modelId: this.modelId,
@@ -526,7 +523,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
input: "{}",
providerExecuted: true,
})
@@ -534,7 +531,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
result: {
result: part.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
@@ -605,7 +602,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: webSearchToolName ?? "web_search",
toolName: getHostedToolName("web_search"),
input: JSON.stringify({ action: part.action }),
providerExecuted: true,
})
@@ -613,7 +610,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: webSearchToolName ?? "web_search",
toolName: getHostedToolName("web_search"),
result: { status: part.status },
})
@@ -645,7 +642,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
input: "{}",
providerExecuted: true,
})
@@ -653,7 +650,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
result: {
queries: part.queries,
results:
@@ -673,7 +670,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-call",
toolCallId: part.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
input: JSON.stringify({
code: part.code,
containerId: part.container_id,
@@ -684,7 +681,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
content.push({
type: "tool-result",
toolCallId: part.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
result: {
outputs: part.outputs,
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
@@ -746,7 +743,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
}
async doStream(options: LanguageModelV3CallOptions) {
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
const { responseHeaders, value: response } = await postJsonToApi({
url: this.config.url({
@@ -866,7 +863,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-input-start",
id: value.item.id,
toolName: webSearchToolName ?? "web_search",
toolName: getHostedToolName("web_search"),
})
} else if (value.item.type === "computer_call") {
ongoingToolCalls[value.output_index] = {
@@ -889,7 +886,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-input-start",
id: value.item.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
})
controller.enqueue({
@@ -901,7 +898,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: value.item.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
input: "{}",
providerExecuted: true,
})
@@ -909,7 +906,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: value.item.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
input: "{}",
providerExecuted: true,
})
@@ -980,7 +977,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: value.item.id,
toolName: "web_search",
toolName: getHostedToolName("web_search"),
input: JSON.stringify({ action: value.item.action }),
providerExecuted: true,
})
@@ -988,7 +985,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "web_search",
toolName: getHostedToolName("web_search"),
result: { status: value.item.status },
})
} else if (value.item.type === "computer_call") {
@@ -1022,7 +1019,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "file_search",
toolName: getHostedToolName("file_search"),
result: {
queries: value.item.queries,
results:
@@ -1041,7 +1038,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
result: {
outputs: value.item.outputs,
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
@@ -1050,7 +1047,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item.id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
result: {
result: value.item.result,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
@@ -1099,7 +1096,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-result",
toolCallId: value.item_id,
toolName: "image_generation",
toolName: getHostedToolName("image_generation"),
result: {
result: value.partial_image_b64,
} satisfies z.infer<typeof imageGenerationOutputSchema>,
@@ -1135,7 +1132,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: "tool-call",
toolCallId: toolCall.toolCallId,
toolName: "code_interpreter",
toolName: getHostedToolName("code_interpreter"),
input: JSON.stringify({
code: value.code,
containerId: toolCall.codeInterpreter!.containerId,
@@ -1,4 +1,9 @@
import { type LanguageModelV3CallOptions, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
import {
type LanguageModelV3CallOptions,
type LanguageModelV3ProviderTool,
type SharedV3Warning,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { codeInterpreterArgsSchema } from "./tool/code-interpreter.js"
import { fileSearchArgsSchema } from "./tool/file-search.js"
import { webSearchArgsSchema } from "./tool/web-search.js"
@@ -6,6 +11,28 @@ import { webSearchPreviewArgsSchema } from "./tool/web-search-preview.js"
import { imageGenerationArgsSchema } from "./tool/image-generation.js"
import type { OpenAIResponsesTool } from "./openai-responses-api-types.js"
export type ResponsesHostedTool = {
name: string
type: "file_search" | "web_search_preview" | "web_search" | "code_interpreter" | "image_generation"
responseType: "file_search" | "web_search" | "code_interpreter" | "image_generation"
}
export function getResponsesHostedTool(tool: LanguageModelV3ProviderTool): ResponsesHostedTool | undefined {
switch (tool.id) {
case "openai.file_search":
return { name: tool.name, type: "file_search", responseType: "file_search" }
case "openai.web_search_preview":
return { name: tool.name, type: "web_search_preview", responseType: "web_search" }
case "openai.web_search":
return { name: tool.name, type: "web_search", responseType: "web_search" }
case "openai.code_interpreter":
return { name: tool.name, type: "code_interpreter", responseType: "code_interpreter" }
case "openai.image_generation":
return { name: tool.name, type: "image_generation", responseType: "image_generation" }
}
return undefined
}
export function prepareResponsesTools({
tools,
toolChoice,
@@ -26,6 +53,8 @@ export function prepareResponsesTools({
| { type: "function"; name: string }
| { type: "code_interpreter" }
| { type: "image_generation" }
hostedTools: ResponsesHostedTool[]
selectedHostedTool?: ResponsesHostedTool
toolWarnings: SharedV3Warning[]
} {
// when the tools array is empty, change it to undefined to prevent errors:
@@ -34,7 +63,49 @@ export function prepareResponsesTools({
const toolWarnings: SharedV3Warning[] = []
if (tools == null) {
return { tools: undefined, toolChoice: undefined, toolWarnings }
return { tools: undefined, toolChoice: undefined, hostedTools: [], toolWarnings }
}
const hostedTools = tools.flatMap((tool) => {
if (tool.type !== "provider") return []
const hostedTool = getResponsesHostedTool(tool)
return hostedTool ? [hostedTool] : []
})
const selectedToolName = toolChoice?.type === "tool" ? toolChoice.toolName : undefined
const selectedTools = selectedToolName === undefined ? [] : tools.filter((tool) => tool.name === selectedToolName)
if (selectedTools.length > 1) {
throw new UnsupportedFunctionalityError({
functionality: `ambiguous tool choice '${selectedToolName}': multiple tool definitions share this name`,
})
}
const selectedHostedTool =
selectedTools[0]?.type === "provider" ? getResponsesHostedTool(selectedTools[0]) : undefined
const ambiguousHostedResponse =
toolChoice?.type === "none" || toolChoice?.type === "tool"
? undefined
: hostedTools.find(
(tool) =>
new Set(
hostedTools.filter((candidate) => candidate.responseType === tool.responseType).map((item) => item.name),
).size > 1,
)
if (ambiguousHostedResponse) {
const names = new Set(
hostedTools.filter((tool) => tool.responseType === ambiguousHostedResponse.responseType).map((tool) => tool.name),
)
throw new UnsupportedFunctionalityError({
functionality: `ambiguous ${ambiguousHostedResponse.responseType} response for hosted tools: ${[...names].join(", ")}`,
})
}
if (selectedHostedTool) {
const names = new Set(hostedTools.filter((tool) => tool.type === selectedHostedTool.type).map((tool) => tool.name))
if (names.size > 1) {
throw new UnsupportedFunctionalityError({
functionality: `ambiguous ${selectedHostedTool.type} tool choice for hosted tools: ${[...names].join(", ")}`,
})
}
}
const openaiTools: Array<OpenAIResponsesTool> = []
@@ -134,7 +205,7 @@ export function prepareResponsesTools({
}
if (toolChoice == null) {
return { tools: openaiTools, toolChoice: undefined, toolWarnings }
return { tools: openaiTools, toolChoice: undefined, hostedTools, selectedHostedTool, toolWarnings }
}
const type = toolChoice.type
@@ -143,20 +214,18 @@ export function prepareResponsesTools({
case "auto":
case "none":
case "required":
return { tools: openaiTools, toolChoice: type, toolWarnings }
case "tool":
return { tools: openaiTools, toolChoice: type, hostedTools, selectedHostedTool, toolWarnings }
case "tool": {
return {
tools: openaiTools,
toolChoice:
toolChoice.toolName === "code_interpreter" ||
toolChoice.toolName === "file_search" ||
toolChoice.toolName === "image_generation" ||
toolChoice.toolName === "web_search_preview" ||
toolChoice.toolName === "web_search"
? { type: toolChoice.toolName }
: { type: "function", name: toolChoice.toolName },
toolChoice: selectedHostedTool
? { type: selectedHostedTool.type }
: { type: "function", name: toolChoice.toolName },
hostedTools,
selectedHostedTool,
toolWarnings,
}
}
default: {
const _exhaustiveCheck: never = type
throw new UnsupportedFunctionalityError({
+2 -7
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem.js"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
@@ -33,12 +33,7 @@ export const make = Effect.gen(function* () {
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
limits: Readonly<Limits>,
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
+2 -2
View File
@@ -2,13 +2,13 @@ export * as InstructionBuiltIns from "./builtins.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import type { Session } from "@opencode-ai/schema/session"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location.js"
import { SessionSchema } from "../session/schema.js"
import { Instructions } from "./index.js"
export interface Interface {
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.List>
readonly load: (sessionID: Session.ID) => Effect.Effect<Instructions.List>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
+98 -106
View File
@@ -378,117 +378,109 @@ const layer = Layer.effect(
}
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
yield* Effect.gen(function* () {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
label,
value: exit.value,
}),
),
Effect.asVoid,
Effect.exit,
)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
)
})
yield* Effect.gen(function* () {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
label,
value: exit.value,
}),
),
Effect.asVoid,
Effect.exit,
)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
}).pipe(Effect.ensuring(close(attempt.scope)))
}, Effect.uninterruptible)
const settleCommand = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<string, unknown>) {
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
const now = yield* Clock.currentTimeMillis
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = Exit.isSuccess(exit)
? { ...match, persisting: true }
: {
status: "failed" as const,
integrationID: match.integrationID,
message: message(exit.cause),
time: match.time,
removeAt: now + terminalRetention,
}
return [match, new Map(current).set(attemptID, next)]
})
if (!attempt) return
if (Exit.isFailure(exit)) {
yield* close(attempt.scope)
return
}
const persistence = yield* createCredential({
const persistence = yield* createCredential({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
status: "complete",
integrationID: attempt.integrationID,
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
}),
)
})
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
: {
status: "failed",
integrationID: attempt.integrationID,
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
}, Effect.uninterruptible)
const scrub = Effect.fnUntraced(function* () {
const now = yield* Clock.currentTimeMillis
+1
View File
@@ -37,6 +37,7 @@ export function buildLocationServiceMap(
...inner,
get: (ref: Location.Ref) => inner.get(canonical(ref)),
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
}),
),
+7 -1
View File
@@ -26,6 +26,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import type { Session } from "@opencode-ai/schema/session"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
@@ -156,6 +157,7 @@ export interface Connection {
readonly callTool: (input: {
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
/** Registers a callback fired when the server emits an MCP logging notification. */
@@ -396,7 +398,11 @@ export const connect = Effect.fnUntraced(function* (
Effect.tryPromise({
try: (signal) =>
client.callTool(
{ name: input.name, arguments: input.args ?? {} },
{
name: input.name,
arguments: input.args ?? {},
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
},
CallToolResultSchema,
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
+3 -1
View File
@@ -3,6 +3,7 @@ export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import type { Session } from "@opencode-ai/schema/session"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
@@ -153,6 +154,7 @@ export interface Interface extends State.Transformable<Draft> {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly prompts: () => Effect.Effect<Prompt[]>
@@ -762,7 +764,7 @@ export const layer = (options?: Options) =>
message: "MCP server is not connected",
})
const result = yield* target.entry.client
.callTool({ name: input.name, args: input.args })
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
.pipe(
Effect.mapError(
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+2 -11
View File
@@ -292,20 +292,11 @@ const layer = Layer.effect(
pending.delete(input.requestID)
if (input.reply !== "always" || !existing.request.save?.length) return
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.undefined),
)
if (!rules) continue
if (denied(item.request, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every(
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
)
)
continue
if (result?.effect !== "allow") continue
yield* bus.publish(Permission.Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
+1 -1
View File
@@ -2,9 +2,9 @@ export * as PermissionSaved from "./saved.js"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "@opencode-ai/schema/project"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Project } from "../project.js"
import { PermissionTable } from "./sql.js"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
+1 -1
View File
@@ -1,6 +1,6 @@
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import type { Project } from "@opencode-ai/schema/project"
import { Timestamps } from "../database/schema.sql.js"
import { Project } from "../project.js"
import { ProjectTable } from "../project/sql.js"
import type { PermissionSaved } from "./saved.js"
+5 -30
View File
@@ -4,7 +4,7 @@ import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Pty } from "@opencode-ai/schema/pty"
@@ -26,19 +26,9 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = Pty.Info & {
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Info = PersistentPty.Info
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type Snapshot = PersistentPty.Snapshot
export type Attachment = {
readonly info: Info
@@ -161,15 +151,7 @@ export const configured = (options: Options = {}) =>
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: {
readonly command?: string
readonly args: readonly string[]
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
input: Parameters<Interface["create"]>[1],
) {
const response = yield* request(
daemon,
@@ -338,14 +320,7 @@ export const configured = (options: Options = {}) =>
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
input: Parameters<Interface["attach"]>[1],
) {
yield* get(id)
const attachment = yield* daemon
+3 -1
View File
@@ -84,7 +84,9 @@ export const Plugin = define({
})
function append(template: string, input: string) {
return [template, input.trim()].filter(Boolean).join("\n\n")
const value = input.trim()
if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", () => value)
return [template, value].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
+2 -1
View File
@@ -369,9 +369,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
vcs: {
get: () => response(vcs.info()),
base: () => response(vcs.base()),
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
status: () => response(vcs.status()),
diff: (input) => response(vcs.diff(input.mode, { context: input.context })),
diff: (input) => response(vcs.diff(input.mode, { context: input.context, base: input.base })),
transform: vcs.transform,
reload: vcs.reload,
},
+2 -2
View File
@@ -1,9 +1,9 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Integration } from "@opencode-ai/schema/integration"
import { Provider } from "@opencode-ai/schema/provider"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { ModelsDev } from "../models-dev.js"
import { Provider } from "../provider.js"
export const ModelsDevPlugin = define({
id: "opencode.models.dev",
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
}
}
})
+1 -1
View File
@@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
+2 -2
View File
@@ -2,12 +2,12 @@ export * as PlanPlugin from "./plan.js"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import type { SessionEvent } from "@opencode-ai/schema/session-event"
import { Global } from "@opencode-ai/util/global"
import { Effect, Stream } from "effect"
import path from "path"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
import { SessionEvent } from "../session/event.js"
const plan = Agent.ID.make("plan")
+6 -4
View File
@@ -76,11 +76,13 @@ to every project for that user. Project configuration can live in any directory
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
in a monorepo.
When OpenCode starts, it searches from the current directory up to the project
root. It merges direct `opencode.json(c)` files from root to current directory,
During ordinary project discovery, OpenCode searches the current Location
directory and every ancestor through the filesystem root, including directories
above the detected project or repository root. It merges direct
`opencode.json(c)` files from the farthest ancestor to the current directory,
then does the same for `.opencode/opencode.json(c)` files. This means every
`.opencode` config overrides every direct config. Global configuration has the
lowest precedence.
discovered `.opencode` config overrides every discovered direct config. Global
filesystem configuration has lower precedence than these discovered documents.
Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
+16
View File
@@ -78,6 +78,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
}
})
@@ -89,6 +92,7 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let observed = 0
@@ -113,6 +117,18 @@ export const layer = Layer.effect(
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
+1 -1
View File
@@ -2,7 +2,7 @@ export * as VariantPlugin from "./variant.js"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Model } from "../model.js"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "../provider.js"
export const Plugin = define({
+137 -38
View File
@@ -4,10 +4,11 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { Base, BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "../../location.js"
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
import { DiffError } from "../../vcs.js"
import {
chunksByFile,
emptyPatch,
@@ -35,9 +36,10 @@ export const Plugin = define({
id: "git",
name: "Git",
info: () => adapter.info(),
base: () => adapter.base(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
diff: (input) => adapter.diff(input.mode, { context: input.context }),
diff: (input) => adapter.diff(input.mode, { context: input.context, base: input.base }),
})
})
}),
@@ -48,7 +50,7 @@ export const Plugin = define({
* batched through one `git diff` invocation where possible and capped by
* per-file and total byte budgets, falling back to empty patches when capped.
*/
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Adapter {
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }) {
// Listing commands scope pathspecs to the requested directory; per-file
// commands run from the worktree root because git lists root-relative paths.
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
@@ -60,6 +62,7 @@ function make(proc: AppProcess.Interface, input: { directory: string; worktree:
})
return { branch: { current, default: root?.name } } satisfies Info
}),
base: () => ctx.git.base(ctx.directory),
branches: Effect.fn("VcsGit.branches")(function* (options?: BranchOptions) {
return yield* ctx.git.branches(ctx.directory, options)
}),
@@ -93,25 +96,23 @@ function make(proc: AppProcess.Interface, input: { directory: string; worktree:
return yield* track(ctx, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options)
}
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
concurrency: 2,
})
if (!root) return []
if (current && current === root.name) return []
const ref = yield* git.mergeBase(ctx.directory, root.ref)
if (!ref) return []
return yield* diffAgainstRef(ctx, ref, options)
if (!(yield* git.hasHead(ctx.directory))) {
return mode === "committed" ? [] : yield* track(ctx, undefined, options)
}
const base = options?.base ?? (yield* git.defaultBranch(ctx.directory))?.ref
const ref = base ? yield* git.mergeBase(ctx.directory, base) : undefined
if (!ref) {
return yield* new DiffError({
message: base ? `No merge base available for ${base}` : "No review base available",
})
}
return yield* diffAgainstRef(ctx, ref, { ...options, target: mode === "committed" ? "HEAD" : undefined })
}),
}
} satisfies Adapter
}
type Kind = FileStatus["status"]
interface Base {
readonly name: string
readonly ref: string
}
interface Item {
readonly file: string
readonly code: string
@@ -124,8 +125,11 @@ interface Stat {
readonly deletions: number
}
interface PatchOptions {
readonly context?: number
interface GitDiffOptions extends DiffOptions {
readonly target?: "HEAD"
}
interface PatchOptions extends GitDiffOptions {
readonly maxOutputBytes?: number
}
@@ -196,7 +200,7 @@ function makeGit(proc: AppProcess.Interface) {
const result = yield* run(["config", "init.defaultBranch"], { cwd })
const name = result.text().trim()
if (!name || !list.includes(name)) return
return { name, ref: name } satisfies Base
return { name, ref: name }
})
const primary = Effect.fnUntraced(function* (cwd: string) {
@@ -239,15 +243,70 @@ function makeGit(proc: AppProcess.Interface) {
.trim()
.replace(/^refs\/remotes\//, "")
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
if (name) return { name, ref } satisfies Base
if (name) return { name, ref }
}
}
const list = yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
const next = yield* configured(cwd, list)
if (next) return next
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
if (list.includes("main")) return { name: "main", ref: "main" }
if (list.includes("master")) return { name: "master", ref: "master" }
})
const resolve = Effect.fnUntraced(function* (cwd: string, ref: string) {
const result = yield* run(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], { cwd })
if (result.exitCode !== 0) return
return result.text().trim() || undefined
})
const ancestor = Effect.fnUntraced(function* (cwd: string, commit: string, ref: string) {
if (!/^[a-f0-9]{40,64}$/.test(commit)) return false
return (yield* run(["merge-base", "--is-ancestor", commit, ref], { cwd })).exitCode === 0
})
const namedRef = Effect.fnUntraced(function* (cwd: string, input: string) {
// Creation hints must identify a branch, not HEAD, an object ID, or a revision expression.
if (input === "HEAD" || input.endsWith("/HEAD") || /[~^:@{}\s]/.test(input)) return
const ref = (yield* text(["rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", input], {
cwd,
})).trim()
if (!/^refs\/(heads|remotes)\/.+/.test(ref) || ref.endsWith("/HEAD") || !(yield* resolve(cwd, ref))) return
return { name: ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\/[^/]+\//, ""), ref }
})
const base = Effect.fn("VcsGit.base")(function* (cwd: string) {
if (!(yield* hasHead(cwd))) return null
const current = yield* branch(cwd)
if (!current) return yield* new DiffError({ message: "Choose a review base" })
const history = (yield* lines(
["reflog", "show", "--max-count=256", "--format=%H%x00%gs", `refs/heads/${current}`],
{
cwd,
},
)).flatMap((line) => {
const match = /^([a-f0-9]+)\0(.+)$/.exec(line)
return match ? [{ commit: match[1], message: match[2] }] : []
})
const renamed = history.some((entry) => entry.message.startsWith("Branch: renamed "))
const creation = renamed ? undefined : history.find((entry) => entry.message.startsWith("branch: Created from "))
const origin = creation?.message.slice("branch: Created from ".length)
if (creation && origin) {
const candidate = yield* namedRef(cwd, origin)
if (
candidate &&
candidate.name !== current &&
(yield* ancestor(cwd, creation.commit, "HEAD")) &&
(yield* ancestor(cwd, creation.commit, candidate.ref))
) {
return { ...candidate, source: "reflog" } satisfies Base
}
}
const root = yield* defaultBranch(cwd)
if (!root || current !== root.name) return yield* new DiffError({ message: "Choose a review base" })
const candidate = yield* namedRef(cwd, root.ref)
if (!candidate) return yield* new DiffError({ message: "The default review base is unavailable" })
return { name: root.name, ref: candidate.ref, source: "default" } satisfies Base
})
const hasHead = Effect.fn("VcsGit.hasHead")(function* (cwd: string) {
@@ -256,7 +315,9 @@ function makeGit(proc: AppProcess.Interface) {
})
const mergeBase = Effect.fn("VcsGit.mergeBase")(function* (cwd: string, base: string) {
const result = yield* run(["merge-base", base, "HEAD"], { cwd })
const ref = yield* resolve(cwd, base)
if (!ref) return
const result = yield* run(["merge-base", ref, "HEAD"], { cwd })
if (result.exitCode !== 0) return
return result.text().trim() || undefined
})
@@ -272,10 +333,13 @@ function makeGit(proc: AppProcess.Interface) {
})
})
const diff = Effect.fn("VcsGit.diffNames")(function* (cwd: string, ref: string) {
const list = nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
const diff = Effect.fn("VcsGit.diffNames")(function* (cwd: string, ref: string, target?: string) {
const result = yield* run(
["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, ...(target ? [target] : []), "--", "."],
{ cwd },
)
if (result.exitCode !== 0) return yield* new DiffError({ message: "Unable to list Git changes" })
const list = nuls(result.text())
return list.flatMap((code, idx) => {
if (idx % 2 !== 0) return []
const file = list[idx + 1]
@@ -284,9 +348,12 @@ function makeGit(proc: AppProcess.Interface) {
})
})
const stats = Effect.fn("VcsGit.stats")(function* (cwd: string, ref: string) {
const stats = Effect.fn("VcsGit.stats")(function* (cwd: string, ref: string, target?: string) {
return nuls(
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
yield* text(
["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, ...(target ? [target] : []), "--", "."],
{ cwd },
),
).flatMap((item) => {
const a = item.indexOf("\t")
const b = item.indexOf("\t", a + 1)
@@ -309,7 +376,17 @@ function makeGit(proc: AppProcess.Interface) {
const patch = Effect.fn("VcsGit.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
[
"diff",
"--patch",
"--no-ext-diff",
"--no-renames",
`--unified=${options?.context ?? 3}`,
ref,
...(options?.target ? [options.target] : []),
"--",
file,
],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
@@ -317,7 +394,17 @@ function makeGit(proc: AppProcess.Interface) {
const patchAll = Effect.fn("VcsGit.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
[
"diff",
"--patch",
"--no-ext-diff",
"--no-renames",
`--unified=${options?.context ?? 3}`,
ref,
...(options?.target ? [options.target] : []),
"--",
".",
],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.text(), truncated: result.truncated } satisfies Patch
@@ -367,6 +454,7 @@ function makeGit(proc: AppProcess.Interface) {
return {
branch,
branches,
base,
defaultBranch,
hasHead,
mergeBase,
@@ -393,10 +481,11 @@ const merge = (...lists: Item[][]) => {
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: DiffOptions) {
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: GitDiffOptions) {
if (list.length === 0) return emptyBatch()
const result = yield* ctx.git.patchAll(ctx.directory, ref, {
target: options?.target,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
@@ -407,7 +496,12 @@ const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: I
}
})
const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefined, item: Item, options?: DiffOptions) {
const nativePatch = Effect.fnUntraced(function* (
ctx: Ctx,
ref: string | undefined,
item: Item,
options?: GitDiffOptions,
) {
const result =
item.code === "??" || !ref
? yield* ctx.git.patchUntracked(ctx.worktree, item.file, {
@@ -415,6 +509,7 @@ const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefin
maxOutputBytes: MAX_PATCH_BYTES,
})
: yield* ctx.git.patch(ctx.worktree, ref, item.file, {
target: options?.target,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_PATCH_BYTES,
})
@@ -434,7 +529,7 @@ const patchForItem = Effect.fnUntraced(function* (
item: Item,
batch: { patches: Map<string, string>; capped: boolean },
capped: boolean,
options?: DiffOptions,
options?: GitDiffOptions,
) {
if (capped) return emptyPatch(item.file)
@@ -450,7 +545,7 @@ const files = Effect.fnUntraced(function* (
list: Item[],
map: Map<string, { additions: number; deletions: number }>,
batch: { patches: Map<string, string>; capped: boolean },
options?: DiffOptions,
options?: GitDiffOptions,
) {
const next: FileDiff.Info[] = []
let total = 0
@@ -459,7 +554,7 @@ const files = Effect.fnUntraced(function* (
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
const stat =
map.get(item.file) ??
(item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined)
(!options?.target && item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined)
const patch = yield* patchForItem(ctx, ref, item, batch, capped, options)
const result: { patch: string; capped: boolean } = capped
? { patch, capped: true }
@@ -481,9 +576,13 @@ const files = Effect.fnUntraced(function* (
return next
})
const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: DiffOptions) {
const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: GitDiffOptions) {
const [list, stats, extra] = yield* Effect.all(
[ctx.git.diff(ctx.directory, ref), ctx.git.stats(ctx.directory, ref), ctx.git.status(ctx.directory)],
[
ctx.git.diff(ctx.directory, ref, options?.target),
ctx.git.stats(ctx.directory, ref, options?.target),
options?.target ? Effect.succeed([]) : ctx.git.status(ctx.directory),
],
{ concurrency: 3 },
)
return yield* files(
+7 -1
View File
@@ -10,6 +10,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "../../location.js"
import type { Adapter, DiffOptions } from "../../vcs.js"
import { DiffError } from "../../vcs.js"
import {
addPatch,
chunksByFile,
@@ -42,7 +43,7 @@ export const Plugin = define({
info: () => adapter.info(),
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
status: () => adapter.status(),
diff: (input) => adapter.diff(input.mode, { context: input.context }),
diff: (input) => adapter.diff(input.mode, { context: input.context, base: input.base }),
})
})
}),
@@ -121,6 +122,11 @@ function make(
}),
diff: Effect.fn("VcsHg.diff")(function* (mode: Mode, options?: DiffOptions) {
if (mode === "working") return yield* diffAgainst(undefined, options)
if (mode === "committed" || options?.base !== undefined) {
return yield* new DiffError({
message: "The Mercurial provider does not support committed reviews or explicit bases",
})
}
const branch = yield* hg.branch()
if (!branch || branch === "default") return []
+3 -3
View File
@@ -1,9 +1,9 @@
export * as WarmingPlugin from "./warming.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Session } from "@opencode-ai/schema/session"
import { Clock, Duration, Effect, Scope } from "effect"
import { Config } from "../config.js"
import { SessionSchema } from "../session/schema.js"
const defaults = {
prompt: "This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK",
@@ -26,8 +26,8 @@ export const Plugin = define({
})
const scope = yield* Scope.Scope
const sessions = new Map<SessionSchema.ID, { last: number; expires: number; settings: typeof defaults }>()
const loop: (sessionID: SessionSchema.ID) => Effect.Effect<void> = Effect.fn("WarmingPlugin.loop")(
const sessions = new Map<Session.ID, { last: number; expires: number; settings: typeof defaults }>()
const loop: (sessionID: Session.ID) => Effect.Effect<void> = Effect.fn("WarmingPlugin.loop")(
function* (sessionID) {
const current = sessions.get(sessionID)
if (!current) return
+206 -207
View File
@@ -88,233 +88,232 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const shell = yield* ShellSelect.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
const exitOrder: PtyID[] = []
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const shell = yield* ShellSelect.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
const exitOrder: PtyID[] = []
function notifyEnd(session: Active, event: { exitCode?: number }) {
for (const subscriber of session.subscribers.values()) {
if (!subscriber.active) {
subscriber.end = event
continue
}
try {
subscriber.onEnd(event)
} catch {}
function notifyEnd(session: Active, event: { exitCode?: number }) {
for (const subscriber of session.subscribers.values()) {
if (!subscriber.active) {
subscriber.end = event
continue
}
session.subscribers.clear()
try {
subscriber.onEnd(event)
} catch {}
}
session.subscribers.clear()
}
function teardown(session: Active) {
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
if (session.info.status === "running") {
try {
session.process.kill()
} catch {}
}
notifyEnd(session, {})
function teardown(session: Active) {
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
if (session.info.status === "running") {
try {
session.process.kill()
} catch {}
}
notifyEnd(session, {})
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) teardown(session)
sessions.clear()
exitOrder.length = 0
}),
)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) teardown(session)
sessions.clear()
exitOrder.length = 0
}),
)
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ ptyID: id })
return session
})
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ ptyID: id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* bus.publish(Pty.Event.Deleted, { id: session.info.id })
})
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* bus.publish(Pty.Event.Deleted, { id: session.info.id })
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
yield* requireSession(id)
yield* removeSession(id)
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
yield* requireSession(id)
yield* removeSession(id)
})
const list = Effect.fn("Pty.list")(function* () {
return Array.from(sessions.values()).map((session) => session.info)
})
const list = Effect.fn("Pty.list")(function* () {
return Array.from(sessions.values()).map((session) => session.info)
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
return (yield* requireSession(id)).info
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || (yield* shell.resolve({ priority: "config" }))
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
...process.env,
...input.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
const info: Info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command,
args,
cwd,
status: "running",
pid: proc.pid,
}
const session: Active = {
info,
process: proc,
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Map(),
listeners: [],
}
sessions.set(id, session)
session.listeners.push(
proc.onData((chunk) => {
session.cursor += chunk.length
for (const [token, subscriber] of session.subscribers.entries()) {
if (!subscriber.active) {
subscriber.pending.push(chunk)
continue
}
try {
subscriber.onData(chunk)
} catch {
session.subscribers.delete(token)
}
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || (yield* shell.resolve({ priority: "config" }))
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
...process.env,
...input.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
const info: Info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command,
args,
cwd,
status: "running",
pid: proc.pid,
}
const session: Active = {
info,
process: proc,
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Map(),
listeners: [],
}
sessions.set(id, session)
session.listeners.push(
proc.onData((chunk) => {
session.cursor += chunk.length
for (const [token, subscriber] of session.subscribers.entries()) {
if (!subscriber.active) {
subscriber.pending.push(chunk)
continue
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
}),
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
session.info.status = "exited"
session.info.exitCode = exitCode
notifyEnd(session, { exitCode })
exitOrder.push(id)
runFork(
Effect.gen(function* () {
yield* Effect.logInfo("session exited", { id, exitCode })
yield* bus.publish(Pty.Event.Exited, { id, exitCode })
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(oldest)
}
}),
)
}),
)
yield* bus.publish(Pty.Event.Created, { info })
return info
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = yield* requireSession(id)
if (input.title) session.info.title = input.title
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
yield* bus.publish(Pty.Event.Updated, { info: session.info })
return session.info
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.write(data)
})
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
const token = {}
const subscriber: Subscriber = {
onData: input.onData,
onEnd: input.onEnd,
active: false,
detached: false,
pending: [],
}
session.subscribers.set(token, subscriber)
const start = session.bufferCursor
const end = session.cursor
const from =
input.cursor === -1
? end
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
? Math.max(0, input.cursor)
: 0
const replay = (() => {
if (!session.buffer || from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
return {
replay,
cursor: end,
write: (data: string) => {
if (session.info.status === "running") session.process.write(data)
},
activate: () => {
if (subscriber.active || subscriber.detached) return
subscriber.active = true
try {
for (const chunk of subscriber.pending) subscriber.onData(chunk)
subscriber.pending.length = 0
if (subscriber.end) subscriber.onEnd(subscriber.end)
subscriber.onData(chunk)
} catch {
session.subscribers.delete(token)
}
},
detach: () => {
subscriber.detached = true
subscriber.pending.length = 0
subscriber.end = undefined
session.subscribers.delete(token)
},
}
})
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
}),
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
session.info.status = "exited"
session.info.exitCode = exitCode
notifyEnd(session, { exitCode })
exitOrder.push(id)
runFork(
Effect.gen(function* () {
yield* Effect.logInfo("session exited", { id, exitCode })
yield* bus.publish(Pty.Event.Exited, { id, exitCode })
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(oldest)
}
}),
)
}),
)
yield* bus.publish(Pty.Event.Created, { info })
return info
})
return Service.of({ list, get, create, update, remove, write, attach })
}),
)
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = yield* requireSession(id)
if (input.title) session.info.title = input.title
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
yield* bus.publish(Pty.Event.Updated, { info: session.info })
return session.info
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.write(data)
})
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
const token = {}
const subscriber: Subscriber = {
onData: input.onData,
onEnd: input.onEnd,
active: false,
detached: false,
pending: [],
}
session.subscribers.set(token, subscriber)
const start = session.bufferCursor
const end = session.cursor
const from =
input.cursor === -1
? end
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
? Math.max(0, input.cursor)
: 0
const replay = (() => {
if (!session.buffer || from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
return {
replay,
cursor: end,
write: (data: string) => {
if (session.info.status === "running") session.process.write(data)
},
activate: () => {
if (subscriber.active || subscriber.detached) return
subscriber.active = true
try {
for (const chunk of subscriber.pending) subscriber.onData(chunk)
subscriber.pending.length = 0
if (subscriber.end) subscriber.onEnd(subscriber.end)
} catch {
session.subscribers.delete(token)
}
},
detach: () => {
subscriber.detached = true
subscriber.pending.length = 0
subscriber.end = undefined
session.subscribers.delete(token)
},
}
})
return Service.of({ list, get, create, update, remove, write, attach })
}),
)
export const node = makeLocationNode({
service: Service,
layer: layer(),
layer,
deps: [Bus.node, Location.node, ShellSelect.node],
})
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PtyTicket from "./ticket.js"
import { Workspace } from "../workspace.js"
import type { Workspace } from "@opencode-ai/schema/workspace"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema.js"
import { Cache, Context, Duration, Effect, Layer } from "effect"
+5 -9
View File
@@ -134,10 +134,9 @@ const layer = Layer.effect(
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
if (truncated) return rows.slice(0, input.limit)
const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber)
@@ -147,7 +146,7 @@ const layer = Layer.effect(
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
return code === 1 ? [] : rows
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
@@ -178,7 +177,7 @@ const layer = Layer.effect(
parse: (line) => Effect.succeed(normalizePath(line)),
}).pipe(
Effect.map((result) =>
result.items.map((relative) =>
result.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
@@ -212,10 +211,7 @@ const layer = Layer.effect(
)
},
onItem: input.onEntry,
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
grep: (input) =>
run<RawMatchData>({
...input,
@@ -248,7 +244,7 @@ const layer = Layer.effect(
),
}).pipe(
Effect.map((result) =>
result.items.map((match) =>
result.map((match) =>
Match.make({
entry: Entry.make({
path: RelativePath.make(match.path.text),
+58 -82
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { Cause, Effect, Fiber, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
@@ -50,8 +50,7 @@ import { Job } from "./job.js"
import { Command } from "./command.js"
import { Shell } from "./shell.js"
import { Global } from "@opencode-ai/util/global"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { ShellResult } from "./shell/result.js"
import { fileURLToPath } from "url"
import { SessionEnvironment } from "./session/environment.js"
import { SessionHistory } from "./session/history.js"
@@ -347,8 +346,6 @@ const layer = Layer.effect(
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const location = Location.Ref.make({
directory: session.location.directory,
@@ -680,10 +677,7 @@ const layer = Layer.effect(
// retried payload, metadata, and delivery mode.
if (admitted.type !== "user" || admitted.sessionID !== input.sessionID)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) {
if (activeShells.has(admitted.sessionID)) return admitted
yield* execution.wake(admitted.sessionID)
}
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
@@ -717,60 +711,62 @@ const layer = Layer.effect(
}),
shell: Effect.fn("Session.shell")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* shellLocks.withLock(input.sessionID)(
Effect.gen(function* () {
activeShells.add(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
const shell = yield* Shell.Service
return yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID },
})
.pipe(Effect.orDie)
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
shell: started,
},
{ id: input.id },
)
const completed = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
const terminal = yield* shell.wait(started.id).pipe(
Effect.map((info) => ({ info, retained: true as const })),
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }),
),
)
const output = terminal.retained
? yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
: missingShellOutput()
return { shell: terminal.info, output }
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: completed.shell,
output: completed.output,
// The server owns completion recording even if the submitting client disconnects.
const running = yield* Effect.gen(function* () {
// Resolve shell services here without pinning Session events to this Location after a move.
const shell = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
}).pipe(Effect.provide(locations.get(session.location)))
const started = yield* shell
.create({
command: input.command,
cwd: session.location.directory,
timeout: 0,
metadata: { sessionID: input.sessionID, background: true },
})
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
activeShells.delete(input.sessionID)
yield* execution.wake(input.sessionID)
}),
),
),
)
.pipe(
Effect.tapError((error) =>
result.synthetic({
sessionID: input.sessionID,
text: `User shell command failed to start:\n${input.command}\n\n${error.message}`,
description: input.command,
metadata: { source: "shell", state: "error" },
resume: false,
}),
),
Effect.orDie,
)
yield* bus.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
shell: started,
},
{ id: input.id },
)
const terminal = yield* shell.result(started)
const preview = yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable)))
yield* bus.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
shell: terminal.info,
output: preview,
})
yield* result
.synthetic({
...ShellResult.userNotification(terminal),
sessionID: input.sessionID,
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
Effect.orDie,
)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
yield* Fiber.join(running)
}),
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
@@ -994,26 +990,6 @@ const layer = Layer.effect(
}),
)
function missingShellOutput() {
const output = "Shell command output is no longer available."
return {
output,
cursor: Buffer.byteLength(output),
size: Buffer.byteLength(output),
truncated: false,
}
}
function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info {
return {
...started,
// The Shell record was removed before waiters could observe it; publish a terminal
// boundary instead of leaving the Session shell message permanently running.
status: "killed",
time: { ...started.time, completed: Date.now() },
}
}
const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
request: Parameters<Interface["prompt"]>[0],
messageID: SessionMessage.ID,
+24 -21
View File
@@ -1,6 +1,7 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
@@ -15,7 +16,6 @@ import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
import { SessionUsage } from "./usage.js"
import { Agent } from "../agent.js"
import { State } from "../state.js"
const DEFAULT_BUFFER = 20_000
@@ -164,7 +164,9 @@ const serialize = (message: SessionMessage.Info) => {
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell")
return `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
return message.metadata?.background === true
? ""
: `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
return ""
}
@@ -393,26 +395,27 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
return yield* input.resolveModel(input.session).pipe(
Effect.matchEffect({
onFailure: (cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (resolved) =>
execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
}),
}),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
+1 -1
View File
@@ -1,5 +1,6 @@
export * as SessionContext from "./context.js"
import { Model } from "@opencode-ai/schema/model"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
@@ -12,7 +13,6 @@ import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
import { McpTool } from "../tool/mcp.js"
import { Model } from "../model.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
+12 -27
View File
@@ -9,6 +9,8 @@ import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { ShellResult } from "../../shell/result.js"
import { SubagentCompletion } from "../subagent-completion.js"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
@@ -115,13 +117,13 @@ export const layer = (options?: Options) =>
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",
...ShellResult.notification({
jobID: background.id,
shellID: recovery.shellID,
command: recovery.command,
state,
},
text,
}),
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
})
.pipe(
@@ -143,29 +145,12 @@ export const layer = (options?: Options) =>
}
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)
yield* SubagentCompletion.deliver(sessions, jobs, {
...result,
recovery,
notificationID: background.notificationID,
resume: suspended.has(recovery.parentSessionID) ? false : undefined,
}).pipe(Effect.orDie)
})
if (background.status !== "running") {
@@ -131,11 +131,7 @@ const layer = Layer.effect(
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("InstructionEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
if (actualBytes > MaxValueBytes)
yield* new ValueTooLargeError({
@@ -159,10 +155,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
})
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
yield* db
.update(InstructionEntryTable)
.set({ value: null, removed: true, time_updated: Date.now() })
+2 -5
View File
@@ -1,7 +1,7 @@
export * as SessionInstructions from "./instructions.js"
import { relative } from "path"
import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect"
import { Context, Effect, Layer, Option, Ref, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -43,10 +43,7 @@ const layer = Layer.effect(
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
const claimed = yield* Ref.modify(inFlight, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
+2 -1
View File
@@ -169,7 +169,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
SessionMessage.Shell.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
metadata: event.metadata,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
+5 -2
View File
@@ -2,12 +2,13 @@ export * as SessionModelRequest from "./model-request.js"
import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Content } from "@opencode-ai/schema/tool"
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
import { Model } from "../model.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
@@ -18,7 +19,6 @@ import { SessionSchema } from "./schema.js"
import { SessionSystemPrompt } from "./system-prompt.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
import type { SessionMessage } from "./message.js"
import type { Agent } from "../agent.js"
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
@@ -48,6 +48,7 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
export interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
/**
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
@@ -364,9 +365,11 @@ export const layer = Layer.effect(
tools
.execute({ ...input, definitions: hooked })
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
return {
request,
options,
retry,
executeTool,
}
})
+23 -20
View File
@@ -1,7 +1,7 @@
export * as SessionRunnerLLM from "./llm.js"
import { Message } from "@opencode-ai/ai"
import { Cause, Effect, Exit, FiberMap, Layer, Pull, Schedule } from "effect"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { InstructionState } from "../instruction-state.js"
@@ -15,7 +15,7 @@ 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 { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform.js"
@@ -44,12 +44,7 @@ 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>()
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
const sessionID = input.sessionID
let force = input.force
let continuing = input.continuation !== undefined
@@ -176,7 +171,7 @@ const layer = Layer.effect(
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
const sessionID = first.session.id
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
const retry = yield* SessionRunnerRetry.make(bus, sessionID)
let initial: SessionContext.Loaded | undefined = first
let recoverOverflow = true
let recoverContinuation = true
@@ -222,6 +217,15 @@ const layer = Layer.effect(
agent: loaded.agent.id,
model: loaded.model,
prepared,
retry: (cause, error, proposed) =>
retry.decide({
cause,
error,
agent: loaded.agent.id,
model: loaded.model.ref,
hook: prepared.retry,
retry: proposed,
}),
recoverContinuation,
recoverOverflow: Effect.suspend(() =>
recoverOverflow && compaction.enabled()
@@ -232,18 +236,17 @@ const layer = Layer.effect(
const completed = yield* SessionStep.Outcome.$match(outcome, {
Completed: (outcome) => Effect.succeed(outcome.needsContinuation),
Retry: (outcome) =>
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
bus
.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
.pipe(Effect.andThen(outcome.cause)),
),
Effect.asVoid,
),
retry.wait({
decision: outcome.decision,
error: outcome.error,
assistantMessageID,
}),
Continue: Effect.fnUntraced(function* (outcome) {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() => outcome.cause),
)
yield* retry.wait({
decision: outcome.decision,
error: outcome.error,
assistantMessageID,
})
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
assistantMessageID = SessionMessage.ID.create()
}),
+10 -10
View File
@@ -2,10 +2,10 @@ export * as SessionRunnerModel from "./model.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Context, Effect, Layer, Schema } from "effect"
import { ModelResolver } from "../../model-resolver.js"
import { Capabilities, ID, Info, Ref, VariantID } from "../../model.js"
import { Provider } from "../../provider.js"
import { SessionSchema } from "../schema.js"
export class ModelNotSelectedError extends Schema.TaggedError<ModelNotSelectedError>()(
@@ -19,7 +19,7 @@ export class ModelNotSelectedError extends Schema.TaggedError<ModelNotSelectedEr
export class ModelUnavailableError extends Schema.TaggedError<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
{ providerID: Provider.ID, modelID: ID },
{ providerID: Provider.ID, modelID: Model.ID },
) {
override get message() {
if (this.providerID === "azure-cognitive-services")
@@ -43,7 +43,7 @@ export interface Interface {
/** Availability is sampled lazily for each explicitly selected model resolution. */
readonly resolve: (
session: SessionSchema.Info,
available: () => Effect.Effect<ReadonlyArray<Info>>,
available: () => Effect.Effect<ReadonlyArray<Model.Info>>,
) => Effect.Effect<Resolved, Error>
}
@@ -53,15 +53,15 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const resolved = (
model: LanguageModel,
options: {
readonly capabilities: Capabilities
readonly variant?: VariantID
readonly cost: Info["cost"]
readonly limit: Info["limit"]
readonly capabilities: Model.Capabilities
readonly variant?: Model.VariantID
readonly cost: Model.Info["cost"]
readonly limit: Model.Info["limit"]
},
): Resolved => ({
model,
ref: Ref.make({
id: ID.make(model.id),
ref: Model.Ref.make({
id: Model.ID.make(model.id),
providerID: Provider.ID.make(model.provider),
...(options.variant === undefined ? {} : { variant: options.variant }),
}),
@@ -1,15 +1,16 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Snapshot } from "@opencode-ai/schema/snapshot"
import { Clock, Effect, Iterable } from "effect"
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Agent } from "../../agent.js"
import { Snapshot } from "../../snapshot.js"
import { RelativePath } from "../../schema.js"
import { SessionUsage } from "../usage.js"
import { Tool } from "@opencode-ai/schema/tool"
@@ -45,9 +46,6 @@ export interface StepRecord {
/** Derives canonical model content from a provider-hosted tool result. */
type NonEmptyContent = readonly [Tool.Content, ...Tool.Content[]]
const nonEmpty = (content: ReadonlyArray<Tool.Content>): NonEmptyContent | undefined =>
content.length > 0 ? (content as NonEmptyContent) : undefined
const stringify = (value: unknown) => {
if (typeof value === "string") return value
try {
@@ -58,10 +56,7 @@ const stringify = (value: unknown) => {
}
const hostedContent = (result: ToolResultValue): NonEmptyContent => {
if (result.type === "content") {
const content = nonEmpty(result.value)
if (content !== undefined) return content
}
if (result.type === "content" && isReadonlyArrayNonEmpty(result.value)) return result.value
return [{ type: "text", text: stringify(result.value) }]
}
@@ -72,7 +67,9 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
* concurrently without a lock. Two rules keep that safe, and every method must preserve
* them. (1) Commit state marks synchronously before the first await: never a yield
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
* stays atomic under cooperative scheduling. Provider-event publication remains
* uninterruptible through its writes so cancellation cannot strand a mark without
* its durable event. (2) Never require a cross-source event
* order: each publishing fiber is sequential, so per-source order holds by construction,
* and consumers fold by id/ordinal rather than global position.
*/
@@ -533,7 +530,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* failAssistant({ type: "provider.unknown", message: event.message })
return
}
})
}, Effect.uninterruptible)
const publishTraced = Effect.fn("SessionRunner.publishLLMEvent")(publish)
@@ -563,12 +560,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
: result.content === undefined
? []
: [...result.content]
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID,
id,
content: [content[0], ...content.slice(1)],
content,
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted,
})
+70 -22
View File
@@ -1,17 +1,29 @@
export * as SessionRunnerRetry from "./retry.js"
import { AIError } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Duration, Effect, Schedule } from "effect"
import { Clock, Duration, Effect, Pull, Schedule } from "effect"
import { Bus } from "../../bus.js"
import type { PluginHooks } from "../../plugin/hooks.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
export interface Input {
interface Input {
readonly cause: AIError
readonly error: SessionError.Error
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly hook: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
readonly retry: boolean
}
export interface Decision {
readonly retry: true
readonly attempt: number
readonly delay: number
}
export function isRetryable(error: AIError) {
@@ -55,22 +67,58 @@ const retryAfter = (input: Input) => {
return undefined
}
export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.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)
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,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
}),
),
)
const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<Input>(),
Schedule.modifyDelay(({ input, duration: delay }) => {
const minimum = retryAfter(input)
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}),
)
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const step = yield* Schedule.toStep(schedule)
let attempt = 1
const decide = (input: Input) =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
const next = yield* step(now, input).pipe(Pull.catchDone(() => Effect.succeed(undefined)))
if (!next) return { retry: false as const }
const [, duration] = next
attempt++
const delay = Math.ceil(Duration.toMillis(duration))
const event: PluginHooks.Domains["session"]["retry"] = {
sessionID,
agent: input.agent,
model: input.model,
error: input.error,
attempt,
decision: input.retry ? { retry: true, delay } : { retry: false },
}
yield* input.hook(event)
if (!event.decision.retry) return event.decision
const normalized =
Number.isFinite(event.decision.delay) && event.decision.delay >= 0 ? Math.ceil(event.decision.delay) : delay
return { retry: true as const, attempt, delay: normalized }
})
const wait = (input: {
readonly decision: Decision
readonly assistantMessageID: SessionMessage.ID
readonly error: SessionError.Error
}) =>
Effect.gen(function* () {
const scheduled = yield* Clock.currentTimeMillis
yield* bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: input.assistantMessageID,
attempt: input.decision.attempt,
at: scheduled + input.decision.delay,
error: input.error,
})
const remaining = Math.max(0, scheduled + input.decision.delay - (yield* Clock.currentTimeMillis))
yield* Effect.sleep(Duration.millis(remaining))
})
return { decide, wait }
})

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