Compare commits

..
Author SHA1 Message Date
Aiden Cline a5829431b0 fix(ai): bridge tool results for mistral family models (#45051) 2026-08-25 13:40:35 -05:00
Aiden Cline 1ca82d154c fix(ai): omit empty chat assistant messages (#45046) 2026-08-25 12:56:24 -05:00
James Long bc1f67e518 feat(cli): embed persistent PTY service binaries (#44970) 2026-08-25 13:19:48 -04:00
Kit Langton 1f45962c84 fix(tui): support inline session rename and title regeneration (#45023) 2026-08-25 13:10:22 -04:00
Kit Langton 88242e21a8 fix(tui): stop completed exploration groups from spinning 2026-08-25 13:09:03 -04:00
0fd719067d feat(plugin): add permission review hooks (#45003)
Co-authored-by: R44VC0RP <R44VC0RP@users.noreply.github.com>
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-25 16:57:17 +00:00
Aiden Cline c94a4913c0 fix(ai): preserve unencrypted reasoning history (#45032) 2026-08-25 11:44:44 -05:00
opencode-agent[bot]andBrendonovich 6c32ba81e2 test: skip unaffected app e2e tests (#45030)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 00:33:14 +08:00
Kit Langton 938a82226a test(tui): synchronize tab close frame before pointer moves (#45017) 2026-08-25 12:30:20 -04:00
Aiden Cline c46b76b58e fix(ai): route response events by output index (#45013) 2026-08-25 11:27:40 -05:00
opencode-agent[bot] fbb3730fdd chore: update nix node_modules hashes 2026-08-25 16:26:05 +00:00
opencode-agent[bot]andBrendonovich 96cff7bb7a fix(app): clarify context usage label (#45012)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 00:20:00 +08:00
Kit Langton 578f8d637a fix(server): pin shared Effect platform runtime (#45016) 2026-08-25 12:19:24 -04:00
Kit Langton e5308a988f feat(merman): refine diagram styling (#44815) 2026-08-25 16:07:12 +00:00
opencode-agent[bot]andnexxeln d177f29dba fix(tui): keep autocomplete selection visible (#44983)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-25 21:30:53 +05:30
Aiden Cline 7f51da509b fix(ai): preserve terminal reasoning metadata (#44997) 2026-08-25 10:37:49 -05:00
James Long 004b647311 feat(server): add persistent PTY daemon API (#44969) 2026-08-25 11:31:47 -04:00
Aiden Cline cce86ac166 fix(ai): fail unknown Bedrock stream exceptions (#45004) 2026-08-25 10:08:21 -05:00
Shoubhit Dash b71291c05a refactor(core): move mercurial vcs into internal plugin (#44993) 2026-08-25 20:35:21 +05:30
Shoubhit Dash 0ab2d783e8 refactor(core): move git vcs into internal plugin (#44992) 2026-08-25 20:26:43 +05:30
Aiden Cline 3deac93d27 fix(ai): sanitize outbound provider request surrogates (#44880) 2026-08-25 09:53:47 -05:00
ed582d1bdb feat(core): run worktree setup scripts (#44455)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-25 22:45:01 +08:00
opencode-agent[bot]andBrendonovich db5a10dad1 fix(app): hide close buttons on cramped inactive tabs (#44995)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 22:35:41 +08:00
Aiden Cline ff59a22ff4 fix(ai): reconcile completed response output items (#44893) 2026-08-25 09:32:42 -05:00
Shoubhit Dash 9c1787617c feat(plugin): add vcs provider api (#44979) 2026-08-25 19:37:06 +05:30
opencode-agent[bot]andBrendonovich 7601ab9fc4 fix(desktop): hide sessions during removal (#44913)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 21:09:20 +08:00
4fb8a6038a feat(app): select worktree base branch (#44906)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-25 21:05:52 +08:00
opencode-agent[bot]andrekram1-node 5c25c38961 fix(tui): paste into active custom answers (#44849)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-25 18:19:28 +05:30
Brendan Allan e3aa13c7d0 fix(app): show pending states when switching servers (#44947) 2026-08-25 20:34:53 +08:00
opencode-agent[bot]andBrendan 5963a30621 fix(ui): restore stacked dialog focus (#44941)
Co-authored-by: Brendan <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 19:10:09 +08:00
190 changed files with 10139 additions and 747 deletions
+32 -1
View File
@@ -22,6 +22,36 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
affected:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
strategy:
@@ -133,7 +163,8 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
needs: affected
if: needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:
+1
View File
@@ -686,6 +686,7 @@
"version": "1.18.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-rIg+QSwF0kmPRnlPrpV7IlPTrHAxvoBOjdtoFOcUnKc=",
"aarch64-linux": "sha256-74rMnqiGy7gAUDWr9HWBc6Sn3kxh/hDfm5kp+I4fjok=",
"aarch64-darwin": "sha256-ImASKYxUQDHzH/UuwXUBsMNkitAH5wDYESlevEy9qeA=",
"x86_64-darwin": "sha256-txPhXfZOSoQdNXPfddKSdKqdjEWrb9GkBb38h+pZIuo="
"x86_64-linux": "sha256-3Jx1Q7hl+Y0Log/k2vd5y6dzBpzFKWlhShPESxn1Rm4=",
"aarch64-linux": "sha256-EiiI6g01oBIrExCMAUgT3w82P0fvu4FAJhI32C+ze0I=",
"aarch64-darwin": "sha256-s+w49HRp1+ewtiTaU65tPWjUiO1NQw3kzfemMEEQZb0=",
"x86_64-darwin": "sha256-/Ee5V7pnL/qm3c4ZHeWEjH7FhGVXArXryOugbG5vsz8="
}
}
+14 -2
View File
@@ -1,7 +1,8 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor.js"
import { mergeHttpOptions, type AIError } from "./schema/index.js"
import { sanitizeSurrogates } from "./utils/sanitize.js"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image.js"
import type { AIError } from "./schema/index.js"
export type Execute = RequestExecutor.Interface["execute"]
@@ -26,7 +27,18 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) => request.model.route.generate(request, executor.execute),
generate: (request) =>
request.model.route.generate(
{
...sanitizeSurrogates({
...request,
model: undefined,
http: mergeHttpOptions(request.model.http, request.http),
}),
model: request.model,
},
executor.execute,
),
})
}),
)
+5 -17
View File
@@ -212,11 +212,7 @@ const BedrockEvent = Schema.Struct({
metrics: Schema.optional(Schema.Unknown),
}),
),
internalServerException: Schema.optional(BedrockStreamException),
modelStreamErrorException: Schema.optional(BedrockStreamException),
validationException: Schema.optional(BedrockStreamException),
throttlingException: Schema.optional(BedrockStreamException),
serviceUnavailableException: Schema.optional(BedrockStreamException),
exception: Schema.optional(Schema.Struct({ type: Schema.String, details: BedrockStreamException })),
})
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
@@ -650,22 +646,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
] as const
}
const exception = (
[
["internalServerException", event.internalServerException],
["modelStreamErrorException", event.modelStreamErrorException],
["serviceUnavailableException", event.serviceUnavailableException],
["throttlingException", event.throttlingException],
["validationException", event.validationException],
] as const
).find((entry) => entry[1] !== undefined)
if (exception) {
if (event.exception) {
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
code: exception[0],
message:
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error",
code: event.exception.type,
}),
})
}
@@ -82,7 +82,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
"Failed to parse Bedrock Converse event-stream payload",
)) as Record<string, unknown>
delete parsed.p
out.push({ [eventType]: parsed })
out.push(messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed })
}
return [cursor, out] as const
})
@@ -154,7 +154,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
...observation,
checkpoint: {
protocol: PROTOCOL,
value: { version: VERSION, responseID, request, output: output.slice() } satisfies CheckpointValue,
value: {
version: VERSION,
responseID,
request,
output: event.response?.output ? [...event.response.output] : output.slice(),
} satisfies CheckpointValue,
},
}
}),
+48 -18
View File
@@ -288,6 +288,7 @@ export const Event = Schema.StructWithRest(
arguments: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -296,6 +297,7 @@ export const Event = Schema.StructWithRest(
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
}),
@@ -340,6 +342,7 @@ export interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly messageItems: ReadonlySet<string>
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
@@ -654,14 +657,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
}
// With store:false, Responses APIs only accept previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
return input
})
const lowerOptions = (request: LLMRequest) => {
@@ -817,6 +813,9 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
@@ -1111,30 +1110,49 @@ 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) => {
if (
!item.id ||
((item.type !== "function_call" || !current.tools[item.id]) &&
(item.type !== "reasoning" || !current.reasoningItems[item.id]))
)
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]
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(state.id, state.tools)
: { tools: state.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...pending.events]
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
current.hasFunctionCall
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
providerMetadata:
event.response?.id || event.response?.service_tier
? providerMetadata(state, {
? providerMetadata(current, {
responseId: event.response.id,
serviceTier: event.response.service_tier,
})
: undefined,
})
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
@@ -1181,7 +1199,11 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, event: Event) => {
export const step = (state: ParserState, input: Event) => {
const event =
input.item_id && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1223,7 +1245,14 @@ export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && event.item?.id
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
: state,
event,
),
)
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
return event.item_id
@@ -1258,6 +1287,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
messageItems: new Set<string>(),
messagePhases: {},
reasoningItems: {},
+12
View File
@@ -478,12 +478,22 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const requireAssistantAfterTool =
request.model.compatibility?.requireAssistantAfterTool ??
["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) =>
request.model.id.toLowerCase().includes(family),
)
const bridgeTools = () => {
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
}
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
if (pendingImages.length === 0) return
bridgeTools()
messages.push({ role: "user", content: pendingImages.splice(0) })
}
for (const message of request.messages) {
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
@@ -526,6 +536,8 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
)
continue
}
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
continue
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, options)
messages.push(...lowered.messages)
@@ -166,7 +166,9 @@ const HOSTED_TOOLS = {
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
+3 -1
View File
@@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
@@ -400,7 +401,8 @@ export function make<Body, Prepared, Frame, Event, State>(
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const original = applyCachePolicy(resolveRequestOptions(request))
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const route = resolved.model.route
const body = yield* route.body
+1
View File
@@ -155,6 +155,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
+12
View File
@@ -0,0 +1,12 @@
import { isRecord } from "./record.js"
export const sanitizeSurrogates = <T>(value: T): T => {
if (typeof value === "string") return value.toWellFormed() as T
if (Array.isArray(value)) return value.map(sanitizeSurrogates) as T
if (value instanceof Uint8Array || value instanceof Error) return value
if (isRecord(value))
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key.toWellFormed(), sanitizeSurrogates(entry)]),
) as T
return value
}
+68 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src/index.js"
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
@@ -247,6 +247,73 @@ describe("request option precedence", () => {
}),
)
it.effect("sanitizes outbound JSON without an HTTP overlay", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "hello \uD800 \u{1F600}",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [{ role: "user", content: "hello \uFFFD \u{1F600}" }],
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("sanitizes unpaired surrogates throughout outbound JSON", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
system: "system \uD800 \u{1F600}",
messages: [
Message.user("user \uDC00"),
Message.assistant([
Message.text("assistant \uD800"),
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "input \uDC00" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: { output: "result \uD800" } }),
],
http: { body: { metadata: { "key\uD800": ["overlay \uDC00", "valid \u{1F600}"] } } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [
{ role: "system", content: "system \uFFFD \u{1F600}" },
{ role: "user", content: "user \uFFFD" },
{
role: "assistant",
content: "assistant \uFFFD",
tool_calls: [{ function: { arguments: '{"query":"input \uFFFD"}' } }],
},
{ role: "tool", content: '{"output":"result \uFFFD"}' },
],
metadata: { "key\uFFFD": ["overlay \uFFFD", "valid \u{1F600}"] },
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
@@ -716,6 +716,32 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("ignores unknown normal stream events", () =>
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
eventFrame("futureEvent", { message: "Ignore this" }),
eventFrame("messageStop", { stopReason: "end_turn" }),
])
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("fails unknown stream exceptions after message stop", () =>
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
eventFrame("messageStop", { stopReason: "end_turn" }),
exceptionFrame("futureException", { message: "A future provider failure" }),
])
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "A future provider failure" })
}),
)
it.effect("classifies throttlingException as a rate limit", () =>
Effect.gen(function* () {
const body = concat([
@@ -85,6 +85,28 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits empty and whitespace-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Before."),
Message.assistant([]),
Message.assistant(""),
Message.assistant(" \n\t "),
Message.assistant("After."),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before." },
{ role: "assistant", content: "After." },
])
}),
)
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -431,6 +453,30 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("bridges image tool results before their synthetic user message when required", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }),
messages: [
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }],
},
}),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"])
expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -238,6 +238,47 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () =>
Effect.gen(function* () {
const cases = [
{ id: "mistral-small", bridge: true },
{ id: "devstral-small", bridge: true },
{ id: "codestral-latest", bridge: true },
{ id: "pixtral-large", bridge: true },
{ id: "open-mixtral-8x22b", bridge: true },
{ id: "ordinary-model", bridge: false },
{ id: "ordinary-model", override: true, bridge: true },
{ id: "mistral-small", override: false, bridge: false },
] as const
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const selected = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({
id: item.id,
compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined,
})
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
Message.user("What next?"),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(
item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"],
)
if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
}),
)
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -225,6 +225,105 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("routes response deltas by output index", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
])
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"par' },
{
type: "response.completed",
response: {
output: [
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"complete"}',
},
],
},
},
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "item_1" } },
})
}),
)
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
},
{ type: "response.reasoning_summary_text.delta", item_id: "rs_raw", delta: "Thinking" },
{
type: "response.completed",
response: {
output: [{ type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" }],
},
},
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
})
}),
)
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
Effect.gen(function* () {
const model = configure({
@@ -518,6 +518,56 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a tool call from authoritative completed response output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
],
},
}),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
const create = yield* second.create(saved)
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
)
it.effect("continues a promoted steer after the completed assistant output", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
@@ -766,6 +816,49 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("sanitizes outbound WebSocket requests and HTTP fallback bodies", () =>
Effect.gen(function* () {
const message = yield* Ref.make("")
const body = yield* Ref.make("")
yield* LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
prompt: "Say \uD800hello \u{1F600}.",
http: { body: { metadata: { source: "overlay\uDC00" } } },
}),
{
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
yield* exchange.driver
.create(undefined)
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.set(body, input.text)
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
const expected = {
input: [{ role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] }],
metadata: { source: "overlay\uFFFD" },
}
expect(JSON.parse(yield* Ref.get(message))).toMatchObject(expected)
expect(JSON.parse(yield* Ref.get(body))).toMatchObject(expected)
}),
)
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
@@ -1960,6 +2053,163 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes assistant text by output index when its item id disagrees", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Indexed")
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
)
it.effect("routes interleaved function calls by output index", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "first", arguments: "" }
const second = { type: "function_call", id: "fc_2", call_id: "call_2", name: "second", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: first },
{ type: "response.output_item.added", output_index: 3, item: second },
{ type: "response.function_call_arguments.delta", output_index: 1, item_id: "fc_2", delta: '{"a":' },
{ type: "response.function_call_arguments.delta", output_index: 3, item_id: "fc_1", delta: '{"b":' },
{
type: "response.function_call_arguments.done",
output_index: 3,
item_id: "fc_1",
arguments: '{"b":2}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_2",
arguments: '{"a":1}',
},
{ type: "response.output_item.done", output_index: 1, item: { ...first, arguments: '{"a":1}' } },
{ type: "response.output_item.done", output_index: 3, item: { ...second, arguments: '{"b":2}' } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"a":' },
{ id: "call_2", text: '{"b":' },
{ id: "call_2", text: "2}" },
{ id: "call_1", text: "1}" },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "first", input: { a: 1 } }),
expect.objectContaining({ id: "call_2", name: "second", input: { b: 2 } }),
])
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 4,
item: { type: "reasoning", id: "rs_1" },
},
{
type: "response.reasoning_summary_part.added",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
},
{
type: "response.reasoning_summary_text.delta",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Thinking",
},
{
type: "response.reasoning_summary_part.done",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
},
{
type: "response.output_item.done",
output_index: 4,
item: { type: "reasoning", id: "rs_1", encrypted_content: "state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Thinking")
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("routes native reasoning text deltas by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "wrong_reasoning", delta: "Raw" },
{ type: "response.output_item.done", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Raw")
}),
)
it.effect("falls back to item ids when an output index was not registered", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 9, item_id: "msg_1", delta: "Fallback" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Fallback")
}),
)
it.effect("rejects output text events without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -1979,6 +2229,25 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("requires item ids even when their output index is known", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 0, delta: "Missing item ID" },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.output_text.delta is missing item_id")
}),
)
it.effect("ignores deltas without a matching output item", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2194,6 +2463,147 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves terminal reasoning metadata when output item completion is missing", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_1",
summary_index: 0,
delta: "Checked the diff.",
},
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "reasoning",
id: "rs_1",
encrypted_content: "terminal-state",
summary: [{ type: "summary_text", text: "Checked the diff." }],
},
],
},
},
),
),
),
)
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" } },
},
])
expect(response.message.content).toContainEqual({
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
})
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",
},
])
}),
)
it.effect("does not repeat reasoning already finalized by an output item", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { output: [item] } },
),
),
),
)
expect(response.events.filter((event) => event.type === "reasoning-start")).toHaveLength(1)
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.reasoning).toBe("Thinking")
}),
)
it.effect("reconciles pending reasoning and function calls in completed output order", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"wea' },
{
type: "response.completed",
response: {
output: [
{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" },
{
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
],
},
},
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
})
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.finishReason.normalized).toBe("tool-calls")
}),
)
it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -2747,7 +3157,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
it.effect("replays stateless reasoning without encrypted state", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -2777,6 +3187,12 @@ describe("OpenAI Responses route", () => {
expect(prepared.body).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: null,
},
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
@@ -3051,6 +3467,163 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("treats empty completed output item arguments as authoritative", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
{
type: "response.output_item.done",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: {} })
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"wea' },
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
],
},
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
input: { query: "weather" },
providerMetadata: { openai: { itemId: "fc_item_1" } },
})
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{
type: "response.function_call_arguments.done",
item_id: "fc_item_1",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.completed",
response: {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"completed"}',
},
],
},
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "completed" } })
}),
)
it.effect("preserves explicit empty arguments from completed response output", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
{
type: "response.completed",
response: {
output: [{ type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }],
},
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: {} })
}),
)
it.effect("does not repeat function calls already finalized by an output item", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const body = sseEvents(
{ type: "response.output_item.added", item: { ...item, arguments: "" } },
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { output: [item] } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("does not finalize pending function calls from incomplete response output", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
}
const body = sseEvents(
{ type: "response.output_item.added", item: { ...item, arguments: "" } },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: item.arguments },
{
type: "response.incomplete",
response: { incomplete_details: { reason: "max_output_tokens" }, output: [item] },
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
expect(response.finishReason.normalized).toBe("length")
}),
)
it.effect("finalizes a pending function call at response completion", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -70,6 +70,42 @@ describe("xAI Responses route", () => {
}),
)
it.effect("routes xAI reasoning summaries by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1" },
},
{
type: "response.reasoning_summary_text.delta",
output_index: 3,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.reasoning).toBe("Considering.")
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
})
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
@@ -78,6 +78,31 @@ describe("Z.ai Images", () => {
),
)
it.effect("sanitizes unpaired surrogates in outbound image requests", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"),
prompt: "A red circle \uD800 on a white background \u{1F600}",
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toMatchObject({
prompt: "A red circle \uFFFD on a white background \u{1F600}",
metadata: { source: "default\uFFFD" },
})
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
),
)
it.effect("lets raw native options override aliases", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test" }).image("model"),
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_workspace_branch"
const directory = "C:/OpenCode/WorkspaceBranch"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("selects a base branch for a new workspace", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_workspace_branch",
worktree: directory,
vcs: "git",
name: "workspace-branch",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
await expect(selected).toBeVisible()
await selected.click()
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
})
@@ -68,6 +68,41 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
await page.setViewportSize({ width: 360, height: 720 })
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, sessionC }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
{ type: "session", server, sessionId: sessionC },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, sessionC: sessionC.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabB).toBeVisible()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.locator(`a[href="${hrefB}"]`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
})
function session(id: string, title: string) {
return {
id,
+1
View File
@@ -100,6 +100,7 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
+2
View File
@@ -21,6 +21,7 @@ export interface MockServerConfig {
cursor?: string
}
vcsDiff?: unknown[]
vcsBranches?: string[]
messageDelay?: number
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
@@ -296,6 +297,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
vcs: () =>
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsBranches: () => Effect.succeed({ location: location(config), data: config.vcsBranches ?? ["main"] }),
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
fsList: (ctx) =>
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
+2 -3
View File
@@ -3,7 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
const layout = useLayout()
@@ -49,8 +49,7 @@ export function createHomeController() {
selection: {
value: selection,
set: setSelection,
focusServer: (conn: ServerConnection.Any) =>
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
},
server: {
list: () => servers.visible,
+5 -17
View File
@@ -5,7 +5,6 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
@@ -43,7 +42,6 @@ export function createHomeSessionsController(home: HomeController) {
const dialog = useDialog()
const language = useLanguage()
const queryClient = useQueryClient()
const [removed, setRemoved] = createStore({ keys: [] as string[] })
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
if (!selected) return
@@ -70,10 +68,9 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
ctx.data.session.apply(
mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()),
),
HOME_SESSION_LIMIT,
Date.now(),
@@ -192,15 +189,9 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
return ctx.sdk.api.session
.remove({ sessionID: session.id })
return ctx.data.session
.remove(session.id)
.then(() => {
const removedIDs = new Set(ids)
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((item) => !removedIDs.has(item.id)),
)
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
directory: session.location.directory,
@@ -216,9 +207,6 @@ export function createHomeSessionsController(home: HomeController) {
return false
})
.finally(() => {
// Always refetch: the pre-mutation cancel may have aborted an
// in-flight index fetch, and a failed delete must not leave the
// index unloaded either.
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
})
}
@@ -256,7 +244,7 @@ export function createHomeSessionsController(home: HomeController) {
data: {
records,
groups,
loading: () => sessionLoad.isLoading,
loading: () => sessionLoad.isPending,
searchRecords: allRecords,
},
session: {
@@ -12,6 +12,7 @@ export function HomeSessions(props: {
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
+19 -19
View File
@@ -1,6 +1,6 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Key } from "@solid-primitives/keyed"
import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js"
import { createMemo, For, Index, onCleanup, Show } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -44,6 +44,7 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: HomeSessionGroup[]
loading: boolean
showProjectName: boolean
server: ServerConnection.Key
canCreateSession: boolean
@@ -97,22 +98,20 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</Show>
</Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
@@ -122,7 +121,8 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Suspense
<Show
when={!props.loading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
@@ -164,7 +164,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</Index>
</div>
</Show>
</Suspense>
</Show>
</div>
</section>
)
@@ -20,6 +20,7 @@ import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/
export function createNewSessionComposerAdapter(props: {
draftID: string
worktree: () => string
branch: () => string | undefined
submitted: () => void
}) {
const route = useSessionKey()
@@ -48,6 +49,7 @@ export function createNewSessionComposerAdapter(props: {
const sessionDirectory = await resolveSessionDirectory({
projectDirectory,
worktree,
branch: props.branch(),
data,
serverSDK,
language,
@@ -73,7 +75,7 @@ export function createNewSessionComposerAdapter(props: {
return { ok: false as const, error }
},
)
const afterCreation = async <T,>(run: () => Promise<T>) => {
const afterCreation = async <T>(run: () => Promise<T>) => {
const result = await creation
if (!result.ok) throw result.error
return run()
@@ -83,7 +85,7 @@ export function createNewSessionComposerAdapter(props: {
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined })
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,
@@ -161,6 +163,7 @@ function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["
async function resolveSessionDirectory(input: {
projectDirectory: string
worktree: string
branch?: string
data: ReturnType<typeof useData>
serverSDK: ReturnType<typeof useServerSDK>
language: ReturnType<typeof useLanguage>
@@ -172,6 +175,7 @@ async function resolveSessionDirectory(input: {
.create({
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
strategy: "git",
branch: input.branch,
directory: getDirectory(
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
),
@@ -39,6 +39,7 @@ export function createComposerProjectControls(props: { draftId: string }) {
server: ServerConnection.key(connection),
directory: worktree,
worktree: undefined,
branch: undefined,
})
}
const addProject = (title: string, serverKey?: string) => {
@@ -421,7 +421,7 @@ export function PromptProjectSelector(props: {
<span class="min-w-0 flex-1 truncate leading-5">{props.controller.labels.add()}</span>
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<Menu.SubContent class="max-h-[224px] min-w-[180px] overflow-y-auto rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For>
+7 -2
View File
@@ -21,15 +21,20 @@ export default function NewSessionPage(props: { draftId: string }) {
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const workspace = createNewSessionWorkspaceController({
selected: () => draftTab()?.worktree,
setSelected: (worktree) => {
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
setSelectedWorktree: (worktree) => {
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
},
setSelectedBranch: (branch) => {
if (search.draftId) tabs.updateDraft(search.draftId, { branch })
},
onViewAll: openWorkspaces,
})
const composer = createNewSessionComposerAdapter({
draftID: props.draftId,
worktree: workspace.selection.value,
branch: workspace.bar.branch,
submitted: workspace.selection.remember,
})
const model = createComposerModel(composer.adapter)
+3
View File
@@ -69,9 +69,12 @@ export function NewSessionView(props: {
value={props.workspace.selection.value()}
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branches={props.workspace.project.branches()}
branch={props.workspace.bar.branch()}
onboarding={onboardingReady() && !onboarding.used}
onChange={select}
onCreate={props.workspace.selection.create}
onSearch={props.workspace.project.searchBranches}
onDone={props.composer.restoreFocus}
onViewAll={props.workspace.project.openAll}
/>
@@ -65,6 +65,17 @@ describe("new session workspace selection", () => {
).toBe(undefined)
})
test("uses a selected branch for a new workspace", () => {
expect(
resolveNewSessionBranch({
worktree: "create",
directory: "/project/feature",
createBranch: "release",
worktreeBranch: () => "feature",
}),
).toBe("release")
})
test("uses location VCS state when the project inventory is stale", () => {
expect(resolveNewSessionGit({ branch: "dev" })).toBe(true)
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
@@ -1,4 +1,6 @@
import { createEffect, createMemo } from "solid-js"
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
@@ -32,8 +34,10 @@ export function normalizeNewSessionWorktree(value: string, directory: string, pr
export function resolveNewSessionBranch(input: {
worktree: string
directory: string
createBranch?: string
worktreeBranch: (worktree: string) => string | undefined
}) {
if (input.worktree === "create" && input.createBranch) return input.createBranch
const directory = input.worktree === "main" || input.worktree === "create" ? input.directory : input.worktree
return input.worktreeBranch(directory)
}
@@ -43,14 +47,18 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri
}
export function createNewSessionWorkspaceController(input: {
selected: () => string | undefined
setSelected: (worktree: string | undefined) => void
selectedWorktree: () => string | undefined
selectedBranch: () => string | undefined
setSelectedWorktree: (worktree: string | undefined) => void
setSelectedBranch: (branch: string | undefined) => void
onViewAll: () => void
}) {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const data = useData()
const settings = useSettings()
const [state, setState] = createStore({ search: "" })
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
const current = projectID ? data.project.get(projectID) : undefined
@@ -64,7 +72,7 @@ export function createNewSessionWorkspaceController(input: {
)
const selected = createMemo(() => {
const project = currentProject()
const worktree = input.selected()
const worktree = input.selectedWorktree()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
@@ -86,6 +94,14 @@ export function createNewSessionWorkspaceController(input: {
}),
)
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
const [branches] = createResource(
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs
.branches({ location: { directory }, search, limit: 50 })
.then((response) => ({ directory, search, data: response.data }))
.catch(() => ({ directory, search, data: [] })),
)
createEffect(() => {
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
() => undefined,
@@ -98,6 +114,7 @@ export function createNewSessionWorkspaceController(input: {
resolveNewSessionBranch({
worktree: value(),
directory: sdk().directory,
createBranch: input.selectedBranch(),
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
}),
)
@@ -116,10 +133,19 @@ export function createNewSessionWorkspaceController(input: {
const current = value()
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
}),
reset: () => input.setSelected(undefined),
reset: () => {
input.setSelectedWorktree(undefined)
input.setSelectedBranch(undefined)
},
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
input.setSelectedBranch(undefined)
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
},
create: (branch: string) => {
input.setSelectedBranch(branch)
input.setSelectedWorktree("create")
remember("create")
},
},
project: {
@@ -129,6 +155,15 @@ export function createNewSessionWorkspaceController(input: {
return project ? workspaceDirectories(project) : []
},
git: visible,
branches: () => {
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
const loaded = branches.latest
const list = loaded?.directory === projectRoot() ? loaded.data : []
return [
...new Set([...list, ...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : [])]),
].slice(0, 50)
},
searchBranches,
openAll: input.onViewAll,
},
bar: {
@@ -1,4 +1,5 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
@@ -10,20 +11,24 @@ export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
branches: string[]
branch?: string
onboarding?: boolean
onChange: (value: string) => void
onCreate: (branch: string) => void
onSearch: (search: string) => void
onDone: () => void
onViewAll: () => void
}) {
const language = useLanguage()
const [search, setSearch] = createSignal("")
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
const query = search().trim().toLowerCase()
const query = search.workspaces.trim().toLowerCase()
if (!query) return props.workspaces
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
})
@@ -37,12 +42,14 @@ export function PromptWorkspaceSelector(props: {
}
const onOpenChange = (open: boolean) => {
if (open) {
setSearch("")
setSearch({ workspaces: "", branches: "" })
props.onSearch("")
return
}
const action = pending
pending = undefined
if (action?.type === "select") props.onChange(action.value)
if (action?.type === "create") props.onCreate(action.branch)
if (action?.type === "viewAll") {
props.onViewAll()
return
@@ -120,21 +127,7 @@ export function PromptWorkspaceSelector(props: {
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<Tooltip
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
@@ -191,11 +184,11 @@ export function PromptWorkspaceSelector(props: {
ref={(element) => {
searchInput = element
}}
value={search()}
value={search.workspaces}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => setSearch(event.currentTarget.value)}
onInput={(event) => setSearch("workspaces", event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
@@ -232,7 +225,94 @@ export function PromptWorkspaceSelector(props: {
</Menu.Portal>
</Menu>
</Tooltip>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
<Show
when={selected() === "create" && props.branch}
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
>
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu
placement="bottom"
gutter={4}
onOpenChange={(open) => {
onOpenChange(open)
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
}}
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
branchSearchInput = element
}}
value={search.branches}
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => {
setSearch("branches", event.currentTarget.value)
props.onSearch(event.currentTarget.value)
}}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
<Show when={search.branches.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setSearch("branches", "")
props.onSearch("")
}}
aria-label={language.t("common.clear")}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<div class="max-h-[224px] overflow-y-auto">
<Menu.RadioGroup value={props.branch}>
<For each={props.branches}>
{(branch) => (
<Menu.RadioItem
value={branch}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
closeOnSelect
onSelect={() => (pending = { type: "create", branch })}
>
<span class="min-w-0 truncate leading-5">{branch}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</div>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</Show>
</>
)
}
+5 -6
View File
@@ -460,7 +460,8 @@ export const dict = {
"dialog.project.edit.color": "Color",
"dialog.project.edit.color.select": "Select {{color}} color",
"dialog.project.edit.worktree.startup": "Workspace startup script",
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
"dialog.project.edit.worktree.startup.description":
"Runs after creating a new workspace (worktree). Use $OPENCODE_WORKTREE_BASE for the base worktree and $OPENCODE_WORKTREE_PATH for the new worktree.",
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
"dialog.releaseNotes.action.getStarted": "Get started",
@@ -499,7 +500,7 @@ export const dict = {
"context.stats.lastActivity": "Last Activity",
"context.usage.tokens": "Tokens",
"context.usage.usage": "Usage",
"context.usage.usage": "Context Usage",
"context.usage.cost": "Cost",
"context.usage.clickToView": "Click to view context",
"context.usage.view": "View context usage",
@@ -1145,16 +1146,14 @@ export const dict = {
"session.delete.title": "Delete session",
"session.delete.confirm": 'Delete session "{{name}}"?',
"session.delete.button": "Delete session",
"session.locationUnavailable.title": "Working directory unavailable",
"session.locationUnavailable.description": "This session is read-only until you move it to another directory.",
"session.locationUnavailable.action": "Move session",
"session.locationUnavailable.pickerTitle": "Choose a new working directory",
"workspace.new": "New workspace",
"common.viewAll": "View all",
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.createFrom": "Create from branch",
"session.new.workspace.branch.search.placeholder": "Search branches",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search workspaces",
"settings.tab.workspaces": "Workspaces",
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createSessionMutations } from "./data"
const session = { id: "ses_test" } as SessionInfo
test("keeps a successful removal applied until its event arrives", async () => {
const release = Promise.withResolvers<void>()
const mutation = createSessionMutations(async () => release.promise)
const request = mutation.remove(session.id)
expect(mutation.apply([session])).toEqual([])
release.resolve()
await request
expect(mutation.apply([session])).toEqual([])
mutation.deleted(session.id)
expect(mutation.apply([session])).toEqual([session])
})
test("rolls back a failed removal", async () => {
const release = Promise.withResolvers<void>()
const mutation = createSessionMutations(async () => {
await release.promise
throw new Error("offline")
})
const request = mutation.remove(session.id)
expect(mutation.apply([session])).toEqual([])
release.resolve()
await expect(request).rejects.toThrow("offline")
expect(mutation.apply([session])).toEqual([session])
})
+51
View File
@@ -0,0 +1,51 @@
import type { Data } from "@opencode-ai/client/solid"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
type SessionMutation = { readonly id: string; readonly type: "remove"; readonly sessionID: string }
export function createDesktopData(input: { data: Data; remove: (sessionID: string) => Promise<void> }) {
const mutation = createSessionMutations(input.remove)
onCleanup(input.data.on("session.deleted", (event) => mutation.deleted(event.data.sessionID)))
return {
...input.data,
session: {
...input.data.session,
list: () => mutation.apply(input.data.session.list()),
apply: mutation.apply,
remove: mutation.remove,
},
}
}
export function createSessionMutations(remove: (sessionID: string) => Promise<void>) {
const [store, setStore] = createStore({ session: [] as SessionMutation[] })
const clear = (id: string) => {
setStore("session", (current) => current.filter((mutation) => mutation.id !== id))
}
return {
apply(sessions: readonly SessionInfo[]) {
const removed = new Set(
store.session.flatMap((mutation) => (mutation.type === "remove" ? [mutation.sessionID] : [])),
)
return removed.size === 0 ? [...sessions] : sessions.filter((session) => !removed.has(session.id))
},
remove(sessionID: string) {
const mutation = { id: crypto.randomUUID(), type: "remove" as const, sessionID }
setStore("session", (current) => [...current, mutation])
return Promise.resolve()
.then(() => remove(sessionID))
.catch((error) => {
clear(mutation.id)
throw error
})
},
deleted(sessionID: string) {
setStore("session", (current) => current.filter((mutation) => mutation.sessionID !== sessionID))
},
}
}
+6 -1
View File
@@ -11,6 +11,7 @@ import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -134,7 +135,7 @@ function createServerController(
) {
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const data = createData({
const source = createData({
api: () => sdk.api,
event: {
on: sdk.event.on,
@@ -143,6 +144,10 @@ function createServerController(
connection: sdk.connection,
directory: "",
})
const data = createDesktopData({
data: source,
remove: (sessionID) => sdk.api.session.remove({ sessionID }),
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
+2 -2
View File
@@ -66,11 +66,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
provider_auth: {},
get path() {
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
if (pathQuery.isLoading) return EMPTY
if (pathQuery.isPending) return EMPTY
return pathQuery.data ?? EMPTY
},
get config() {
if (configQuery.isLoading) return {}
if (configQuery.isPending) return {}
return configQuery.data ?? {}
},
get reload() {
@@ -1,54 +0,0 @@
import { expect, test } from "bun:test"
import { moveSessionLocation } from "./location-recovery"
test("moves an unavailable session to the selected directory", async () => {
const moving: boolean[] = []
const moved: string[] = []
const result = await moveSessionLocation({
selection: ["/repo/recovered"],
moving: false,
setMoving: (value) => moving.push(value),
move: async (directory) => moved.push(directory),
failed: () => undefined,
})
expect(result).toBe(true)
expect(moved).toEqual(["/repo/recovered"])
expect(moving).toEqual([true, false])
})
test("keeps the recovery action available after a failed move", async () => {
const moving: boolean[] = []
const errors: unknown[] = []
const error = new Error("unavailable")
const result = await moveSessionLocation({
selection: "/repo/missing",
moving: false,
setMoving: (value) => moving.push(value),
move: async () => {
throw error
},
failed: (cause) => errors.push(cause),
})
expect(result).toBe(false)
expect(errors).toEqual([error])
expect(moving).toEqual([true, false])
})
test("ignores cancelled and duplicate recovery attempts", async () => {
let moves = 0
const input = {
setMoving: () => undefined,
move: async () => {
moves++
},
failed: () => undefined,
}
expect(await moveSessionLocation({ ...input, selection: null, moving: false })).toBe(false)
expect(await moveSessionLocation({ ...input, selection: "/repo/next", moving: true })).toBe(false)
expect(moves).toBe(0)
})
@@ -1,20 +0,0 @@
export async function moveSessionLocation(input: {
selection: string | string[] | null
moving: boolean
setMoving: (moving: boolean) => void
move: (directory: string) => Promise<unknown>
failed: (error: unknown) => void
}) {
const directory = Array.isArray(input.selection) ? input.selection[0] : input.selection
if (!directory || input.moving) return false
input.setMoving(true)
return input
.move(directory)
.then(() => true)
.catch((error) => {
input.failed(error)
return false
})
.finally(() => input.setMoving(false))
}
@@ -1,69 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { showToast } from "@/shell/notifications/toast"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { moveSessionLocation } from "./location-recovery"
export function SessionLocationUnavailable(props: { sessionID: string }) {
const language = useLanguage()
const serverSDK = useServerSDK()
const pickDirectory = useDirectoryPicker()
const [store, setStore] = createStore({ moving: false })
const chooseDirectory = () => {
if (store.moving) return
pickDirectory({
server: serverSDK.server,
title: language.t("session.locationUnavailable.pickerTitle"),
onSelect: (result) => {
void moveSessionLocation({
selection: result,
moving: store.moving,
setMoving: (moving) => setStore("moving", moving),
move: (directory) => serverSDK.api.session.move({ sessionID: props.sessionID, directory }),
failed: (error) =>
showToast({
variant: "error",
title: language.t("workspace.move.failed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
}),
})
},
})
}
return (
<SessionLocationUnavailableView
title={language.t("session.locationUnavailable.title")}
description={language.t("session.locationUnavailable.description")}
action={language.t("session.locationUnavailable.action")}
moving={store.moving}
onMove={chooseDirectory}
/>
)
}
export function SessionLocationUnavailableView(props: {
title: string
description: string
action: string
moving: boolean
onMove: () => void
}) {
return (
<div
data-component="session-location-unavailable"
class="flex w-full items-center gap-3 rounded-[12px] border border-border-weak-base bg-background-base p-3"
>
<div class="min-w-0 flex-1">
<div class="text-14-medium text-text-strong">{props.title}</div>
<div class="text-13-regular text-text-weak">{props.description}</div>
</div>
<Button type="button" variant="outline" icon="folder" disabled={props.moving} onClick={props.onMove}>
{props.action}
</Button>
</div>
)
}
+7 -16
View File
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, on, onMount, Show } from "solid-js"
import { createEffect, on, onMount } from "solid-js"
import { Composer } from "@/composer/composer"
import { createComposerModel, type ComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
@@ -32,7 +32,6 @@ import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { useSettings } from "@/settings/model"
import { SessionLocationUnavailable } from "./location-unavailable"
export function createActiveSessionRegion(input: {
session: SessionModel
@@ -221,7 +220,6 @@ export function ActiveSessionComposerRegion(props: {
onResponseSubmit: () => void
}) {
const settings = useSettings()
const location = useWorkspaceLocation()
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
@@ -250,19 +248,12 @@ export function ActiveSessionComposerRegion(props: {
<SessionComposerRegion
controller={region}
composer={
<Show
when={location().error && !location().current}
fallback={
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
</div>
</div>
}
>
<SessionLocationUnavailable sessionID={requireSessionID(props.session)} />
</Show>
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
</div>
</div>
}
/>
)
@@ -168,15 +168,15 @@ export function createTimelineController(input: { session: TimelineSessionSource
const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const success = await serverSDK.api.session
.remove({ sessionID: id })
const removed = removedSessionIDs(data.session.list(), id)
const success = await data.session
.remove(id)
.then(() => true)
.catch((error) => {
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
return false
})
if (!success) return false
const removed = removedSessionIDs(data.session.list(), id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true
@@ -39,7 +39,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
<Button type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</DialogFooter>
@@ -1,7 +1,6 @@
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
@@ -9,7 +8,6 @@ import { type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global"
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
@@ -72,9 +70,14 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
// TODO: Restore project edits when the V2 client exposes a project update API.
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
throw new Error(`Project ${props.project.id} cannot be updated`)
await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
@@ -88,7 +91,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
function submit(event: SubmitEvent) {
event.preventDefault()
if (!supported || save.isPending) return
if (save.isPending) return
save.mutate()
}
@@ -98,7 +101,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
folderName,
defaultName,
save,
supported,
submit,
drop,
dragOver,
+12 -2
View File
@@ -31,9 +31,19 @@ export function migrateTabs(value: unknown): Tab[] {
tab.type === "draft" &&
typeof tab.draftID === "string" &&
typeof tab.directory === "string" &&
(tab.worktree === undefined || typeof tab.worktree === "string")
(tab.worktree === undefined || typeof tab.worktree === "string") &&
(tab.branch === undefined || typeof tab.branch === "string")
) {
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
return [
{
type: tab.type,
server,
draftID: tab.draftID,
directory: tab.directory,
worktree: tab.worktree,
branch: tab.branch,
},
]
}
return []
})
+1
View File
@@ -29,6 +29,7 @@ export type DraftTab = {
server: ServerConnection.Key
directory: string
worktree?: string
branch?: string
}
export type Tab = SessionTab | DraftTab
@@ -143,6 +143,10 @@
display: none;
}
[data-titlebar-tab]:not([data-active="true"]) [data-slot="tab-close"] {
display: none;
}
[data-slot="tab-close"] {
right: auto;
left: 50%;
+19 -1
View File
@@ -9,6 +9,7 @@ import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -78,6 +79,23 @@ const appAssetsPlugin: BunPlugin = {
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
platform: item.os,
arch: item.arch,
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
})
const opencodePtyPlugin: BunPlugin = {
name: "opencode-pty-binary",
setup(build) {
build.onLoad({ filter: /persistent-pty[/\\]pty-binding\.ts$/ }, () => ({
loader: "js",
contents: opencodePty
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
: "export default undefined",
}))
},
}
const simulationInputs = new Set<string>()
const simulationGraphPlugin: BunPlugin = {
name: "opencode-simulation-graph",
@@ -105,7 +123,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+7
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
import { collectFiles } from "./files"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
@@ -18,6 +19,11 @@ export type NodeAsset = {
}
export async function collectNodeAssets(target: NodeTarget) {
const opencodePty = await resolveOpencodePty({
platform: target.platform,
arch: target.arch,
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
})
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
const assets: NodeAsset[] = [
@@ -41,6 +47,7 @@ export async function collectNodeAssets(target: NodeTarget) {
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
...(await collectFiles(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
+102
View File
@@ -0,0 +1,102 @@
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const VERSION = "0.1.5"
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
const SHA256 = {
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
} as const
export type OpencodePtyAsset = {
readonly source: string
readonly version: string
readonly sha256: string
}
type Target = {
readonly platform: string
readonly arch: string
readonly libc?: "glibc" | "musl"
}
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
export function resolveOpencodePty(target: Target) {
const rustTarget = targetName(target)
if (!rustTarget) return Promise.resolve(undefined)
const existing = pending.get(rustTarget)
if (existing) return existing
const result = acquire(rustTarget).catch((error) => {
pending.delete(rustTarget)
throw error
})
pending.set(rustTarget, result)
return result
}
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
const executable = path.join(root, "opencode-pty")
const cached = await readFile(executable).catch(() => undefined)
if (cached)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(cached).digest("hex"),
}
await mkdir(root, { recursive: true })
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
const response = await fetch(`${RELEASE}/${archiveName}`)
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
const archive = new Uint8Array(await response.arrayBuffer())
const actual = createHash("sha256").update(archive).digest("hex")
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
try {
const archivePath = path.join(temporary, archiveName)
await writeFile(archivePath, archive)
run("tar", ["-xzf", archivePath, "-C", temporary])
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
const bytes = await readFile(source)
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
await rename(staged, executable).catch(async (error) => {
await rm(staged, { force: true })
if (!(await readFile(executable).catch(() => undefined))) throw error
})
const installed = await readFile(executable)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(installed).digest("hex"),
}
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function targetName(target: Target): keyof typeof SHA256 | undefined {
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
if (!arch) return undefined
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
if (target.platform === "linux" && target.libc === "musl")
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
return undefined
}
function run(command: string, args: readonly string[]) {
const result = spawnSync(command, args, { stdio: "inherit" })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}
@@ -3,10 +3,13 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () {
yield* Service.stop(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
}),
)
+2
View File
@@ -13,6 +13,7 @@ export function nodeTarget(platform: string, arch: string) {
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
return {
platform: targetPlatform,
@@ -25,6 +26,7 @@ export function nodeTarget(platform: string, arch: string) {
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
fffFfiPackage,
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
opencodePtyAsset,
}
}
@@ -62,6 +62,15 @@ function managedService(options: EnsureOptions) {
}
}
export const shutdownPersistentPty = Effect.fn("cli.server-connection.shutdown-persistent-pty")(function* (
options: EnsureOptions,
) {
const endpoint = yield* Service.discover({ ...options, version: undefined })
if (!endpoint) return
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* Effect.tryPromise(() => client.experimental.persistentPty.shutdown())
})
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
+1
View File
@@ -8,6 +8,7 @@ test("collects each SEA asset key once", async () => {
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
+6 -2
View File
@@ -120,6 +120,7 @@ function nodePrelude(input: NodeBuildInput) {
input.target.platform === "darwin"
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
: undefined
const opencodePtyAsset = input.target.opencodePtyAsset
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Agent = sdk.Agent
@@ -200,13 +201,17 @@ if (__ocIsSea()) {
const __ocAssetRoot = __ocIsSea()
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
if (__ocIsSea()) {
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
for (const __ocKey of __ocAssetKeys()) {
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
if (__ocExists(__ocTarget)) continue
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
if ((__ocKey === __ocPtySpawnHelper || __ocKey === __ocPersistentPty) && process.platform !== "win32")
__ocChmod(__ocTemporary, 0o755)
try {
__ocRename(__ocTemporary, __ocTarget)
} catch (__ocError) {
@@ -214,8 +219,6 @@ if (__ocIsSea()) {
if (!__ocExists(__ocTarget)) throw __ocError
}
}
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
}
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
@@ -227,6 +230,7 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
try {
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
} catch {}
+167
View File
@@ -1350,6 +1350,15 @@ export interface CredentialApi<E = never> {
export type ProjectListOutput = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly name?: string | undefined
readonly icon?: Project.Icon | undefined
readonly commands?: Project.Commands | undefined
}
export type ProjectUpdateOutput = Project.Info
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
export type ProjectCurrentInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1358,6 +1367,7 @@ export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) =
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly update: ProjectUpdateOperation<E>
readonly current: ProjectCurrentOperation<E>
}
@@ -1582,6 +1592,152 @@ export interface PtyApi<E = never> {
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
}
export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID }
export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
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 ExperimentalPersistentPtyListOperation<E = never> = (
input: ExperimentalPersistentPtyListInput,
) => Effect.Effect<ExperimentalPersistentPtyListOutput, E>
export type ExperimentalPersistentPtyCreateInput = {
readonly sessionID: Session.ID
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number } | undefined
}
export type ExperimentalPersistentPtyCreateOutput = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
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 ExperimentalPersistentPtyCreateOperation<E = never> = (
input: ExperimentalPersistentPtyCreateInput,
) => Effect.Effect<ExperimentalPersistentPtyCreateOutput, E>
export type ExperimentalPersistentPtyShutdownOutput = void
export type ExperimentalPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<
ExperimentalPersistentPtyShutdownOutput,
E
>
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtyGetOutput = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
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 ExperimentalPersistentPtyGetOperation<E = never> = (
input: ExperimentalPersistentPtyGetInput,
) => Effect.Effect<ExperimentalPersistentPtyGetOutput, E>
export type ExperimentalPersistentPtyUpdateInput = {
readonly ptyID: Pty.ID
readonly attachmentID?: string | undefined
readonly size: { readonly cols: number; readonly rows: number }
}
export type ExperimentalPersistentPtyUpdateOutput = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
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 ExperimentalPersistentPtyUpdateOperation<E = never> = (
input: ExperimentalPersistentPtyUpdateInput,
) => Effect.Effect<ExperimentalPersistentPtyUpdateOutput, E>
export type ExperimentalPersistentPtySnapshotInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtySnapshotOutput = {
readonly info: {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
readonly text: string
readonly checkpoint: globalThis.Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type ExperimentalPersistentPtySnapshotOperation<E = never> = (
input: ExperimentalPersistentPtySnapshotInput,
) => Effect.Effect<ExperimentalPersistentPtySnapshotOutput, E>
export type ExperimentalPersistentPtyRemoveInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtyRemoveOutput = void
export type ExperimentalPersistentPtyRemoveOperation<E = never> = (
input: ExperimentalPersistentPtyRemoveInput,
) => Effect.Effect<ExperimentalPersistentPtyRemoveOutput, E>
export type ExperimentalPersistentPtyConnectTokenInput = {
readonly ptyID: Pty.ID
readonly "x-opencode-ticket"?: string | undefined
}
export type ExperimentalPersistentPtyConnectTokenOutput = PtyTicket.ConnectToken
export type ExperimentalPersistentPtyConnectTokenOperation<E = never> = (
input: ExperimentalPersistentPtyConnectTokenInput,
) => Effect.Effect<ExperimentalPersistentPtyConnectTokenOutput, E>
export interface ExperimentalApi<E = never> {
readonly persistentPty: {
readonly list: ExperimentalPersistentPtyListOperation<E>
readonly create: ExperimentalPersistentPtyCreateOperation<E>
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
readonly get: ExperimentalPersistentPtyGetOperation<E>
readonly update: ExperimentalPersistentPtyUpdateOperation<E>
readonly snapshot: ExperimentalPersistentPtySnapshotOperation<E>
readonly remove: ExperimentalPersistentPtyRemoveOperation<E>
readonly connectToken: ExperimentalPersistentPtyConnectTokenOperation<E>
}
}
export type ShellListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1664,6 +1820,7 @@ export type WorktreeCreateInput = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly branch?: string | undefined
readonly directory: AbsolutePath
readonly name?: string | undefined
}
@@ -1720,6 +1877,14 @@ export type VcsStatusInput = {
export type VcsStatusOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: VcsStatusInput) => Effect.Effect<VcsStatusOutput, E>
export type VcsBranchesInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}
export type VcsBranchesOutput = { readonly location: Location.Info; readonly data: Vcs.BranchList }
export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effect.Effect<VcsBranchesOutput, E>
export type VcsDiffInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
@@ -1731,6 +1896,7 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly status: VcsStatusOperation<E>
readonly branches: VcsBranchesOperation<E>
readonly diff: VcsDiffOperation<E>
}
@@ -1822,6 +1988,7 @@ export interface AppApi<E = never> {
readonly skill: SkillApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly experimental: ExperimentalApi<E>
readonly shell: ShellApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
+138 -1
View File
@@ -141,6 +141,8 @@ import type {
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -192,6 +194,21 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ExperimentalPersistentPtyListInput,
ExperimentalPersistentPtyListOutput,
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
ExperimentalPersistentPtyUpdateOutput,
ExperimentalPersistentPtySnapshotInput,
ExperimentalPersistentPtySnapshotOutput,
ExperimentalPersistentPtyRemoveInput,
ExperimentalPersistentPtyRemoveOutput,
ExperimentalPersistentPtyConnectTokenInput,
ExperimentalPersistentPtyConnectTokenOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -222,6 +239,8 @@ import type {
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -932,6 +951,14 @@ const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
params: { projectID: input["projectID"] },
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
preserveEffect<ProjectCurrentOutput>()(
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -939,6 +966,7 @@ const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: Pr
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
list: EndpointProjectList(raw),
update: EndpointProjectUpdate(raw),
current: EndpointProjectCurrent(raw),
})
@@ -1179,6 +1207,100 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
connect: { token: EndpointPtyConnectToken(raw) },
})
const EndpointExperimentalPersistentPtyList =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyListInput) =>
preserveEffect<ExperimentalPersistentPtyListOutput>()(
raw["persistentPty.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyCreate =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyCreateInput) =>
preserveEffect<ExperimentalPersistentPtyCreateOutput>()(
raw["persistentPty.create"]({
params: { sessionID: input["sessionID"] },
payload: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyShutdown = (raw: RawClient["server.experimental"]) => () =>
preserveEffect<ExperimentalPersistentPtyShutdownOutput>()(
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyGet =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyGetInput) =>
preserveEffect<ExperimentalPersistentPtyGetOutput>()(
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyUpdate =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyUpdateInput) =>
preserveEffect<ExperimentalPersistentPtyUpdateOutput>()(
raw["persistentPty.update"]({
params: { ptyID: input["ptyID"] },
payload: { attachmentID: input["attachmentID"], size: input["size"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtySnapshot =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtySnapshotInput) =>
preserveEffect<ExperimentalPersistentPtySnapshotOutput>()(
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyRemove =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyRemoveInput) =>
preserveEffect<ExperimentalPersistentPtyRemoveOutput>()(
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyConnectToken =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyConnectTokenInput) =>
preserveEffect<ExperimentalPersistentPtyConnectTokenOutput>()(
raw["persistentPty.connectToken"]({
params: { ptyID: input["ptyID"] },
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
persistentPty: {
list: EndpointExperimentalPersistentPtyList(raw),
create: EndpointExperimentalPersistentPtyCreate(raw),
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
get: EndpointExperimentalPersistentPtyGet(raw),
update: EndpointExperimentalPersistentPtyUpdate(raw),
snapshot: EndpointExperimentalPersistentPtySnapshot(raw),
remove: EndpointExperimentalPersistentPtyRemove(raw),
connectToken: EndpointExperimentalPersistentPtyConnectToken(raw),
},
})
const EndpointShellList = (raw: RawClient["server.shell"]) => (input?: ShellListInput) =>
preserveEffect<ShellListOutput>()(
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1248,7 +1370,13 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: Wo
preserveEffect<WorktreeCreateOutput>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
payload: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1300,6 +1428,13 @@ const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusIn
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranchesInput) =>
preserveEffect<VcsBranchesOutput>()(
raw["vcs.branches"]({
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
preserveEffect<VcsDiffOutput>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
@@ -1310,6 +1445,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
get: EndpointVcsGet(raw),
status: EndpointVcsStatus(raw),
branches: EndpointVcsBranches(raw),
diff: EndpointVcsDiff(raw),
})
@@ -1377,6 +1513,7 @@ const adaptClient = (raw: RawClient) => ({
skill: adaptGroupSkill(raw["server.skill"]),
event: adaptGroupEvent(raw["server.event"]),
pty: adaptGroupPty(raw["server.pty"]),
experimental: adaptGroupExperimental(raw["server.experimental"]),
shell: adaptGroupShell(raw["server.shell"]),
reference: adaptGroupReference(raw["server.reference"]),
worktree: adaptGroupWorktree(raw["server.worktree"]),
+3
View File
@@ -4,15 +4,18 @@ export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type GenerateApi = Client["generate"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type PermissionApi = Client["permission"]
export type ProviderApi = Client["provider"]
export type ReferenceApi = Client["reference"]
export type WebSearchApi = Client["websearch"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
export type VcsApi = Client["vcs"]
export interface CatalogApi {
readonly provider: ProviderApi
@@ -135,6 +135,8 @@ import type {
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -188,6 +190,21 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ExperimentalPersistentPtyListInput,
ExperimentalPersistentPtyListOutput,
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
ExperimentalPersistentPtyUpdateOutput,
ExperimentalPersistentPtySnapshotInput,
ExperimentalPersistentPtySnapshotOutput,
ExperimentalPersistentPtyRemoveInput,
ExperimentalPersistentPtyRemoveOutput,
ExperimentalPersistentPtyConnectTokenInput,
ExperimentalPersistentPtyConnectTokenOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -218,6 +235,8 @@ import type {
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -1270,6 +1289,18 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
request<ProjectUpdateOutput>(
{
method: "PATCH",
path: `/api/project/${encodeURIComponent(input.projectID)}`,
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
request<ProjectCurrentOutput>(
{
@@ -1621,6 +1652,108 @@ export function make(options: ClientOptions) {
),
},
},
experimental: {
persistentPty: {
list: (input: ExperimentalPersistentPtyListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyListOutput }>(
{
method: "GET",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: ExperimentalPersistentPtyCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyCreateOutput }>(
{
method: "POST",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal`,
body: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
shutdown: (requestOptions?: RequestOptions) =>
request<ExperimentalPersistentPtyShutdownOutput>(
{
method: "POST",
path: `/api/experimental/persistent-pty/shutdown`,
successStatus: 204,
declaredStatuses: [503, 401, 400],
empty: true,
},
requestOptions,
),
get: (input: ExperimentalPersistentPtyGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyGetOutput }>(
{
method: "GET",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
update: (input: ExperimentalPersistentPtyUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyUpdateOutput }>(
{
method: "PUT",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
body: { attachmentID: input["attachmentID"], size: input["size"] },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
snapshot: (input: ExperimentalPersistentPtySnapshotInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtySnapshotOutput }>(
{
method: "GET",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/snapshot`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: ExperimentalPersistentPtyRemoveInput, requestOptions?: RequestOptions) =>
request<ExperimentalPersistentPtyRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
connectToken: (input: ExperimentalPersistentPtyConnectTokenInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyConnectTokenOutput }>(
{
method: "POST",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect-token`,
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
successStatus: 200,
declaredStatuses: [403, 404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
},
shell: {
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
request<ShellListOutput>(
@@ -1736,6 +1869,7 @@ export function make(options: ClientOptions) {
body: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
@@ -1819,6 +1953,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
branches: (input?: VcsBranchesInput, requestOptions?: RequestOptions) =>
request<VcsBranchesOutput>(
{
method: "GET",
path: `/api/vcs/branches`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
diff: (input: VcsDiffInput, requestOptions?: RequestOptions) =>
request<VcsDiffOutput>(
{
@@ -338,6 +338,21 @@ export type Pty = {
exitCode?: number
}
export type PersistentPtyInfo = {
id: string
title: string
command: string
args: Array<string>
cwd: string
status: "running" | "exited"
pid: number
exitCode?: number
sessionID: string
foregroundProcess: string | null
size: { cols: number; rows: number }
output: { head: number; tail: number }
}
export type FormMetadata1 = { [x: string]: any }
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
@@ -393,6 +408,8 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified"
}
export type VcsBranchList = Array<string>
export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
@@ -996,6 +1013,15 @@ export type PtyDeleted = {
data: { id: string }
}
export type PersistentPtyRemoved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "persistent-pty.removed"
location?: LocationRef
data: { sessionID: string; ptyID: string }
}
export type ShellExited = {
id: string
created: number
@@ -1286,6 +1312,7 @@ export type ModelCompatibility = {
reasoningField?: ModelReasoningField
maxTokensField?: ModelMaxTokensField
requireFinishReason?: boolean
requireAssistantAfterTool?: boolean
}
export type ModelCost = {
@@ -1391,6 +1418,7 @@ export type PermissionRequest = {
save?: Array<string>
metadata?: { [x: string]: JsonValue }
source?: PermissionSource
message?: string
}
export type PermissionAsked = {
@@ -1407,6 +1435,7 @@ export type PermissionAsked = {
save?: Array<string>
metadata?: { [x: string]: any }
source?: PermissionSource
message?: string
}
}
@@ -1437,6 +1466,22 @@ export type PtyUpdated = {
data: { info: Pty }
}
export type PersistentPtyAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "persistent-pty.added"
location?: LocationRef
data: { sessionID: string; terminal: PersistentPtyInfo }
}
export type PersistentPtySnapshot = {
info: PersistentPtyInfo
text: string
checkpoint: string
cursor: { x: number; y: number }
}
export type FormStringField1 = {
key: string
title?: string
@@ -2131,6 +2176,8 @@ export type V2Event =
| PtyUpdated
| PtyExited
| PtyDeleted
| PersistentPtyAdded
| PersistentPtyRemoved
| ShellCreated
| ShellExited
| ShellDeleted
@@ -2277,6 +2324,14 @@ export type McpServerNotFoundError = {
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
export type ProjectNotFoundError = {
readonly _tag: "ProjectNotFoundError"
readonly projectID: string
readonly message: string
}
export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError"
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
@@ -4308,6 +4363,27 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = Array<Project>
export type ProjectUpdateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly name?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["name"]
readonly icon?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["icon"]
readonly commands?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["commands"]
}
export type ProjectUpdateOutput = Project
export type ProjectCurrentInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -5469,6 +5545,99 @@ export type PtyConnectTokenOutput = {
data: PtyTicketConnectToken
}
export type ExperimentalPersistentPtyListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type ExperimentalPersistentPtyListOutput = { data: Array<PersistentPtyInfo> }["data"]
export type ExperimentalPersistentPtyCreateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly command: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["command"]
readonly args: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["args"]
readonly cwd: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["cwd"]
readonly title: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["title"]
readonly env: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["env"]
readonly size?: {
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number }
}["size"]
}
export type ExperimentalPersistentPtyCreateOutput = { data: PersistentPtyInfo }["data"]
export type ExperimentalPersistentPtyShutdownOutput = void
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ExperimentalPersistentPtyGetOutput = { data: PersistentPtyInfo }["data"]
export type ExperimentalPersistentPtyUpdateInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly attachmentID?: {
readonly attachmentID?: string
readonly size: { readonly cols: number; readonly rows: number }
}["attachmentID"]
readonly size: {
readonly attachmentID?: string
readonly size: { readonly cols: number; readonly rows: number }
}["size"]
}
export type ExperimentalPersistentPtyUpdateOutput = { data: PersistentPtyInfo }["data"]
export type ExperimentalPersistentPtySnapshotInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ExperimentalPersistentPtySnapshotOutput = { data: PersistentPtySnapshot }["data"]
export type ExperimentalPersistentPtyRemoveInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] }
export type ExperimentalPersistentPtyRemoveOutput = void
export type ExperimentalPersistentPtyConnectTokenInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly "x-opencode-ticket"?: { readonly "x-opencode-ticket"?: string | undefined }["x-opencode-ticket"]
}
export type ExperimentalPersistentPtyConnectTokenOutput = { data: PtyTicketConnectToken }["data"]
export type ShellListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -5593,24 +5762,35 @@ export type WorktreeCreateInput = {
readonly strategy: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["strategy"]
readonly from?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["from"]
readonly branch?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["branch"]
readonly directory: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["directory"]
readonly name?: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly name?: string
}["name"]
@@ -5663,6 +5843,29 @@ export type VcsStatusOutput = {
data: Array<VcsFileStatus>
}
export type VcsBranchesInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}["location"]
readonly search?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}["search"]
readonly limit?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}["limit"]
}
export type VcsBranchesOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: VcsBranchList
}
export type VcsDiffInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+25 -3
View File
@@ -27,7 +27,6 @@ test("exposes every standard HTTP API group", () => {
"event",
"pty",
"shell",
"question",
"reference",
"worktree",
"workspace",
@@ -47,11 +46,11 @@ 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", "diff"])
expect(Object.keys(client.vcs)).toEqual(["get", "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.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
@@ -82,6 +81,29 @@ 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("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
id: "proj_test",
canonical: "/tmp/project",
commands: { start: "bun install" },
time: { created: 1, updated: 2 },
sandboxes: [],
}
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json(project)
},
})
expect(await client.project.update({ projectID: "proj_test", commands: { start: "bun install" } })).toEqual(project)
expect(request?.method).toBe("PATCH")
expect(request?.url).toBe("http://localhost:3000/api/project/proj_test")
expect(await request?.json()).toEqual({ commands: { start: "bun install" } })
})
test("generate.text uses the locationless public contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
+6
View File
@@ -41,6 +41,12 @@
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
},
"#persistent-pty-binary": {
"workerd": "./src/persistent-pty/binary.workerd.ts",
"bun": "./src/persistent-pty/binary.bun.ts",
"node": "./src/persistent-pty/binary.node.ts",
"default": "./src/persistent-pty/binary.bun.ts"
},
"#fff": {
"workerd": "./src/filesystem/fff.workerd.ts",
"bun": "./src/filesystem/fff.bun.ts",
+3 -1
View File
@@ -109,6 +109,7 @@ export interface Interface {
readonly create: (input: {
repository: Repository
directory: AbsolutePath
ref?: string
}) => Effect.Effect<Repository, WorktreeError>
readonly remove: (input: {
repository: Repository
@@ -644,11 +645,12 @@ const layer = Layer.effect(
const worktreeCreate = Effect.fn("Git.worktree.create")(function* (input: {
repository: Repository
directory: AbsolutePath
ref?: string
}) {
yield* worktreeRun(
"create",
input.repository,
["worktree", "add", "--detach", input.directory, "HEAD"],
["worktree", "add", "--detach", "--", input.directory, input.ref ?? "HEAD"],
input.directory,
)
const repository = yield* discover(input.directory)
+49 -56
View File
@@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema.js"
import { SessionStore } from "./session/store.js"
import { Wildcard } from "./util/wildcard.js"
import { PermissionSaved } from "./permission/saved.js"
import { PluginHooks } from "./plugin/hooks.js"
const PermissionEffect = Permission.Effect
export { PermissionEffect as Effect }
@@ -70,9 +71,10 @@ export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission
rules: Permission.Ruleset,
permission: Schema.String,
resources: Schema.Array(Schema.String),
reason: Schema.String.pipe(Schema.optional),
}) {
override get message() {
return `Permission denied: ${this.permission}`
return this.reason ?? `Permission denied: ${this.permission}`
}
}
@@ -99,11 +101,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly allowsAll: (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) => Effect.Effect<boolean, SessionErrors.NotFoundError>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -128,6 +125,7 @@ const layer = Layer.effect(
const agents = yield* Agent.Service
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
@@ -159,24 +157,6 @@ const layer = Layer.effect(
return agent?.permissions ?? missingAgentPermissions
})
const allowsAll = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) {
const rules = yield* configured(input.sessionID, input.agent)
const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action))
for (let index = relevant.length - 1; index >= 0; index--) {
const rule = relevant[index]
if (rule.resource !== "*") {
if (rule.effect !== "allow") return false
continue
}
return rule.effect === "allow"
}
return false
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
@@ -191,10 +171,19 @@ const layer = Layer.effect(
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: input.sessionID,
agent: input.agent,
action: input.action,
resources: input.resources,
metadata: input.metadata,
source: input.source,
effect,
})
return { effect: event.effect, message: event.message, rules: all }
})
function request(input: AssertInput): Request {
function request(input: AssertInput, message?: string): Request {
return {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
@@ -203,6 +192,7 @@ const layer = Layer.effect(
save: input.save,
metadata: input.metadata,
source: input.source,
message,
}
}
@@ -223,39 +213,42 @@ const layer = Layer.effect(
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input)
const value = request(input, result.message)
if (result.effect === "ask") yield* create(value, input.agent)
return { id: value.id, effect: result.effect }
})
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
),
Effect.gen(function* () {
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
reason: result.message,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input, result.message), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
)
}),
)
const reply = Effect.fn("Permission.reply")((input: ReplyInput) =>
@@ -337,12 +330,12 @@ const layer = Layer.effect(
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ allowsAll, ask, assert, reply, get, forSession, list })
return Service.of({ ask, assert, reply, get, forSession, list })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node],
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node],
})
+1
View File
@@ -0,0 +1 @@
export { PersistentPty } from "./persistent-pty/index.js"
@@ -0,0 +1,82 @@
import { createHash } from "node:crypto"
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"
import path from "node:path"
import asset from "./pty-binding.js"
export async function resolveBinary(bin: string) {
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
if (!asset) return "opencode-pty"
return install(bin, asset)
}
async function install(
bin: string,
input: { readonly path: string; readonly version: string; readonly sha256: string },
) {
const root = path.join(bin, "opencode-pty")
await privateDirectory(root)
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
await privateDirectory(directory)
const destination = path.join(directory, "opencode-pty")
if (await exists(destination, input.sha256)) return destination
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
const temporary = path.join(directory, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
try {
const file = await open(temporary, "wx", 0o700)
try {
await file.writeFile(bytes)
await file.sync()
} finally {
await file.close()
}
await chmod(temporary, 0o755)
await rename(temporary, destination).catch(async (error) => {
if (!(await exists(destination, input.sha256))) throw error
})
} finally {
await rm(temporary, { force: true })
}
return validate(destination, input.sha256)
}
async function privateDirectory(directory: string) {
await mkdir(directory, { recursive: true, mode: 0o700 })
const info = await lstat(directory)
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty directory: ${directory}`)
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
if (uid !== undefined && info.uid !== uid)
throw new Error(`opencode-pty directory is owned by another user: ${directory}`)
await chmod(directory, 0o700)
}
async function exists(file: string, expected: string) {
try {
await validate(file, expected)
return true
} catch (error) {
if (isMissing(error)) return false
throw error
}
}
async function validate(file: string, expected?: string) {
const info = await lstat(file)
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
if (uid !== undefined && info.uid !== uid)
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
if (expected && sha256(await readFile(file)) !== expected)
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
await chmod(file, 0o755)
return file
}
function sha256(bytes: Uint8Array) {
return createHash("sha256").update(bytes).digest("hex")
}
function isMissing(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === "ENOENT"
}
@@ -0,0 +1,3 @@
export async function resolveBinary() {
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
}
@@ -0,0 +1,3 @@
export async function resolveBinary(): Promise<string> {
throw new Error("Persistent PTYs are unavailable in this runtime")
}
+535
View File
@@ -0,0 +1,535 @@
import { spawn } from "node:child_process"
import { readFile } from "node:fs/promises"
import net from "node:net"
import path from "node:path"
import { Data, Duration, Effect, Schema, Semaphore } from "effect"
const ProtocolVersion = 6
const MaxFrameBytes = 8 * 1024 * 1024
const Lifecycle = Schema.Union([
Schema.Struct({ status: Schema.Literal("running") }),
Schema.Struct({ status: Schema.Literal("exited"), exit_code: Schema.NullOr(Schema.Number) }),
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String }),
])
export const WireTerminal = Schema.Struct({
id: Schema.Number,
pid: Schema.NullOr(Schema.Number),
title: Schema.String,
foreground_process: Schema.NullOr(Schema.String),
group_id: Schema.String,
command: Schema.Array(Schema.String),
cwd: Schema.String,
cols: Schema.Number,
rows: Schema.Number,
lifecycle: Lifecycle,
output_head: Schema.Number,
output_tail: Schema.Number,
})
export type WireTerminal = typeof WireTerminal.Type
const Registration = Schema.Struct({
instance_id: Schema.String,
pid: Schema.Number,
protocol: Schema.Number,
socket: Schema.String,
token: Schema.String,
})
type Registration = typeof Registration.Type
export const WireResponse = Schema.Union([
Schema.Struct({
type: Schema.Literal("pong"),
instance_id: Schema.String,
pid: Schema.Number,
protocol: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("created"), terminal: WireTerminal }),
Schema.Struct({ type: Schema.Literal("terminals"), terminals: Schema.Array(WireTerminal) }),
Schema.Struct({ type: Schema.Literal("ok") }),
Schema.Struct({
type: Schema.Literal("snapshot"),
terminal: WireTerminal,
text: Schema.String,
checkpoint_base64: Schema.String,
cursor_x: Schema.Number,
cursor_y: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("attached"),
terminal: WireTerminal,
role: Schema.Literals(["controller", "observer"]),
generation: Schema.Number,
requested_offset: Schema.Number,
available_offset: Schema.Number,
end_offset: Schema.Number,
truncated: Schema.Boolean,
replay_base64: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("resized"),
cols: Schema.Number,
rows: Schema.Number,
generation: Schema.Number,
checkpoint_base64: Schema.String,
}),
Schema.Struct({
type: Schema.Literal("exited"),
exit_code: Schema.NullOr(Schema.Number),
final_offset: Schema.Number,
}),
Schema.Struct({
type: Schema.Literal("controller_changed"),
attachment_id: Schema.NullOr(Schema.String),
generation: Schema.Number,
}),
Schema.Struct({ type: Schema.Literal("title_changed"), title: Schema.String }),
Schema.Struct({ type: Schema.Literal("foreground_process_changed"), process: Schema.NullOr(Schema.String) }),
Schema.Struct({ type: Schema.Literal("error"), message: Schema.String }),
])
export type WireResponse = typeof WireResponse.Type
export type Role = "controller" | "observer"
export type StreamEvent =
| { readonly type: "output"; readonly start: number; readonly end: number; readonly data: Uint8Array }
| {
readonly type: "resized"
readonly cols: number
readonly rows: number
readonly generation: number
readonly checkpoint: Uint8Array
}
| { readonly type: "exited"; readonly exitCode?: number; readonly finalOffset: number }
| { readonly type: "controller_changed"; readonly attachmentID?: string; readonly generation: number }
| { readonly type: "title_changed"; readonly title: string }
| { readonly type: "foreground_process_changed"; readonly process: string | null }
export type DaemonAttachment = {
readonly terminal: WireTerminal
readonly role: Role
readonly generation: number
readonly replay: {
readonly requestedOffset: number
readonly availableOffset: number
readonly endOffset: number
readonly truncated: boolean
readonly data: Uint8Array
}
readonly activate: () => void
readonly detach: () => void
}
export class DaemonError extends Data.TaggedError("PersistentPty.DaemonError")<{
readonly kind: "connect" | "response" | "registration" | "protocol" | "spawn"
readonly message: string
readonly pid?: number
}> {}
export interface DaemonTransport {
readonly request: (value: object, start?: boolean) => Effect.Effect<WireResponse, DaemonError>
readonly requestIfRunning: (value: object) => Effect.Effect<WireResponse | undefined, DaemonError>
readonly shutdown: Effect.Effect<WireResponse | undefined, DaemonError>
readonly subscribe: (
id: number,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) => Effect.Effect<DaemonAttachment, DaemonError>
}
export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport")(function* (
directory: string,
binary: () => Promise<string> = () => Promise.resolve(process.env.OPENCODE_PTY_BIN || "opencode-pty"),
) {
const startup = Semaphore.makeUnsafe(1)
let registration: Registration | undefined
const discover = Effect.fn("PersistentPty.daemon.discover")(function* () {
const value = yield* Effect.tryPromise({
try: () => readFile(path.join(directory, "service.json"), "utf8"),
catch: (cause) => failure("connect", cause),
})
const decoded = yield* Effect.try({
try: () => Schema.decodeUnknownSync(Registration)(JSON.parse(value)),
catch: (cause) => failure("protocol", cause),
})
if (decoded.protocol !== ProtocolVersion)
return yield* Effect.fail(
new DaemonError({
kind: "protocol",
message: `opencode-pty protocol mismatch: daemon=${decoded.protocol}, client=${ProtocolVersion}`,
pid: decoded.pid,
}),
)
const response = yield* oneShot(decoded, { op: "ping" })
if (
response.type !== "pong" ||
response.instance_id !== decoded.instance_id ||
response.pid !== decoded.pid ||
response.protocol !== ProtocolVersion
)
return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "opencode-pty registration mismatch" }))
return decoded
})
const start = Effect.fn("PersistentPty.daemon.start")(function* () {
const executable = yield* Effect.tryPromise({ try: binary, catch: (cause) => failure("spawn", cause) })
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn(executable, ["daemon"], {
detached: true,
stdio: "ignore",
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: directory },
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
}),
catch: (cause) => failure("spawn", cause),
})
const deadline = Date.now() + 5_000
let last: DaemonError | undefined
while (Date.now() < deadline) {
const found = yield* discover().pipe(
Effect.map((value) => ({ value })),
Effect.catch((error) => {
last = error
return Effect.succeed(undefined)
}),
)
if (found) return found.value
yield* Effect.sleep(50)
}
return yield* Effect.fail(
last ?? new DaemonError({ kind: "connect", message: "opencode-pty did not become ready" }),
)
})
const connect = Effect.fn("PersistentPty.daemon.connect")(function* (shouldStart: boolean) {
if (registration) return registration
return yield* startup.withPermit(
Effect.gen(function* () {
if (registration) return registration
const found = yield* discover().pipe(
Effect.catch((error) => {
if (!shouldStart) return Effect.fail(error)
if (error.kind === "connect") return start()
if (error.kind !== "protocol" || error.pid === undefined) return Effect.fail(error)
return terminate(error.pid).pipe(Effect.andThen(start()))
}),
)
registration = found
return found
}),
)
})
const attempt = Effect.fn("PersistentPty.daemon.request-attempt")(function* (value: object, shouldStart: boolean) {
const current = yield* connect(shouldStart)
return yield* oneShot(current, value).pipe(
Effect.catch((error) => {
if (error.kind === "registration" && registration === current) registration = undefined
return Effect.fail(error)
}),
)
})
const request = Effect.fn("PersistentPty.daemon.request")(function* (value: object, shouldStart = false) {
return yield* attempt(value, shouldStart).pipe(
Effect.catch((error) => {
if (error.kind === "registration") return attempt(value, shouldStart)
if (error.kind !== "connect") return Effect.fail(error)
registration = undefined
if (!shouldStart) return Effect.fail(error)
return attempt(value, true)
}),
)
})
const requestIfRunning = (value: object) =>
request(value).pipe(
Effect.catch((error) => (error.kind === "connect" ? Effect.succeed(undefined) : Effect.fail(error))),
)
const shutdown = Effect.gen(function* () {
const response = yield* requestIfRunning({ op: "shutdown" })
registration = undefined
if (!response) return undefined
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
const running = yield* discover().pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!running) return response
yield* Effect.sleep(50)
}
return yield* Effect.fail(new DaemonError({ kind: "connect", message: "opencode-pty did not stop" }))
})
const subscribe = Effect.fn("PersistentPty.daemon.subscribe")(function* (
id: number,
input: Parameters<DaemonTransport["subscribe"]>[1],
) {
const attempt = Effect.gen(function* () {
const current = yield* connect(false)
return yield* Effect.tryPromise({
try: () => subscribePromise(current, id, input),
catch: (cause) => (cause instanceof DaemonError ? cause : failure("connect", cause)),
}).pipe(
Effect.catch((error) => {
if (error.kind === "registration" && registration === current) registration = undefined
return Effect.fail(error)
}),
)
})
return yield* attempt.pipe(Effect.catch((error) => (error.kind === "registration" ? attempt : Effect.fail(error))))
})
return { request, requestIfRunning, shutdown, subscribe } satisfies DaemonTransport
})
const oneShot = Effect.fn("PersistentPty.daemon.oneShot")(function* (registration: Registration, request: object) {
const payload = yield* Effect.try({
try: () => encode({ token: registration.token, request }),
catch: (cause) => failure("protocol", cause),
})
let dispatched = false
return yield* Effect.acquireUseRelease(
Effect.tryPromise({
try: (signal) =>
new Promise<net.Socket>((resolve, reject) => {
const socket = net.createConnection({ path: registration.socket, signal })
socket.once("connect", () => resolve(socket))
socket.once("error", reject)
}),
catch: (cause) => failure("connect", cause),
}),
(socket) =>
Effect.gen(function* () {
yield* Effect.try({
try: () => {
dispatched = true
socket.write(payload)
},
catch: (cause) => failure("response", cause),
})
const first = yield* Effect.tryPromise({
try: async (signal) => {
const frames = decoder(socket)
const abort = () => socket.destroy()
signal.addEventListener("abort", abort, { once: true })
try {
const frame = await frames.next()
if (frame.done) throw new Error("opencode-pty closed without response")
return frame.value
} finally {
signal.removeEventListener("abort", abort)
}
},
catch: (cause) => failure("response", cause),
})
const response = yield* Effect.try({
try: () => decode(first),
catch: (cause) => failure("protocol", cause),
})
if (response.type === "error")
return yield* Effect.fail(
new DaemonError({
kind: response.message === "authentication failed" ? "registration" : "protocol",
message: response.message,
}),
)
return response
}),
(socket) => Effect.sync(() => socket.destroy()),
).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(5),
orElse: () =>
Effect.fail(
new DaemonError({
kind: dispatched ? "response" : "connect",
message: "opencode-pty request timed out",
}),
),
}),
)
})
async function subscribePromise(
registration: Registration,
id: number,
input: Parameters<DaemonTransport["subscribe"]>[1],
): Promise<DaemonAttachment> {
const socket = net.createConnection(registration.socket)
const frames = decoder(socket)
try {
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve)
socket.once("error", reject)
})
socket.write(
encode({
token: registration.token,
request: {
op: "subscribe",
id,
offset: input.cursor,
attachment_id: input.attachmentID,
role: input.role,
takeover: input.takeover ?? false,
},
}),
)
const initial = await frames.next()
if (initial.done) throw new Error("opencode-pty closed before attachment")
const response = decode(initial.value)
if (response.type === "error")
throw new DaemonError({
kind: response.message === "authentication failed" ? "registration" : "protocol",
message: response.message,
})
if (response.type !== "attached") throw new Error(`unexpected opencode-pty response: ${response.type}`)
let detached = false
const pump = async () => {
try {
for await (const frame of frames) {
if (frame[0] === 0) {
if (frame.length < 17) throw new Error("invalid opencode-pty output frame")
input.onEvent({
type: "output",
start: Number(frame.readBigUInt64BE(1)),
end: Number(frame.readBigUInt64BE(9)),
data: frame.subarray(17),
})
continue
}
const event = decode(frame)
if (event.type === "resized")
input.onEvent({
type: "resized",
cols: event.cols,
rows: event.rows,
generation: event.generation,
checkpoint: Buffer.from(event.checkpoint_base64, "base64"),
})
if (event.type === "controller_changed")
input.onEvent({
type: "controller_changed",
attachmentID: event.attachment_id ?? undefined,
generation: event.generation,
})
if (event.type === "title_changed") input.onEvent({ type: "title_changed", title: event.title })
if (event.type === "foreground_process_changed")
input.onEvent({ type: "foreground_process_changed", process: event.process })
if (event.type === "exited") {
input.onEvent({
type: "exited",
exitCode: event.exit_code ?? undefined,
finalOffset: event.final_offset,
})
return
}
}
} finally {
if (!detached) input.onEnd()
}
}
let activated = false
return {
terminal: response.terminal,
role: response.role,
generation: response.generation,
replay: {
requestedOffset: response.requested_offset,
availableOffset: response.available_offset,
endOffset: response.end_offset,
truncated: response.truncated,
data: Buffer.from(response.replay_base64, "base64"),
},
activate() {
if (activated || detached) return
activated = true
void pump().catch(() => {})
},
detach() {
if (detached) return
detached = true
socket.destroy()
},
}
} catch (error) {
socket.destroy()
throw error
}
}
function encode(value: unknown) {
const payload = Buffer.from(JSON.stringify(value))
if (payload.length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
const output = Buffer.allocUnsafe(payload.length + 4)
output.writeUInt32BE(payload.length)
payload.copy(output, 4)
return output
}
async function* decoder(socket: net.Socket) {
let pending = Buffer.alloc(0)
for await (const value of socket) {
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk])
while (pending.length >= 4) {
const length = pending.readUInt32BE(0)
if (length > MaxFrameBytes) throw new Error("opencode-pty frame too large")
if (pending.length < length + 4) break
yield pending.subarray(4, length + 4)
pending = pending.subarray(length + 4)
}
}
if (pending.length !== 0) throw new Error("opencode-pty truncated frame")
}
function decode(payload: Uint8Array) {
return Schema.decodeUnknownSync(WireResponse)(JSON.parse(Buffer.from(payload).toString("utf8")))
}
function failure(kind: DaemonError["kind"], cause: unknown) {
return new DaemonError({ kind, message: cause instanceof Error ? cause.message : String(cause) })
}
const terminate = Effect.fn("PersistentPty.daemon.terminate-incompatible")(function* (pid: number) {
yield* Effect.logWarning("replacing incompatible opencode-pty daemon", { pid })
yield* Effect.try({ try: () => process.kill(pid, "SIGTERM"), catch: (cause) => failure("spawn", cause) }).pipe(
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
)
const deadline = Date.now() + 2_000
while (Date.now() < deadline && processRunning(pid)) yield* Effect.sleep(25)
if (!processRunning(pid)) return
yield* Effect.try({ try: () => process.kill(pid, "SIGKILL"), catch: (cause) => failure("spawn", cause) }).pipe(
Effect.catch((error) => (isMissingProcess(error) ? Effect.void : Effect.fail(error))),
)
})
function processRunning(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function isMissingProcess(error: DaemonError) {
return error.message.includes("ESRCH") || error.message.includes("no such process")
}
+397
View File
@@ -0,0 +1,397 @@
export * as PersistentPty from "./index.js"
import { createHash } from "node:crypto"
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, Removed } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Pty } from "@opencode-ai/schema/pty"
import { Global } from "@opencode-ai/util/global"
import {
makeDaemonTransport,
type DaemonTransport,
type Role,
type StreamEvent,
type WireResponse,
type WireTerminal,
} from "./daemon.js"
import { resolveBinary } from "#persistent-pty-binary"
export type { Role, StreamEvent } from "./daemon.js"
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 Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type Attachment = {
readonly info: Info
readonly role: Role
readonly generation: number
readonly replay: {
readonly requestedOffset: number
readonly availableOffset: number
readonly endOffset: number
readonly truncated: boolean
readonly data: Uint8Array
}
readonly activate: () => void
readonly detach: () => void
}
export class UnavailableError extends Schema.TaggedError<UnavailableError>()("PersistentPty.UnavailableError", {
message: Schema.String,
}) {}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("PersistentPty.NotFoundError", {
ptyID: Pty.ID,
}) {}
export interface Interface {
readonly list: (sessionID?: Session.ID) => Effect.Effect<Info[], UnavailableError>
readonly get: (id: Pty.ID) => Effect.Effect<Info, NotFoundError | UnavailableError>
readonly create: (
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
},
) => Effect.Effect<Info, UnavailableError>
readonly write: (
id: Pty.ID,
data: string,
attachmentID?: string,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly resize: (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly control: (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly input: (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly snapshot: (id: Pty.ID) => Effect.Effect<Snapshot, NotFoundError | UnavailableError>
readonly remove: (id: Pty.ID) => Effect.Effect<void, NotFoundError | UnavailableError>
readonly shutdown: () => Effect.Effect<void, UnavailableError>
readonly attach: (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) => Effect.Effect<Attachment, NotFoundError | UnavailableError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const database = yield* Database.Service
const global = yield* Global.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
let binary: Promise<string> | undefined
const daemon = yield* makeDaemonTransport(
runtimeDirectory(databasePath(database.db)),
() =>
(binary ??= resolveBinary(global.bin).catch((error) => {
binary = undefined
throw error
})),
)
const removing = new Set<Pty.ID>()
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
const response = yield* optionalRequest(daemon, { op: "list" })
if (!response) return []
if (response.type !== "terminals") return yield* unexpected(response)
return response.terminals
.map(toInfo)
.filter((terminal) => sessionID === undefined || terminal.sessionID === sessionID)
})
const get = Effect.fn("PersistentPty.get")(function* (id: Pty.ID) {
const found = (yield* list()).find((terminal) => terminal.id === id)
if (!found) return yield* new NotFoundError({ ptyID: id })
return found
})
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
},
) {
const response = yield* request(
daemon,
{
op: "create",
program: input.command,
args: input.args,
cwd: input.cwd,
title: input.title,
group_id: sessionID,
env: input.env,
cols: input.cols ?? 80,
rows: input.rows ?? 24,
},
true,
)
if (response.type !== "created") return yield* unexpected(response)
const terminal = toInfo(response.terminal)
yield* bus.publish(Added, { sessionID, terminal })
return terminal
})
const write = Effect.fn("PersistentPty.write")(function* (id: Pty.ID, data: string, attachmentID?: string) {
yield* get(id)
const response = yield* request(daemon, {
op: "write",
id: fromID(id),
attachment_id: attachmentID ?? null,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const resize = Effect.fn("PersistentPty.resize")(function* (
id: Pty.ID,
cols: number,
rows: number,
attachmentID?: string,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "resize",
id: fromID(id),
attachment_id: attachmentID ?? null,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const control = Effect.fn("PersistentPty.control")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "control",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const input = Effect.fn("PersistentPty.input")(function* (
id: Pty.ID,
attachmentID: string,
cols: number,
rows: number,
data: Uint8Array,
) {
yield* get(id)
const response = yield* request(daemon, {
op: "input",
id: fromID(id),
attachment_id: attachmentID,
cols,
rows,
data_base64: Buffer.from(data).toString("base64"),
})
if (response.type !== "ok") return yield* unexpected(response)
return undefined
})
const snapshot = Effect.fn("PersistentPty.snapshot")(function* (id: Pty.ID) {
yield* get(id)
const response = yield* request(daemon, { op: "snapshot", id: fromID(id) })
if (response.type !== "snapshot") return yield* unexpected(response)
return {
info: toInfo(response.terminal),
text: response.text,
checkpoint: Buffer.from(response.checkpoint_base64, "base64"),
cursor: { x: response.cursor_x, y: response.cursor_y },
}
})
const remove = Effect.fn("PersistentPty.remove")(function* (id: Pty.ID) {
const terminal = yield* get(id)
const response = yield* request(daemon, { op: "terminate", id: fromID(id) })
if (response.type !== "ok") return yield* unexpected(response)
yield* bus.publish(Removed, { sessionID: terminal.sessionID, ptyID: id })
return undefined
})
const shutdown = Effect.fn("PersistentPty.shutdown")(function* () {
const response = yield* daemon.shutdown.pipe(Effect.mapError(unavailable))
if (!response) return
if (response.type !== "ok") return yield* unexpected(response)
})
const removeVisibleExit = (id: Pty.ID) => {
if (removing.has(id)) return
removing.add(id)
runFork(
remove(id).pipe(
Effect.catchTags({
"PersistentPty.NotFoundError": () => Effect.void,
"PersistentPty.UnavailableError": (error) =>
Effect.logWarning("failed to remove visible exited terminal", { id, error: error.message }),
}),
Effect.ensuring(Effect.sync(() => removing.delete(id))),
),
)
}
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
},
) {
yield* get(id)
const attachment = yield* daemon
.subscribe(fromID(id), {
...input,
onEvent: (event) => {
if (event.type === "exited") removeVisibleExit(id)
input.onEvent(event)
},
})
.pipe(Effect.mapError(unavailable))
return {
info: toInfo(attachment.terminal),
role: attachment.role,
generation: attachment.generation,
replay: attachment.replay,
activate: attachment.activate,
detach: attachment.detach,
}
})
return Service.of({ list, get, create, write, resize, control, input, snapshot, remove, shutdown, attach })
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node, Global.node] })
const request = (daemon: DaemonTransport, value: object, start = false) =>
daemon.request(value, start).pipe(Effect.mapError(unavailable))
const optionalRequest = (daemon: DaemonTransport, value: object) =>
daemon.requestIfRunning(value).pipe(Effect.mapError(unavailable))
const unexpected = (response: WireResponse) =>
Effect.fail(new UnavailableError({ message: `unexpected opencode-pty response: ${response.type}` }))
const unavailable = (error: unknown) =>
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
function databasePath(db: Database.Interface["db"]) {
const client: unknown = db.$client
if ((typeof client !== "object" && typeof client !== "function") || client === null || !("config" in client))
return undefined
const config = client.config
if (typeof config !== "object" || config === null || !("filename" in config)) return undefined
if (typeof config.filename !== "string" || config.filename === ":memory:") return undefined
return path.resolve(config.filename)
}
const runtimeDirectory = (databasePath?: string) => {
const root =
process.env.OPENCODE_PTY_RUNTIME_DIR ??
(process.env.XDG_RUNTIME_DIR
? path.join(process.env.XDG_RUNTIME_DIR, "opencode-pty")
: path.join(
os.tmpdir(),
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
))
const identity = databasePath ?? `memory:${crypto.randomUUID()}`
return path.join(root, createHash("sha256").update(identity).digest("hex").slice(0, 16))
}
function toInfo(value: WireTerminal): Info {
const status = value.lifecycle.status
return {
...Pty.Info.make({
id: toID(value.id),
title: value.title,
command: value.command[0] || "",
args: value.command.slice(1),
cwd: value.cwd,
status: status === "running" ? "running" : "exited",
pid: value.pid ?? 0,
...(status === "exited" ? { exitCode: value.lifecycle.exit_code ?? undefined } : {}),
}),
sessionID: Session.ID.make(value.group_id),
foregroundProcess: value.foreground_process,
size: { cols: value.cols, rows: value.rows },
output: { head: value.output_head, tail: value.output_tail },
}
}
function toID(value: number) {
return Pty.ID.make(`pty_persistent_${value}`)
}
function fromID(value: Pty.ID) {
if (!value.startsWith("pty_persistent_")) throw new Error(`invalid persistent PTY ID: ${value}`)
const parsed = Number(value.slice("pty_persistent_".length))
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`invalid persistent PTY ID: ${value}`)
return parsed
}
@@ -0,0 +1,3 @@
const asset: { readonly path: string; readonly version: string; readonly sha256: string } | undefined = undefined
export default asset
+6
View File
@@ -22,7 +22,10 @@ import { Reference } from "./reference.js"
import { Skill } from "./skill.js"
import { State } from "./state.js"
import { Tool } from "./tool.js"
import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
@@ -199,8 +202,11 @@ export const node = makeLocationNode({
Reference.node,
Skill.node,
Tool.node,
Vcs.node,
PluginHooks.node,
PluginRuntime.node,
WebSearch.node,
Generate.node,
Permission.node,
],
})
+3
View File
@@ -5,6 +5,7 @@ import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration"
import type { PermissionHooks } from "@opencode-ai/plugin/effect/permission"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state.js"
@@ -12,6 +13,7 @@ import { State } from "../state.js"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly permission: PermissionHooks
readonly shell: ShellHooks
readonly tool: ToolHooks
}
@@ -22,6 +24,7 @@ type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
interface Failures extends Record<keyof Domains, unknown> {
readonly aisdk: NoFailures<AISDKHooks>
readonly session: NoFailures<SessionHooks>
readonly permission: NoFailures<PermissionHooks>
readonly shell: NoFailures<ShellHooks>
readonly tool: ToolFailures
}
+42
View File
@@ -24,7 +24,10 @@ import { AbsolutePath, type DeepMutable } from "../schema.js"
import { Skill } from "../skill.js"
import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { Vcs } from "../vcs.js"
import { WebSearch } from "../websearch.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
import type { Interface } from "../plugin.js"
@@ -43,7 +46,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const reference = yield* Reference.Service
const skill = yield* Skill.Service
const tools = yield* Tool.Service
const vcs = yield* Vcs.Service
const websearch = yield* WebSearch.Service
const generate = yield* Generate.Service
const permission = yield* Permission.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
const locationInfo = () =>
@@ -186,6 +192,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
event: {
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
},
generate: {
text: (input) => generate.text(input).pipe(Effect.map((text) => ({ text }))),
},
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
@@ -311,6 +320,30 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
})
}),
},
permission: {
hook: (name, callback) => hooks.register("permission", name, callback),
list: (input) => permission.forSession(input.sessionID),
get: (input) =>
permission
.get(input.requestID)
.pipe(
Effect.flatMap((request) =>
request?.sessionID === input.sessionID
? Effect.succeed(request)
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
reply: (input) =>
permission
.get(input.requestID)
.pipe(
Effect.flatMap((request) =>
request?.sessionID === input.sessionID
? permission.reply({ requestID: input.requestID, reply: input.reply, message: input.message })
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
},
plugin: {
list: () => response(plugin.list()),
},
@@ -354,6 +387,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
get: () => response(vcs.info()),
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 })),
transform: vcs.transform,
reload: vcs.reload,
},
websearch: {
providers: () => response(websearch.providers()),
query: (input) =>
@@ -407,6 +448,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
.interrupt(input.sessionID, { continue: input.continue })
.pipe(Effect.map((interrupted) => ({ interrupted }))),
wait: (input) => runtime.session.wait(input.sessionID),
context: (input) => runtime.session.context(input.sessionID),
},
} satisfies Plugin.Context
})
+4
View File
@@ -83,8 +83,10 @@ import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { PluginRuntime } from "./runtime.js"
import { SkillPlugin } from "./skill.js"
import { VcsHgPlugin } from "./vcs/hg.js"
import { SystemPromptPlugin } from "./system-prompt.js"
import { VariantPlugin } from "./variant.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
import { WellKnownPlugin } from "../wellknown/plugin.js"
@@ -235,10 +237,12 @@ const pre = [
ConfigMCPPlugin.Plugin,
MCPCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
VcsGitPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
...ProviderPlugins,
+2
View File
@@ -26,6 +26,7 @@ export interface Interface {
| "interrupt"
| "synthetic"
| "wait"
| "context"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -81,6 +82,7 @@ export const layerWithCell = (cell: Cell) =>
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
},
job: {
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
@@ -1,20 +1,47 @@
export * as VcsGit from "./git.js"
export * as VcsGitPlugin from "./git.js"
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 { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs.js"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch.js"
import type { Patch } from "./patch.js"
import { Location } from "../../location.js"
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "../../vcs/patch.js"
import type { Patch } from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.git",
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
const processes = yield* AppProcess.Service
const adapter = make(processes, {
directory: location.directory,
worktree: location.project.directory,
})
yield* ctx.vcs.transform((draft) => {
draft.add({
id: "git",
name: "Git",
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 }),
})
})
}),
})
/**
* Git adapter for the Vcs service. Ported from the V1 pipeline: patches are
* batched through one `git diff` invocation where possible and capped by
* per-file and total byte budgets, falling back to empty patches when capped.
*/
export function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Interface {
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Adapter {
// 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 }
@@ -26,6 +53,9 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor
})
return { branch: { current, default: root?.name } } satisfies Info
}),
branches: Effect.fn("VcsGit.branches")(function* (options?: BranchOptions) {
return yield* ctx.git.branches(ctx.directory, options)
}),
status: Effect.fn("VcsGit.status")(function* () {
const git = ctx.git
const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined
@@ -176,6 +206,22 @@ function makeGit(proc: AppProcess.Interface) {
return result.text().trim() || undefined
})
const branches = Effect.fn("VcsGit.branches")(function* (cwd: string, options?: BranchOptions) {
const search = options?.search?.trim().replace(/[*?[\]\\]/g, "\\$&")
return (yield* lines(
[
"for-each-ref",
"--ignore-case",
"--sort=refname",
"--sort=-committerdate",
"--format=%(refname:short)",
...(options?.limit ? [`--count=${options.limit}`] : []),
...(search ? [`refs/heads/*${search}*`, `refs/remotes/*${search}*`] : ["refs/heads", "refs/remotes"]),
],
{ cwd },
)).filter((item) => !item.endsWith("/HEAD")) satisfies BranchList
})
const defaultBranch = Effect.fn("VcsGit.defaultBranch")(function* (cwd: string) {
const remote = yield* primary(cwd)
if (remote) {
@@ -313,6 +359,7 @@ function makeGit(proc: AppProcess.Interface) {
return {
branch,
branches,
defaultBranch,
hasHead,
mergeBase,
@@ -1,13 +1,15 @@
export * as VcsHg from "./hg.js"
export * as VcsHgPlugin from "./hg.js"
import path from "path"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs.js"
import { Location } from "../../location.js"
import type { Adapter, DiffOptions } from "../../vcs.js"
import {
addPatch,
chunksByFile,
@@ -17,18 +19,44 @@ import {
MAX_PATCH_BYTES,
MAX_TOTAL_PATCH_BYTES,
PATCH_CONTEXT_LINES,
} from "./patch.js"
} from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.hg",
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
const processes = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const adapter = make(processes, fs, {
directory: location.directory,
worktree: location.project.directory,
})
yield* ctx.vcs.transform((draft) => {
draft.add({
id: "hg",
name: "Mercurial",
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 }),
})
})
}),
})
/**
* Mercurial adapter for the Vcs service. `hg diff --git` emits git-format
* patches for tracked changes; untracked (`?`) and missing (`!`) files never
* appear in `hg diff`, so their patches are synthesized from file contents.
*/
export function make(
function make(
proc: AppProcess.Interface,
fs: FSUtil.Interface,
input: { directory: string; worktree: string },
): Interface {
): Adapter {
const hg = makeHg(proc, input.worktree)
// All commands run from the worktree root (hg prints root-relative paths);
// this pathspec scopes them to the requested directory.
@@ -76,6 +104,9 @@ export function make(
info: Effect.fn("VcsHg.info")(function* () {
return { branch: { current: yield* hg.branch(), default: "default" } } satisfies Info
}),
branches: Effect.fn("VcsHg.branches")(function* () {
return []
}),
status: Effect.fn("VcsHg.status")(function* () {
const [items, batch] = yield* Effect.all(
// Zero-context patches are enough to count changed lines.
+34 -1
View File
@@ -29,6 +29,13 @@ export type Current = ProjectSchema.Current
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = ProjectSchema.UpdateInput
export type UpdateInput = ProjectSchema.UpdateInput
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Project.NotFoundError", {
projectID: ID,
}) {}
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -47,6 +54,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
}
@@ -145,6 +153,31 @@ const layer = Layer.effect(
return rows.map(fromRow)
})
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
const row = yield* db
.update(ProjectTable)
.set({
name: input.name === undefined ? undefined : input.name || null,
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
commands:
input.commands?.start === undefined
? undefined
: input.commands.start
? { start: input.commands.start }
: null,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ projectID: input.projectID })
const project = fromRow(row)
yield* bus.publish(ProjectSchema.Event.Updated, project)
return project
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -258,7 +291,7 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, resolve })
return Service.of({ list, update, resolve })
}),
)
+5
View File
@@ -13,6 +13,11 @@ export type Current = typeof Current.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = Project.UpdateInput
export type UpdateInput = typeof UpdateInput.Type
export const Event = Project.Event
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
+2 -2
View File
@@ -332,8 +332,8 @@ const layer = Layer.effect(
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0)
yield* FiberMap.run(titles, sessionID, title.generateForFirstPrompt(sessionID).pipe(Effect.ignore), {
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
// Promoted input opens a fresh step allowance.
+33 -11
View File
@@ -21,6 +21,8 @@ import { SessionUsage } from "./usage.js"
import { SessionStore } from "./store.js"
const MAX_LENGTH = 100
const MAX_CONTEXT_LENGTH = 8_000
const MAX_FIRST_MESSAGE_LENGTH = 2_000
const titleChanged = Symbol("Session title changed")
type Dependencies = {
@@ -36,14 +38,14 @@ type Dependencies = {
}
export interface Interface {
/** Generates a title from the session's first user message when the session remains untitled. */
readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Generates an initial title or regenerates one from bounded conversation history. */
readonly generate: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
const isUntitled = (session: SessionSchema.Info) =>
export const isUntitled = (session: SessionSchema.Info) =>
isExactRootFallback({
title: session.title,
time: { created: DateTime.toEpochMillis(session.time.created) },
@@ -108,16 +110,36 @@ const attempt = Effect.fn("SessionTitle.attempt")(function* (
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
const make = (dependencies: Dependencies) => {
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
const generate = Effect.fn("SessionTitle.generate")(function* (
db: Database.Interface["db"],
sessionID: SessionSchema.ID,
) {
const session = yield* dependencies.store.get(sessionID)
if (!session) return
if (session.parentID) return
if (!isUntitled(session)) return
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
if (!firstUser) return
const text = !isUntitled(session)
? yield* dependencies.store.context(session.id).pipe(
Effect.map((messages) => {
const original = `Original request:\n${firstUser.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}`
const recent = messages
.flatMap((message) => {
if (message.type === "user" && message.id !== firstUser.id) return [`User: ${message.text.trim()}`]
if (message.type !== "assistant") return []
const text = message.content
.flatMap((part) => (part.type === "text" ? [part.text.trim()] : []))
.filter(Boolean)
.join("\n")
return text ? [`Assistant: ${text}`] : []
})
.join("\n\n")
if (!recent) return original
const prefix = `${original}\n\nRecent conversation:\n`
return `${prefix}${recent.slice(-(MAX_CONTEXT_LENGTH - prefix.length))}`
}),
Effect.orElseSucceed(() => firstUser.text),
)
: firstUser.text
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
if (!agent) return
const primary = yield* dependencies.models.resolve(session).pipe(Effect.orElseSucceed(() => undefined))
@@ -143,14 +165,14 @@ const make = (dependencies: Dependencies) => {
const selected = preferred ?? primary
if (!selected) return
const title =
(yield* attempt(dependencies, { session, agent, text: firstUser.text, model: selected })) ??
(yield* attempt(dependencies, { session, agent, text, model: selected })) ??
(primary && !isDeepStrictEqual(selected.ref, primary.ref)
? yield* attempt(dependencies, { session, agent, text: firstUser.text, model: primary })
? yield* attempt(dependencies, { session, agent, text, model: primary })
: undefined)
if (!title) return
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
const current = yield* dependencies.store.get(sessionID)
if (!current || !isUntitled(current)) return
if (!current || current.title !== session.title || current.title === truncate(title)) return
yield* dependencies.bus
.publish(
SessionEvent.Renamed,
@@ -162,7 +184,7 @@ const make = (dependencies: Dependencies) => {
)
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
})
return { generateForFirstPrompt }
return { generate }
}
export const layer = Layer.effect(
@@ -178,7 +200,7 @@ export const layer = Layer.effect(
const database = yield* Database.Service
const title = make({ bus, llm, agents, catalog, models, modelRequests, store })
return Service.of({
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
generate: (sessionID) => title.generate(database.db, sessionID),
})
}),
)
+31 -45
View File
@@ -194,54 +194,40 @@ export const Plugin = {
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const unrestricted =
(yield* permission.allowsAll({
sessionID: context.sessionID,
action: name,
agent: context.agent,
})) &&
(yield* permission.allowsAll({
sessionID: context.sessionID,
action: "external_directory",
agent: context.agent,
}))
invocation.cwd = target.absolute
finalTimeout = invocation.timeout
if (!unrestricted) {
const portable =
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
portable,
})
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
const portable =
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
portable,
})
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
)
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter(
(item, index, items) => items.findIndex((other) => other.resource === item.resource) === index,
)
const external = [target, ...directories]
.map((item) => item.externalDirectory)
.filter((item) => item !== undefined)
.filter(
(item, index, items) =>
items.findIndex((other) => other.resource === item.resource) === index,
)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
+117 -32
View File
@@ -1,54 +1,102 @@
export * as Vcs from "./vcs.js"
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location.js"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "./bus.js"
import { VcsGit } from "./vcs/git.js"
import { VcsHg } from "./vcs/hg.js"
import { State } from "./state.js"
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
export { FileStatus, Info, Mode }
export { BranchList, FileStatus, Info, Mode }
export interface DiffOptions {
readonly context?: number
}
export interface Interface {
export interface BranchOptions {
readonly search?: string
readonly limit?: number
}
export interface Adapter {
readonly info: () => Effect.Effect<Info>
readonly branches: (options?: BranchOptions) => Effect.Effect<BranchList>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
export interface Interface extends Adapter, State.Transformable<VcsDraft> {}
// Adapter seam: one working-copy implementation per VCS type, selected by the
// resolved location. Locations without a supported VCS degrade to empty
// results so callers never need to special-case.
const adapter = (proc: AppProcess.Interface, fs: FSUtil.Interface, location: Location.Interface) => {
const scope = { directory: location.directory, worktree: location.project.directory }
if (location.vcs?.type === "git") return VcsGit.make(proc, scope)
if (location.vcs?.type === "hg") return VcsHg.make(proc, fs, scope)
interface Data {
readonly providers: Map<string, VcsDefinition>
selection?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const proc = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const impl = adapter(proc, fs, location)
const vcs = location.vcs
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
const current: { info: Info } = { info: { branch: {} } }
const scope = {
directory: location.directory,
worktree: location.project.directory,
canonical: location.project.canonical,
...(vcs ? { store: vcs.store } : {}),
}
const decodeInfo = Schema.decodeUnknownEffect(Schema.toType(Info))
const decodeBranches = Schema.decodeUnknownEffect(BranchList)
const decodeStatus = Schema.decodeUnknownEffect(Schema.Array(FileStatus))
const decodeDiff = Schema.decodeUnknownEffect(Schema.Array(FileDiff.Info))
const state: State.Interface<Data, VcsDraft> = State.create<Data, VcsDraft>({
name: "vcs",
initial: () => ({ providers: new Map() }),
draft: (draft) => ({
add: (provider) => draft.providers.set(provider.id, provider),
default: {
get: () => draft.selection,
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => refresh(),
})
const selected = () => {
const value = state.get()
const id = value.selection ?? vcs?.type
return id ? value.providers.get(id) : undefined
}
const protect = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, fallback: A) =>
effect.pipe(
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(cause).pipe(Effect.orDie)
: Effect.logWarning("vcs provider failed", { provider: provider.id, operation, cause }).pipe(
Effect.as(fallback),
),
),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
})
if (vcs && impl) {
if (vcs) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.orElseSucceed(() => vcs.store))
const isBranchMetadata =
vcs.type === "git"
@@ -57,29 +105,66 @@ const layer = Layer.effect(
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}
return Service.of({
transform: state.transform,
reload: state.reload,
info: Effect.fn("Vcs.info")(function* () {
return state.info
return current.info
}),
branches: Effect.fn("Vcs.branches")(function* (options?: BranchOptions) {
const provider = selected()
if (provider)
return yield* protect(
provider,
"branches",
provider.branches({ ...scope, ...options }).pipe(Effect.flatMap(decodeBranches)),
[],
)
return []
}),
status: Effect.fn("Vcs.status")(function* () {
if (!impl) return []
return yield* impl.status()
const provider = selected()
if (provider)
return yield* protect(
provider,
"status",
provider.status(scope).pipe(
Effect.flatMap(decodeStatus),
Effect.map((rows) => Array.from(rows)),
),
[],
)
return []
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
if (!impl) return []
return yield* impl.diff(mode, options)
const provider = selected()
if (!provider) return []
const rows = yield* protect(
provider,
"diff",
provider
.diff({
...scope,
mode,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
.pipe(Effect.flatMap(decodeDiff)),
[],
)
let total = 0
return rows.map((row) => {
const bytes = Buffer.byteLength(row.patch)
if (total + bytes > MAX_TOTAL_PATCH_BYTES) return { ...row, patch: emptyPatch(row.file) }
total += bytes
return row
})
}),
})
}),
@@ -88,5 +173,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
deps: [FSUtil.node, Location.node, Bus.node],
})
+31 -1
View File
@@ -18,6 +18,8 @@ import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
import { WorktreeGit } from "./worktree/git.js"
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
import { ProjectTable } from "./project/sql.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
export { DirectoryUnavailableError } from "./worktree/directory.js"
@@ -87,6 +89,7 @@ export type Error =
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| AppProcess.AppProcessError
| Git.WorktreeError
export interface Strategy {
@@ -94,6 +97,7 @@ export interface Strategy {
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
branch?: string
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
@@ -147,6 +151,7 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const processService = yield* AppProcess.Service
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
@@ -251,6 +256,7 @@ const layer = Layer.effect(
const result = yield* selected.create({
directory: worktreeDirectory,
sourceDirectory,
branch: input.branch,
})
yield* changed(
input.projectID,
@@ -260,6 +266,30 @@ const layer = Layer.effect(
strategy: input.strategy,
}),
)
const project = yield* db
.select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, input.projectID))
.get()
.pipe(Effect.orDie)
const command = project?.commands?.start?.trim()
if (command && project) {
const windows = process.platform === "win32"
yield* processService
.run(
ChildProcess.make(windows ? command : "bash", windows ? [] : ["-lc", command], {
cwd: result.directory,
env: {
OPENCODE_WORKTREE_BASE: project.worktree,
OPENCODE_WORKTREE_PATH: result.directory,
},
extendEnv: true,
stdin: "ignore",
shell: windows,
}),
)
.pipe(Effect.flatMap(AppProcess.requireSuccess))
}
return result
})
@@ -342,7 +372,7 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
})
export const refreshNode = makeLocationNode({
+1 -1
View File
@@ -16,7 +16,7 @@ export const make = Effect.gen(function* () {
create: Effect.fn("Worktree.Git.create")(function* (input) {
const repository = yield* git.repo.discover(input.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.sourceDirectory })
yield* git.worktree.create({ repository, directory: input.directory })
yield* git.worktree.create({ repository, directory: input.directory, ref: input.branch })
return { directory: yield* canonical(fs, input.directory) }
}),
remove: Effect.fn("Worktree.Git.remove")(function* (input) {
@@ -78,6 +78,7 @@ describe("node build", () => {
acquisitions++
return Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
}),
+2 -5
View File
@@ -1,8 +1,5 @@
import { Permission } from "@opencode-ai/core/permission"
import { Effect, Layer } from "effect"
import { Layer } from "effect"
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
Layer.mock(Permission.Service, {
allowsAll: () => Effect.succeed(false),
...overrides,
})
Layer.mock(Permission.Service, overrides)
+1
View File
@@ -5,6 +5,7 @@ export const globalProjectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
}),
)
+1
View File
@@ -13,6 +13,7 @@ const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
@@ -392,6 +392,7 @@ describe("ModelResolver", () => {
reasoningField: "vendor_reasoning",
maxTokensField: "max_completion_tokens",
requireFinishReason: false,
requireAssistantAfterTool: true,
},
settings: {
apiKey: "settings-secret",
@@ -417,6 +418,7 @@ describe("ModelResolver", () => {
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
expect(resolved.compatibility?.requireFinishReason).toBe(false)
expect(resolved.compatibility?.requireAssistantAfterTool).toBe(true)
expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
expect(prepared.body).not.toHaveProperty("max_tokens")
expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
+79 -27
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -10,6 +10,7 @@ import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -26,7 +27,15 @@ const current = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
Agent.node,
PluginHooks.node,
Permission.node,
]),
[[Location.node, current]],
),
)
@@ -112,31 +121,6 @@ describe("Permission", () => {
}),
)
it.effect("proves only unconditional configured allows", () =>
Effect.gen(function* () {
const service = yield* Permission.Service
const input = { sessionID: Session.ID.make("ses_test"), action: "shell" }
yield* setup([{ action: "shell", resource: "*", effect: "allow" }])
expect(yield* service.allowsAll(input)).toBe(true)
yield* setRules([
{ action: "shell", resource: "*", effect: "allow" },
{ action: "shell", resource: "rm *", effect: "deny" },
])
expect(yield* service.allowsAll(input)).toBe(false)
yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }])
expect(yield* service.allowsAll(input)).toBe(false)
yield* setRules([
{ action: "shell", resource: "rm *", effect: "deny" },
{ action: "shell", resource: "*", effect: "allow" },
])
expect(yield* service.allowsAll(input)).toBe(true)
}),
)
it.effect("evaluates against an explicit provider-turn agent", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
@@ -172,6 +156,74 @@ describe("Permission", () => {
}),
)
it.effect("lets plugins review allow and ask decisions without overriding configured denies", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
seen.push(event.effect)
event.effect = event.action === "write" ? "deny" : "allow"
event.message = "Reviewed by policy"
}),
)
const service = yield* Permission.Service
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
yield* setRules([])
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_ask") }))).toMatchObject({ effect: "allow" })
expect(yield* service.list()).toEqual([])
const blocked = yield* service
.assert(assertion({ id: Permission.ID.create("per_write"), action: "write" }))
.pipe(Effect.flip)
expect(blocked).toBeInstanceOf(Permission.BlockedError)
expect(blocked.message).toBe("Reviewed by policy")
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_deny") }))).toMatchObject({ effect: "deny" })
expect(seen).toEqual(["allow", "ask", "ask"])
}),
)
it.effect("publishes the reviewer message when a plugin escalates to ask", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const hooks = yield* PluginHooks.Service
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
event.effect = "ask"
event.message = "Confirm production access"
}),
)
const service = yield* Permission.Service
const result = yield* service.ask(assertion())
expect(result.effect).toBe("ask")
expect(yield* service.get(result.id)).toMatchObject({ message: "Confirm production access" })
}),
)
it.effect("allows cancellation while a permission reviewer is running", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const hooks = yield* PluginHooks.Service
const started = yield* Deferred.make<void>()
yield* hooks.register("permission", "evaluate", () =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
)
const service = yield* Permission.Service
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
it.effect("allows managed output reads without granting external directory access", () =>
Effect.gen(function* () {
yield* setup([
@@ -0,0 +1,277 @@
import { expect, test } from "bun:test"
import { spawn } from "node:child_process"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
import { Effect } from "effect"
import { makeDaemonTransport } from "../src/persistent-pty/daemon"
const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 6 }
test("rediscovers a same-protocol daemon after its registration rotates", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-registration-"))
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let creates = 0
const server = await listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op === "create") creates++
return request.op === "list" ? { type: "terminals", terminals: [] } : { type: "ok" }
})
try {
await writeRegistration(directory, socketPath, instance, token)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
await Effect.runPromise(daemon.request({ op: "list" }))
token = "new-token"
instance = "new-instance"
await writeRegistration(directory, socketPath, instance, token)
const running = await Effect.runPromise(daemon.requestIfRunning({ op: "list" }))
expect(running).toEqual({ type: "terminals", terminals: [] })
token = "newest-token"
instance = "newest-instance"
await writeRegistration(directory, socketPath, instance, token)
await Effect.runPromise(daemon.request({ op: "create" }, true))
expect(creates).toBe(1)
} finally {
await close(server)
await rm(directory, { recursive: true, force: true })
}
})
test("rediscovers a rotated registration when acquiring a subscription", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-subscription-"))
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
let subscriptions = 0
const server = await listen(socketPath, (_socket, request, receivedToken) => {
if (receivedToken !== token) return { type: "error", message: "authentication failed" }
if (request.op === "ping") return { ...pong, instance_id: instance }
if (request.op !== "subscribe") return { type: "terminals", terminals: [] }
subscriptions++
return {
type: "attached",
terminal: terminal(1),
role: "observer",
generation: 1,
requested_offset: 0,
available_offset: 0,
end_offset: 0,
truncated: false,
replay_base64: "",
}
})
try {
await writeRegistration(directory, socketPath, instance, token)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
await Effect.runPromise(daemon.request({ op: "list" }))
token = "new-token"
instance = "new-instance"
await writeRegistration(directory, socketPath, instance, token)
const attachment = await Effect.runPromise(
daemon.subscribe(1, {
cursor: 0,
attachmentID: "attachment",
role: "observer",
onEvent: () => {},
onEnd: () => {},
}),
)
expect(attachment.terminal.id).toBe(1)
expect(subscriptions).toBe(1)
attachment.detach()
} finally {
await close(server)
await rm(directory, { recursive: true, force: true })
}
})
test("retries a start-required request when connection fails before dispatch", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-retry-"))
const firstSocket = path.join(directory, "first.sock")
const secondSocket = path.join(directory, "second.sock")
const first = await listen(firstSocket, (_socket, request) => {
if (request.op === "ping") return pong
return { type: "terminals", terminals: [] }
})
try {
await writeRegistration(directory, firstSocket)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
await Effect.runPromise(daemon.request({ op: "list" }))
await close(first)
let creates = 0
const second = await listen(secondSocket, (_socket, request) => {
if (request.op === "ping") return pong
creates++
return { type: "ok" }
})
try {
await writeRegistration(directory, secondSocket)
await Effect.runPromise(daemon.request({ op: "create" }, true))
expect(creates).toBe(1)
} finally {
await close(second)
}
} finally {
await close(first)
await rm(directory, { recursive: true, force: true })
}
})
test("does not replay a dispatched mutating request when its response is lost", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-response-"))
const socketPath = path.join(directory, "daemon.sock")
let creates = 0
const server = await listen(socketPath, (socket, request) => {
if (request.op === "ping") return pong
creates++
socket.destroy()
return undefined
})
try {
await writeRegistration(directory, socketPath)
const daemon = await Effect.runPromise(makeDaemonTransport(directory))
const error = await Effect.runPromise(Effect.flip(daemon.request({ op: "create" }, true)))
expect(error.kind).toBe("response")
expect(creates).toBe(1)
} finally {
await close(server)
await rm(directory, { recursive: true, force: true })
}
})
test("reports protocol mismatches until a start-required request replaces the daemon", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-mismatch-"))
const existing = spawn("sleep", ["30"])
const exited = new Promise<void>((resolve) => existing.once("exit", () => resolve()))
try {
if (existing.pid === undefined) throw new Error("Expected fixture process PID")
await writeFile(
path.join(directory, "service.json"),
JSON.stringify({ instance_id: "old", pid: existing.pid, protocol: 5, socket: "/unused", token: "old" }),
)
const daemon = await Effect.runPromise(
makeDaemonTransport(directory, () => Promise.resolve("/missing/opencode-pty")),
)
const optional = await Effect.runPromise(Effect.flip(daemon.requestIfRunning({ op: "list" })))
expect(optional).toMatchObject({
kind: "protocol",
message: "opencode-pty protocol mismatch: daemon=5, client=6",
pid: existing.pid,
})
expect(existing.exitCode).toBeNull()
const starting = await Effect.runPromise(Effect.flip(daemon.request({ op: "create" }, true)))
await exited
expect(starting).toMatchObject({ kind: "spawn" })
expect(existing.signalCode).toBe(process.platform === "win32" ? null : "SIGTERM")
} finally {
existing.kill("SIGKILL")
await rm(directory, { recursive: true, force: true })
}
})
function listen(
socketPath: string,
handle: (socket: net.Socket, request: Record<string, unknown>, token: string) => object | undefined,
) {
const server = net.createServer((socket) => {
void readRequest(socket)
.then((envelope) => {
const response = handle(socket, envelope.request, envelope.token)
if (response) socket.write(frame(response))
})
.catch(() => socket.destroy())
})
return new Promise<net.Server>((resolve, reject) => {
server.once("error", reject)
server.listen(socketPath, () => resolve(server))
})
}
function readRequest(socket: net.Socket) {
return new Promise<Buffer>((resolve, reject) => {
let pending = Buffer.alloc(0)
const cleanup = () => {
socket.off("data", onData)
socket.off("end", onEnd)
socket.off("error", onError)
}
const onData = (value: Buffer) => {
pending = Buffer.concat([pending, value])
if (pending.length < 4) return
const length = pending.readUInt32BE(0)
if (pending.length < length + 4) return
cleanup()
resolve(pending.subarray(4, length + 4))
}
const onEnd = () => {
cleanup()
reject(new Error("connection closed before request"))
}
const onError = (error: Error) => {
cleanup()
reject(error)
}
socket.on("data", onData)
socket.on("end", onEnd)
socket.on("error", onError)
}).then((payload) => {
const envelope: unknown = JSON.parse(payload.toString("utf8"))
if (!isRecord(envelope) || typeof envelope.token !== "string" || !isRecord(envelope.request))
throw new Error("Invalid test daemon envelope")
return { token: envelope.token, request: envelope.request }
})
}
function frame(value: object) {
const payload = Buffer.from(JSON.stringify(value))
const output = Buffer.allocUnsafe(payload.length + 4)
output.writeUInt32BE(payload.length)
payload.copy(output, 4)
return output
}
function writeRegistration(directory: string, socket: string, instance = "test", token = "test") {
return writeFile(
path.join(directory, "service.json"),
JSON.stringify({ instance_id: instance, pid: process.pid, protocol: 6, socket, token }),
)
}
function terminal(id: number) {
return {
id,
pid: null,
title: "test",
foreground_process: null,
group_id: "test",
command: ["test"],
cwd: "/tmp",
cols: 80,
rows: 24,
lifecycle: { status: "running" },
output_head: 0,
output_tail: 0,
}
}
function close(server: net.Server) {
if (!server.listening) return Promise.resolve()
return new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+32
View File
@@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import { Vcs } from "@opencode-ai/core/vcs"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
@@ -93,6 +94,37 @@ describe("Plugin", () => {
}),
)
it.effect("registers and removes scoped VCS providers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const vcs = yield* Vcs.Service
const provider = EffectPlugin.define({
id: "custom-vcs",
effect: (ctx) =>
ctx.vcs
.transform((draft) => {
draft.add({
id: "custom",
name: "Custom VCS",
info: () => Effect.succeed({ branch: { current: "feature" } }),
branches: () => Effect.succeed(["feature"]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
})
draft.default.set("custom")
})
.pipe(Effect.asVoid),
})
yield* plugins.activate([versioned(provider)])
expect(yield* vcs.info()).toEqual({ branch: { current: "feature" } })
expect(yield* vcs.branches()).toEqual(["feature"])
yield* plugins.activate([])
expect(yield* vcs.info()).toEqual({ branch: {} })
}),
)
it.effect("replaces plugins by ID and version", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+22
View File
@@ -10,6 +10,7 @@ import { Bus } from "@opencode-ai/core/bus"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { KV } from "@opencode-ai/core/kv"
import { Location } from "@opencode-ai/core/location"
@@ -18,11 +19,13 @@ import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Tool } from "@opencode-ai/core/tool"
import { Vcs } from "@opencode-ai/core/vcs"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
@@ -37,6 +40,20 @@ const npmLayer = Layer.succeed(
}),
)
const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text: () => Effect.succeed("") }))
const permissionLayer = Layer.succeed(
Permission.Service,
Permission.Service.of({
ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }),
assert: () => Effect.void,
reply: () => Effect.void,
get: () => Effect.succeed(undefined),
forSession: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
)
export const PluginTestLayer = LayerNode.compile(
LayerNode.group([
FileSystem.node,
@@ -46,6 +63,7 @@ export const PluginTestLayer = LayerNode.compile(
Credential.node,
Bus.node,
Form.node,
Generate.node,
LayerNodePlatform.httpClient,
Plugin.node,
Agent.node,
@@ -56,12 +74,14 @@ export const PluginTestLayer = LayerNode.compile(
KV.node,
MCP.node,
PluginRuntime.node,
Permission.node,
PluginHooks.node,
Reference.node,
Skill.node,
SkillDiscovery.node,
PluginHooks.node,
Tool.node,
Vcs.node,
Watcher.node,
WebSearch.node,
]),
@@ -70,5 +90,7 @@ export const PluginTestLayer = LayerNode.compile(
[Npm.node, npmLayer],
[Config.node, Config.testLayer()],
[MCP.node, emptyMcpLayer],
[Generate.node, generateLayer],
[Permission.node, permissionLayer],
],
) as unknown as Layer.Layer<unknown, never>

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