Compare commits

..

1 Commits

Author SHA1 Message Date
Luke Parker 6f4934ef43 fix(core): relocate moved project checkouts
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-27 02:57:41 +00:00
45 changed files with 359 additions and 724 deletions
-2
View File
@@ -317,7 +317,6 @@
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
@@ -1051,7 +1050,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-A3dgTsHBfIn+trqbserr1CAERfKeJjTiUxZxzkz4tPU=",
"aarch64-linux": "sha256-G72CWFF7gTNHxmq5TFMRFiffqp9Voqcoe1oz8Kk47vU=",
"aarch64-darwin": "sha256-7/lxVQWuebPDFo+cuel/S1IpwLuTxFYIUYBsNimqcI4=",
"x86_64-darwin": "sha256-GTRYa17f0SpwrWh+M3Q2MTIUzzGINknKMmRzYUgqMus="
"x86_64-linux": "sha256-rB/CcrBUQVZ08nBFSYA8u2w88rQmTpKxKPkIreDEKgI=",
"aarch64-linux": "sha256-ZRTphtic8Ip96MnILteFgZAUxjK9O4YfJu2O6u/0H8k=",
"aarch64-darwin": "sha256-VK5XIzraP0HtqnPwPCejiDKer4ewtNtX1vxP5uuyjSk=",
"x86_64-darwin": "sha256-ZLPHqcCZB1EmxQk95cmUpiODTTKOyi7PSF0yr/rDk6Y="
}
}
+1 -2
View File
@@ -153,7 +153,6 @@
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
}
}
+20 -57
View File
@@ -35,8 +35,6 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -1364,7 +1362,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid,
title: language.t("command.model.choose"),
keybind: command.keybindParts("model.choose"),
keybind: command.keybind("model.choose"),
model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
@@ -1381,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"),
keybind: command.keybindParts("agent.cycle"),
keybind: command.keybind("agent.cycle"),
options: props.controls.agents.options,
current: props.controls.agents.current,
style: control(),
@@ -1499,14 +1497,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()}
<TooltipV2
<TooltipKeybind
placement="top"
value={
<>
{language.t("prompt.action.attachFile")}
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
</>
}
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<IconButton
data-action="prompt-attach"
@@ -1520,7 +1514,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
</TooltipV2>
</TooltipKeybind>
<Show when={showAgentControl()}>
<ComposerAgentControl state={agentControlState()} />
</Show>
@@ -1535,15 +1529,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
!props.controls.model.selection.variant.current() && !store.variantOpen,
}}
>
<TooltipV2
<TooltipKeybind
placement="top"
gutter={4}
value={
<>
{language.t("command.model.variant.cycle")}
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
</>
}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
@@ -1561,11 +1551,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipV2>
</TooltipKeybind>
</div>
</Show>
</div>
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
@@ -1580,7 +1570,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</TooltipV2>
</Tooltip>
</div>
</DockShellForm>
</div>
@@ -1926,7 +1916,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type ComposerAgentControlState = {
title: string
keybind: string[]
keybind: string
options: string[]
current: string
style: JSX.CSSProperties | undefined
@@ -1938,7 +1928,7 @@ type ComposerModelControlState = {
shouldAnimate: boolean
paid: boolean
title: string
keybind: string[]
keybind: string
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
@@ -1953,16 +1943,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
<Icon name="sliders" size="small" />
</div>
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Select
size="normal"
options={props.state.options}
@@ -1974,7 +1955,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipV2>
</TooltipKeybind>
</div>
)
}
@@ -1985,16 +1966,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Show
when={props.state.paid}
fallback={
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<Button
data-action="prompt-model"
as="div"
@@ -2019,19 +1991,10 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</Button>
</TooltipV2>
</TooltipKeybind>
}
>
<TooltipV2
placement="top"
gutter={4}
value={
<>
{props.state.title}
<KeybindV2 keys={props.state.keybind} variant="neutral" />
</>
}
>
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
@@ -2060,7 +2023,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</ModelSelectorPopover>
</TooltipV2>
</TooltipKeybind>
</Show>
</Show>
)
+2 -2
View File
@@ -455,7 +455,7 @@ export function NewHome() {
onClose={closeSearch}
onSelect={selectSearchSession}
/>
<ScrollView class="mt-3 -mr-3 min-h-0 flex-1">
<ScrollView class="mt-3 min-h-0 flex-1">
<Show
when={!sessionLoad.isLoading}
fallback={
@@ -468,7 +468,7 @@ export function NewHome() {
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div class="flex flex-col gap-6 pt-3 pr-3">
<div class="pt-3 flex flex-col gap-6">
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
-2
View File
@@ -512,7 +512,6 @@ export type SessionsContextOutput = {
readonly id: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
@@ -1590,7 +1589,6 @@ export type SessionsMessageOutput = {
readonly id: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
readonly type: "tool"
-1
View File
@@ -104,7 +104,6 @@
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
+14 -17
View File
@@ -25,7 +25,6 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { Reference } from "../reference"
import { SkillV2 } from "../skill"
import { State } from "../state"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
@@ -105,22 +104,20 @@ const layer = Layer.effectDiscard(
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
}
yield* State.batch(
Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(VariantPlugin.Plugin)
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
yield* Effect.gen(function* () {
yield* add(ConfigReferencePlugin.Plugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(VariantPlugin.Plugin)
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}),
)
@@ -1,4 +1,4 @@
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import { Duration, Effect, Schema, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
@@ -79,7 +79,6 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
@@ -106,7 +105,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
})
connected = (yield* ctx.integration.connection.active("opencode")) !== undefined
yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
catalog.provider.update(providerID, (provider) => {
@@ -177,13 +176,11 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
}),
})
-19
View File
@@ -20,7 +20,6 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
}
export interface RunOptions {
readonly combineOutput?: boolean
readonly maxOutputBytes?: number
readonly maxErrorBytes?: number
readonly signal?: AbortSignal
@@ -38,10 +37,8 @@ export interface RunStreamOptions {
export interface RunResult {
readonly command: string
readonly exitCode: number
readonly output?: Buffer
readonly stdout: Buffer
readonly stderr: Buffer
readonly outputTruncated?: boolean
readonly stdoutTruncated: boolean
readonly stderrTruncated: boolean
}
@@ -146,22 +143,6 @@ export const layer = Layer.effect(
const collect = Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
if (options?.combineOutput) {
const [output, exitCode] = yield* Effect.all(
[collectStream(handle.all, options.maxOutputBytes), handle.exitCode],
{ concurrency: "unbounded" },
)
return {
command: description,
exitCode,
output: output.buffer,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
outputTruncated: output.truncated,
stdoutTruncated: false,
stderrTruncated: false,
} satisfies RunResult
}
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStream(handle.stdout, options?.maxOutputBytes),
+1 -5
View File
@@ -1,7 +1,3 @@
export * as PublicEventManifest from "./public-event-manifest"
import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
export const Definitions = EventManifest.ServerDefinitions
export const Latest = Event.latest(Definitions)
export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest"
+1 -7
View File
@@ -400,13 +400,7 @@ export const layer = Layer.effect(
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
+2 -1
View File
@@ -14,6 +14,7 @@ import { SessionSchema } from "./schema"
import { SessionContextEpochTable } from "./sql"
type DatabaseService = Database.Interface["db"]
type Transaction = Parameters<Parameters<DatabaseService["transaction"]>[0]>[0]
interface Prepared {
readonly baseline: string
@@ -109,7 +110,7 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
})
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
db: DatabaseService,
db: DatabaseService | Transaction,
sessionID: SessionSchema.ID,
) {
yield* db
@@ -349,7 +349,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
time: { created: event.data.timestamp },
}),
),
)
@@ -366,7 +365,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
})
+1 -2
View File
@@ -335,8 +335,7 @@ export const layer = Layer.effect(
if (stream._tag === "Success" && !publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
return yield* Effect.failCause(settled.cause)
if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
}),
)
+1 -1
View File
@@ -133,7 +133,7 @@ export const fromCatalogModel = (
credential?: Credential.Value,
): Effect.Effect<Model, UnsupportedApiError> => {
const resolved =
credential?.type !== "key" || credential.metadata === undefined
credential?.metadata === undefined
? model
: produce(model, (draft) => {
Object.assign(draft.request.body, credential.metadata)
+11 -45
View File
@@ -1,8 +1,6 @@
export * as ApplyPatchTool from "./apply-patch"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -26,10 +24,7 @@ export const Applied = Schema.Struct({
target: Schema.String,
})
export const Output = Schema.Struct({
applied: Schema.Array(Applied),
files: Schema.Array(FileDiff.Info),
})
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
export type Output = typeof Output.Type
export const toModelOutput = (output: Output) =>
@@ -41,17 +36,11 @@ export const toModelOutput = (output: Output) =>
].join("\n")
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: LocationMutation.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly source: Uint8Array
readonly content: string
readonly before: string
readonly after: string
})
export const layer = Layer.effectDiscard(
@@ -124,36 +113,29 @@ export const layer = Layer.effectDiscard(
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
if (hunk.type === "add") {
prepared.push({
...hunk,
target,
before: "",
after:
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
})
prepared.push({ ...hunk, target })
return
}
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
const source = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
const before = original.replace(/^\uFEFF/, "")
if (hunk.type === "delete") {
prepared.push({ ...hunk, target, before, after: "" })
prepared.push({ ...hunk, target })
return
}
const update = Patch.derive(hunk.path, hunk.chunks, original)
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}
const patchFiles = prepared.map(patchFile)
yield* Effect.forEach(
prepared,
(change) =>
@@ -183,7 +165,7 @@ export const layer = Layer.effectDiscard(
}).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true },
)
return { applied, files: patchFiles }
return { applied }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
},
}),
@@ -193,19 +175,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
file: change.target.resource,
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
...counts,
}
}
+36 -28
View File
@@ -30,15 +30,16 @@ export const Input = Schema.Struct({
}),
})
const StructuredOutput = Schema.Struct({
exit: Schema.Number.pipe(Schema.optional),
truncated: Schema.Boolean,
timeout: Schema.Boolean.pipe(Schema.optional),
})
const Output = Schema.Struct({
...StructuredOutput.fields,
command: Schema.String,
cwd: Schema.String,
exitCode: Schema.Number.pipe(Schema.optional),
/** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */
output: Schema.String,
truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
timedOut: Schema.Boolean.pipe(Schema.optional),
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
@@ -46,12 +47,24 @@ type Output = typeof Output.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
const compactOutput = (stdout: string, stderr: string) => {
const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout
return output || "(no output)"
}
const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
return undefined
}
const modelOutput = (output: Output) => {
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.`
return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.`
}
const isTimeout = (error: AppProcess.AppProcessError) =>
@@ -103,16 +116,7 @@ export const layer = Layer.effectDiscard(
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
toModelOutput: ({ output }) => [
{ type: "text", text: output.output },
{ type: "text", text: modelOutput(output) },
],
toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@@ -159,9 +163,9 @@ export const layer = Layer.effectDiscard(
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess
.run(command, {
combineOutput: true,
timeout: Duration.millis(timeout),
maxOutputBytes: MAX_CAPTURE_BYTES,
maxErrorBytes: MAX_CAPTURE_BYTES,
})
.pipe(
Effect.catchTag("AppProcessError", (error) =>
@@ -170,22 +174,26 @@ export const layer = Layer.effectDiscard(
)
if (!result) {
return {
command: input.command,
cwd: target.canonical,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
timedOut: true,
...(warnings.length ? { warnings } : {}),
}
}
const output = result.output?.toString("utf8") || "(no output)"
const notice = result.outputTruncated
? "[output capture truncated at the in-memory safety limit]"
: undefined
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
return {
exit: result.exitCode,
output: notice ? `${output}\n\n${notice}` : output,
truncated: result.outputTruncated === true,
command: input.command,
cwd: target.canonical,
exitCode: result.exitCode,
output: notice ? `${compact}\n\n${notice}` : compact,
truncated: result.stdoutTruncated || result.stderrTruncated,
...(warnings.length ? { warnings } : {}),
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}),
+6 -22
View File
@@ -7,8 +7,6 @@
export * as EditTool from "./edit"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -32,7 +30,10 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Struct({
files: Schema.Array(FileDiff.Info),
operation: Schema.Literal("write"),
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
replacements: Schema.Number,
})
export type Output = typeof Output.Type
@@ -70,7 +71,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[
`Edited file successfully: ${output.files[0]?.file}`,
`Edited file successfully: ${output.resource}`,
`Replacements: ${output.replacements}`,
"```diff",
...previewLines(oldString, "-"),
@@ -178,13 +179,6 @@ export const layer = Layer.effectDiscard(
input.replaceAll === true
? source.text.replaceAll(oldString, newString)
: source.text.replace(oldString, newString)
const counts = diffLines(source.text, replaced).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
@@ -193,17 +187,7 @@ export const layer = Layer.effectDiscard(
content: joinBom(next.text, source.bom || next.bom),
}),
)
return {
files: [
{
file: result.resource,
patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced),
status: "modified" as const,
...counts,
},
],
replacements,
} satisfies Output
return { ...result, replacements } satisfies Output
})
},
}),
+8 -26
View File
@@ -37,19 +37,10 @@ export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
type Config<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
> = {
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly structured?: Structured
readonly toStructuredOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => Schema.Schema.Type<Structured>
readonly execute: (
input: Schema.Schema.Type<Input>,
context: Context,
@@ -68,12 +59,10 @@ type Runtime = {
const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<
Input extends SchemaType<any>,
Output extends SchemaType<any>,
Structured extends SchemaType<any> = Output,
>(config: Config<Input, Output, Structured>): Definition<Input, Structured> {
const tool = Object.freeze({}) as Definition<Input, Structured>
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
@@ -83,7 +72,7 @@ export function make<
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.structured ?? config.output),
outputSchema: toJsonSchema(config.output),
})
definitions.set(name, definition)
return definition
@@ -95,13 +84,6 @@ export function make<
config.execute(input, context).pipe(
Effect.flatMap((output) =>
Schema.encodeEffect(config.output)(output).pipe(
Effect.flatMap((output) => {
if (!config.structured || !config.toStructuredOutput)
return Effect.succeed({ output, structured: output })
return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe(
Effect.map((structured) => ({ output, structured })),
)
}),
Effect.mapError(
(error) =>
new ToolFailure({
@@ -110,8 +92,8 @@ export function make<
),
),
),
Effect.map(({ output, structured }) => ({
structured,
Effect.map((output) => ({
structured: output,
content:
config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text"
@@ -42,15 +42,6 @@ const openai: Lowerer = {
},
request(options) {
const result = snake(options)
if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) {
result.reasoning = {
...(isRecord(result.reasoning) ? result.reasoning : {}),
...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}),
...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}),
}
delete result.reasoning_effort
delete result.reasoning_summary
}
if (options.textVerbosity !== undefined) {
result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity }
delete result.text_verbosity
+2 -2
View File
@@ -601,9 +601,9 @@ describe("Config", () => {
models: {
model: {
request: {
body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" },
body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" },
},
variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }],
variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }],
},
},
})
@@ -42,14 +42,12 @@ describe("ConfigProviderOptionsV1", () => {
expect(
lowerer.request({
reasoningEffort: "high",
reasoningSummary: "auto",
reasoning: { encryptedContent: true },
textVerbosity: "low",
text: { outputFormat: "plain" },
nestedValue: { camelCase: true },
}),
).toEqual({
reasoning: { encrypted_content: true, effort: "high", summary: "auto" },
reasoning_effort: "high",
text: { output_format: "plain", verbosity: "low" },
nested_value: { camel_case: true },
})
@@ -140,8 +138,8 @@ describe("ConfigProviderOptionsV1", () => {
body: { trace: true },
settings: { resourceName: "resource" },
})
expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({
reasoning: { effort: "high", summary: "auto" },
expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({
reasoning_effort: "high",
text: { verbosity: "low" },
})
})
@@ -25,20 +25,6 @@ function required<T>(value: T | undefined): T {
return value
}
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 1000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
@@ -81,14 +67,11 @@ describe("OpencodePlugin", () => {
Effect.acquireUseRelease(
Effect.sync(() => {
const authorization: Array<string | null> = []
const gate = Promise.withResolvers<void>()
return {
authorization,
release: gate.resolve,
server: Bun.serve({
port: 0,
fetch: async (request) => {
await gate.promise
fetch: (request) => {
authorization.push(request.headers.get("authorization"))
const origin = new URL(request.url).origin
return Response.json({
@@ -127,7 +110,7 @@ describe("OpencodePlugin", () => {
}),
}
}),
({ authorization, release, server }) =>
({ authorization, server }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
@@ -145,15 +128,8 @@ describe("OpencodePlugin", () => {
})
yield* addPlugin()
expect(authorization).toEqual([])
release()
const provider = required(
yield* eventually(
catalog.provider.get(ProviderV2.ID.make("remote")),
(item) => item?.integrationID === Integration.ID.make("opencode"),
),
)
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote")))
expect(provider).toMatchObject({
name: "Remote",
integrationID: "opencode",
@@ -39,22 +39,6 @@ describe("AppProcess", () => {
}),
)
it.effect(
"captures stdout and stderr in emission order",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const script = [
'process.stdout.write("out 1\\n")',
'setTimeout(() => process.stderr.write("err 1\\n"), 10)',
'setTimeout(() => process.stdout.write("out 2\\n"), 20)',
].join(";")
const result = yield* svc.run(cmd("-e", script), { combineOutput: true })
expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n")
expect(result.stdout.toString("utf8")).toBe("")
expect(result.stderr.toString("utf8")).toBe("")
}),
)
it.effect(
"non-zero exit returns RunResult; caller can require success",
Effect.gen(function* () {
+2 -20
View File
@@ -377,7 +377,7 @@ describe("SessionV2.create", () => {
}),
)
it.effect("ignores a model switch when the selected model is unchanged", () =>
it.effect("persists repeated switches as distinct durable Session events", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
@@ -389,29 +389,11 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(2)
).toHaveLength(3)
expect(yield* session.get(created.id)).toMatchObject({ model })
}),
)
it.effect("treats an omitted variant as the default variant", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
const created = yield* session.create({ location, model })
yield* session.switchModel({
sessionID: created.id,
model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
})
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(1)
}),
)
it.effect("rejects a model switch for a missing Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
@@ -4,7 +4,6 @@ import { LLMClient } from "@opencode-ai/llm/route"
import { DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -292,27 +291,6 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("does not project OAuth account metadata into the request body", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "secret",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { server: "https://console.example", orgID: "org_123" },
}),
)
expect(resolved.route.defaults.http?.body).toEqual({})
}),
)
it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
+3 -13
View File
@@ -2565,7 +2565,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("returns unexpected local tool defects to the model and continues", () =>
it.effect("propagates unexpected local tool defects operationally", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -2579,20 +2579,11 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-after-defect" }),
LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }),
LLMEvent.textEnd({ id: "text-after-defect" }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
yield* session.resume(sessionID)
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect")
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call defect" },
{
@@ -2608,7 +2599,6 @@ describe("SessionRunnerLLM", () => {
},
],
},
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
}),
)
@@ -149,29 +149,6 @@ describe("ApplyPatchTool", () => {
{ type: "update", resource: "update.txt" },
{ type: "delete", resource: "remove.txt" },
],
files: [
{
file: "nested/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
{
file: "update.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
{
file: "remove.txt",
status: "deleted",
additions: 0,
deletions: 1,
patch: expect.stringContaining("-remove"),
},
],
})
expect(assertions).toMatchObject([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
+31 -47
View File
@@ -31,10 +31,8 @@ let denyAction: string | undefined
let result: AppProcess.RunResult = {
command: "mock",
exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false,
stderrTruncated: false,
}
@@ -85,10 +83,8 @@ const reset = () => {
result = {
command: "mock",
exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false,
stderrTruncated: false,
}
@@ -139,33 +135,24 @@ describe("BashTool", () => {
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd")
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
result: {
type: "content",
value: [
{ type: "text", text: "hello\n" },
{ type: "text", text: "Command exited with code 0." },
],
},
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
output: {
structured: {
exit: 0,
command: "pwd",
cwd: realpathSync(tmp.path),
exitCode: 0,
output: "hello\n",
truncated: false,
},
content: [
{ type: "text", text: "hello\n" },
{ type: "text", text: "Command exited with code 0." },
],
content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }],
},
})
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
expect(runs[0]?.options).toMatchObject({
combineOutput: true,
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}),
@@ -235,17 +222,13 @@ describe("BashTool", () => {
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "core-bash" },
{ type: "text", text: "Command exited with code 0." },
],
})
expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." })
expect(settled.output?.structured).toMatchObject({
exit: 0,
command: "printf core-bash",
cwd: realpathSync(tmp.path),
exitCode: 0,
output: "core-bash",
})
expect(settled.output?.structured).not.toHaveProperty("output")
}),
),
)
@@ -320,13 +303,11 @@ describe("BashTool", () => {
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toHaveLength(1)
expect(settled.output?.structured).toMatchObject({
truncated: false,
})
expect(settled.output?.structured).not.toHaveProperty("warnings")
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Warnings:"),
warnings: [
`Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
],
})
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") })
}),
),
)
@@ -343,19 +324,21 @@ describe("BashTool", () => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") }
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.content[1]).toMatchObject({
expect(settled.result).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 7"),
value: expect.stringContaining("Command exited with code 7"),
})
expect(settled.output?.structured).toMatchObject({
exit: 7,
command: "false",
cwd: realpathSync(tmp.path),
exitCode: 7,
output: "HEAD full output TAIL",
truncated: false,
})
expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" })
}),
),
)
@@ -369,14 +352,14 @@ describe("BashTool", () => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, outputTruncated: true }
result = { ...result, stdoutTruncated: true }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true })
expect(settled.output?.content[0]).toMatchObject({
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
expect(settled.result).toMatchObject({
type: "text",
text: expect.stringContaining("output capture truncated"),
value: expect.stringContaining("stdout capture truncated"),
})
expect(settled.output?.structured).not.toHaveProperty("resource")
}),
@@ -396,12 +379,13 @@ describe("BashTool", () => {
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.content[1]).toMatchObject({
expect(settled.result).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
value: expect.stringContaining("Command timed out"),
})
expect(settled.output?.structured).toMatchObject({
timeout: true,
command: "sleep 60",
timedOut: true,
truncated: false,
})
}),
+4 -9
View File
@@ -125,16 +125,11 @@ describe("EditTool", () => {
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
})
expect(settled.output?.structured).toEqual({
operation: "write",
target: yield* Effect.promise(() => fs.realpath(target)),
resource: "hello.txt",
existed: true,
replacements: 1,
files: [
{
file: "hello.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringContaining("-before\n+after"),
},
],
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
+10 -11
View File
@@ -28,7 +28,7 @@ import { McpAuth } from "./auth"
import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/server/tui-event"
import open from "open"
import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect"
import { Cause, Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@@ -928,17 +928,16 @@ export const layer = Layer.effect(
const pending = pendingOAuthTransports.get(mcpName)
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
const error = yield* Effect.tryPromise({
try: () => pending.transport.finishAuth(authorizationCode),
catch: (error) => error,
}).pipe(
Effect.match({
onFailure: (error) => (error instanceof Error ? error.message : String(error)),
onSuccess: () => undefined,
}),
)
const result = yield* Effect.tryPromise({
try: () => pending.transport.finishAuth(authorizationCode).then(() => true as const),
catch: (error) => {
return error
},
}).pipe(Effect.option)
if (error) return { status: "failed", error: `OAuth completion failed: ${error}` } satisfies Status
if (Option.isNone(result)) {
return { status: "failed", error: "OAuth completion failed" } satisfies Status
}
yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve())
yield* auth.clearCodeVerifier(mcpName)
+83 -1
View File
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
@@ -22,6 +23,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/schema/project"
import path from "path"
export const Info = Project.Info
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
@@ -192,6 +194,70 @@ export const layer = Layer.effect(
.pipe(Effect.orDie)
})
// Repair persisted absolute paths after the checkout was moved outside OpenCode.
// This is not a semantic Session move: there is no source checkout or change transfer.
// Keep every affected projection in one transaction so a restart cannot expose mixed paths.
const relocateProject = Effect.fn("Project.relocateProject")(function* (input: {
projectID: ProjectV2.ID
source: string
destination: string
}) {
return yield* db
.transaction(
(d) =>
Effect.gen(function* () {
const row = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()
if (!row) return yield* Effect.die(new Error("Project disappeared during relocation"))
if (row.worktree !== input.source) return fromRow(row)
const destination = AbsolutePath.make(input.destination)
const sessions = yield* d
.select({ id: SessionTable.id, directory: SessionTable.directory })
.from(SessionTable)
.where(eq(SessionTable.project_id, input.projectID))
.all()
const moved = sessions.filter((session) => FSUtil.contains(input.source, session.directory))
yield* Effect.forEach(
moved,
(session) =>
Effect.gen(function* () {
yield* d
.update(SessionTable)
.set({ directory: path.join(destination, path.relative(input.source, session.directory)) })
.where(eq(SessionTable.id, session.id))
.run()
yield* SessionContextEpoch.reset(d, session.id)
}),
{ concurrency: 1, discard: true },
)
yield* projectDirectories.create({ projectID: input.projectID, directory: destination }, d)
yield* projectDirectories.remove(
{ projectID: input.projectID, directory: AbsolutePath.make(input.source) },
d,
)
const updated = yield* d
.update(ProjectTable)
.set({
worktree: destination,
sandboxes: row.sandboxes.filter(
(sandbox) => sandbox !== input.source && sandbox !== input.destination,
),
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get()
if (!updated) return yield* Effect.die(new Error("Project disappeared during relocation"))
return fromRow(updated)
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
})
const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: {
projectID: ProjectV2.ID
directory: string
@@ -220,7 +286,7 @@ export const layer = Layer.effect(
const projectID = ProjectV2.ID.make(data.id)
yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID)
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
const existing = row
const persisted = row
? fromRow(row)
: {
id: projectID,
@@ -229,6 +295,22 @@ export const layer = Layer.effect(
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
const relocationCandidate = row && projectID !== ProjectV2.ID.global && persisted.worktree !== worktree
const primaryMissing = relocationCandidate
? yield* fs.stat(persisted.worktree).pipe(
Effect.as(false),
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(true)),
Effect.orDie,
)
: false
const existing =
relocationCandidate && primaryMissing
? yield* relocateProject({
projectID,
source: persisted.worktree,
destination: worktree,
})
: persisted
if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
+1 -2
View File
@@ -1934,8 +1934,7 @@ export const layer = Layer.effect(
return { providerID: entry.providerID, modelID: entry.modelID }
}
const configured = Object.keys(cfg.provider ?? {})
const provider = Object.values(s.providers).find((p) => configured.length === 0 || configured.includes(p.id))
const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
if (!provider) return yield* new NoProvidersError()
const [model] = sort(Object.values(provider.models))
if (!model) return yield* new NoModelsError({ providerID: provider.id })
@@ -263,7 +263,7 @@ mcpTest.instance(
expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
status: "failed",
error: "OAuth completion failed: Token exchange failed",
error: "OAuth completion failed",
})
const entry = yield* auth.get(name)
expect(entry?.tokens?.accessToken).toBe("working-token")
@@ -1,5 +1,4 @@
import { test, expect, describe } from "bun:test"
import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js"
import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider"
import type { McpAuth } from "../../src/mcp/auth"
@@ -60,43 +59,3 @@ describe("McpOAuthProvider.clientMetadata", () => {
expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none")
})
})
describe("MCP OAuth scope selection", () => {
test("adds offline_access when the authorization server and client support refresh tokens", () => {
expect(
determineScope({
resourceMetadata: {
resource: "https://mcp.example.com/mcp",
scopes_supported: ["resource.read"],
},
authServerMetadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
scopes_supported: ["resource.read", "offline_access"],
},
clientMetadata: makeProvider({}).clientMetadata,
}),
).toBe("resource.read offline_access")
})
test("does not add unsupported authorization server scopes", () => {
expect(
determineScope({
resourceMetadata: {
resource: "https://mcp.example.com/mcp",
scopes_supported: ["resource.read"],
},
authServerMetadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
scopes_supported: ["resource.read"],
},
clientMetadata: makeProvider({}).clientMetadata,
}),
).toBe("resource.read")
})
})
+89 -3
View File
@@ -6,10 +6,10 @@ import path from "path"
import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionContextEpochTable, SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { eq } from "drizzle-orm"
import { eq, inArray } from "drizzle-orm"
import { Hash } from "@opencode-ai/core/util/hash"
import { SessionID } from "@/session/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
@@ -23,6 +23,7 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { testEffect } from "../lib/effect"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { AbsolutePath } from "@opencode-ai/core/schema"
const encoder = new TextEncoder()
@@ -366,6 +367,8 @@ describe("Project.fromDirectory with worktrees", () => {
const next = yield* project.fromDirectory(clone)
expect(next.project.id).toBe(result.project.id)
expect(next.project.worktree).toBe(tmp)
expect(next.project.sandboxes).toContain(clone)
}),
)
@@ -403,6 +406,89 @@ describe("Project.fromDirectory with worktrees", () => {
expect(result.project.sandboxes).not.toContain(tmp)
}),
)
it.live("relocates a missing primary checkout and its sessions", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const project = yield* Project.Service
const tmp = yield* tmpdirScoped({ git: true })
const original = yield* project.fromDirectory(tmp)
const moved = `${tmp}-moved`
const rootSession = SessionID.make(`ses_${crypto.randomUUID()}`)
const nestedSession = SessionID.make(`ses_${crypto.randomUUID()}`)
yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${moved}`.quiet().nothrow()).pipe(Effect.ignore))
yield* db
.insert(SessionTable)
.values([
{
id: rootSession,
project_id: original.project.id,
slug: rootSession,
directory: tmp,
title: "root",
version: "test",
time_created: 1,
time_updated: 1,
},
{
id: nestedSession,
project_id: original.project.id,
slug: nestedSession,
directory: path.join(tmp, "packages", "app"),
path: "packages/app",
title: "nested",
version: "test",
time_created: 2,
time_updated: 2,
},
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionContextEpochTable)
.values([
{ session_id: rootSession, baseline: "root", snapshot: {}, baseline_seq: 0 },
{ session_id: nestedSession, baseline: "nested", snapshot: {}, baseline_seq: 0 },
])
.run()
.pipe(Effect.orDie)
yield* Effect.promise(() => $`mv ${tmp} ${moved}`.quiet())
const [result, concurrent] = yield* Effect.all([project.fromDirectory(moved), project.fromDirectory(moved)], {
concurrency: "unbounded",
})
const sessions = yield* db
.select({ id: SessionTable.id, directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.project_id, original.project.id))
.all()
.pipe(Effect.orDie)
const directories = yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, original.project.id))
.all()
.pipe(Effect.orDie)
const epochs = yield* db
.select({ sessionID: SessionContextEpochTable.session_id })
.from(SessionContextEpochTable)
.where(inArray(SessionContextEpochTable.session_id, [rootSession, nestedSession]))
.all()
.pipe(Effect.orDie)
expect(result.project.worktree).toBe(moved)
expect(concurrent.project.worktree).toBe(moved)
expect(result.project.sandboxes).toEqual([])
expect(sessions).toContainEqual({ id: rootSession, directory: moved, path: null })
expect(sessions).toContainEqual({
id: nestedSession,
directory: path.join(moved, "packages", "app"),
path: "packages/app",
})
expect(directories).toEqual([{ directory: AbsolutePath.make(moved) }])
expect(epochs).toEqual([])
}),
)
})
describe("Project.discover", () => {
@@ -356,17 +356,6 @@ it.instance(
{ config: { model: "anthropic/claude-sonnet-4-20250514" } },
)
it.instance(
"defaultModel treats empty provider config as no allowlist",
Effect.gen(function* () {
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
const model = yield* Provider.use.defaultModel()
expect(model.providerID).toBeDefined()
expect(model.modelID).toBeDefined()
}),
{ config: { provider: {} } },
)
it.instance(
"defaultModel returns a typed error when config excludes every provider",
Effect.gen(function* () {
-4
View File
@@ -150,10 +150,6 @@ export const AssistantReasoning = Schema.Struct({
id: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
}).pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
+10 -6
View File
@@ -2766,6 +2766,8 @@ export type SessionHistory = {
hasMore: boolean
}
export type SessionDurableEvent1 = string
export type SessionMessagesResponse = {
data: Array<SessionMessage>
cursor: {
@@ -2845,7 +2847,7 @@ export type QuestionRejected2 = {
}
}
export type V2Event =
export type V2Event1 =
| ModelsDevRefreshed
| IntegrationUpdated
| IntegrationConnectionUpdated
@@ -2935,6 +2937,8 @@ export type V2Event =
| ServerConnected
| GlobalDisposed
export type V2Event = string
export type ForbiddenError = {
_tag: "ForbiddenError"
message: string
@@ -4026,10 +4030,6 @@ export type SessionMessageAssistantReasoning = {
id: string
text: string
providerMetadata?: LlmProviderMetadata
time?: {
created: number
completed?: number
}
}
export type SessionMessageToolStatePending = {
@@ -11894,7 +11894,11 @@ export type V2SessionEventsResponses = {
/**
* Success
*/
200: SessionDurableEvent
200: {
id: string
event: string
data: SessionDurableEvent1
}
}
export type V2SessionEventsResponse = V2SessionEventsResponses[keyof V2SessionEventsResponses]
-13
View File
@@ -27588,19 +27588,6 @@
},
"providerMetadata": {
"$ref": "#/components/schemas/LLMProviderMetadata"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
},
"completed": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
}
},
"required": ["type", "id", "text"],
+3 -3
View File
@@ -16,7 +16,7 @@ import type {
SessionMessageAssistantTool,
SessionV2Info,
SkillV2Info,
V2Event,
V2Event1,
} from "@opencode-ai/sdk/v2"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
@@ -121,7 +121,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
}
function handleEvent(event: V2Event) {
function handleEvent(event: V2Event1) {
switch (event.type) {
case "catalog.updated":
void Promise.all([
@@ -408,7 +408,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
...event,
data: event.properties,
location: { directory: metadata.directory, workspaceID: metadata.workspace },
} as V2Event)
} as V2Event1)
})
onCleanup(unsub)
})
+3
View File
@@ -140,6 +140,9 @@
color: var(--text-strong);
transition:
background-color 0.2s ease-in-out,
color 0.2s ease-in-out;
outline: none;
user-select: none;
@@ -221,158 +221,6 @@ index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb7
this._cleanupTimeout(messageId);
reject(error);
});
diff --git a/dist/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts
index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644
--- a/dist/cjs/client/auth.d.ts
+++ b/dist/cjs/client/auth.d.ts
@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise<OA
* @returns A Promise that resolves to an OAuthError instance
*/
export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
+/**
+ * Selects scopes per the MCP spec and augments them for refresh token support.
+ */
+export declare function determineScope(options: {
+ requestedScope?: string;
+ resourceMetadata?: OAuthProtectedResourceMetadata;
+ authServerMetadata?: AuthorizationServerMetadata;
+ clientMetadata: OAuthClientMetadata;
+}): string | undefined;
/**
* Orchestrates the full auth flow with a server.
*
diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js
index c2e4fa91d26f5336889f6afa416147db75fc4872..178d7cfd96412d53bc14bbc13a8f76c11f727ee7 100644
--- a/dist/cjs/client/auth.js
+++ b/dist/cjs/client/auth.js
@@ -7,6 +7,7 @@ exports.UnauthorizedError = void 0;
exports.selectClientAuthMethod = selectClientAuthMethod;
exports.parseErrorResponse = parseErrorResponse;
exports.auth = auth;
+exports.determineScope = determineScope;
exports.isHttpsUrl = isHttpsUrl;
exports.selectResourceURL = selectResourceURL;
exports.extractWWWAuthenticateParams = extractWWWAuthenticateParams;
@@ -186,6 +187,19 @@ async function auth(provider, options) {
throw error;
}
}
+/**
+ * Selects scopes per the MCP spec and augments them for refresh token support.
+ */
+function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
+ if (effectiveScope &&
+ authServerMetadata?.scopes_supported?.includes('offline_access') &&
+ !effectiveScope.split(' ').includes('offline_access') &&
+ clientMetadata.grant_types?.includes('refresh_token')) {
+ effectiveScope = `${effectiveScope} offline_access`;
+ }
+ return effectiveScope;
+}
async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
// Check if the provider has cached discovery state to skip discovery
const cachedState = await provider.discoveryState?.();
@@ -241,12 +255,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
});
}
const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
- // Apply scope selection strategy (SEP-835):
- // 1. WWW-Authenticate scope (passed via `scope` param)
- // 2. PRM scopes_supported
- // 3. Client metadata scope (user-configured fallback)
- // The resolved scope is used consistently for both DCR and the authorization request.
- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
+ const resolvedScope = determineScope({
+ requestedScope: scope,
+ resourceMetadata,
+ authServerMetadata: metadata,
+ clientMetadata: provider.clientMetadata
+ });
// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
if (!clientInformation) {
@@ -741,7 +755,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
if (scope) {
authorizationUrl.searchParams.set('scope', scope);
}
- if (scope?.includes('offline_access')) {
+ if (scope?.split(' ').includes('offline_access')) {
// if the request includes the OIDC-only "offline_access" scope,
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts
index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644
--- a/dist/esm/client/auth.d.ts
+++ b/dist/esm/client/auth.d.ts
@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise<OA
* @returns A Promise that resolves to an OAuthError instance
*/
export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
+/**
+ * Selects scopes per the MCP spec and augments them for refresh token support.
+ */
+export declare function determineScope(options: {
+ requestedScope?: string;
+ resourceMetadata?: OAuthProtectedResourceMetadata;
+ authServerMetadata?: AuthorizationServerMetadata;
+ clientMetadata: OAuthClientMetadata;
+}): string | undefined;
/**
* Orchestrates the full auth flow with a server.
*
diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js
index e183040fc2bba22ca1ccc784984f3310854403b7..d367661e580ee61a96654f7af78b2af61dcad98b 100644
--- a/dist/esm/client/auth.js
+++ b/dist/esm/client/auth.js
@@ -161,6 +161,19 @@ export async function auth(provider, options) {
throw error;
}
}
+/**
+ * Selects scopes per the MCP spec and augments them for refresh token support.
+ */
+export function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
+ if (effectiveScope &&
+ authServerMetadata?.scopes_supported?.includes('offline_access') &&
+ !effectiveScope.split(' ').includes('offline_access') &&
+ clientMetadata.grant_types?.includes('refresh_token')) {
+ effectiveScope = `${effectiveScope} offline_access`;
+ }
+ return effectiveScope;
+}
async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
// Check if the provider has cached discovery state to skip discovery
const cachedState = await provider.discoveryState?.();
@@ -216,12 +229,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
});
}
const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
- // Apply scope selection strategy (SEP-835):
- // 1. WWW-Authenticate scope (passed via `scope` param)
- // 2. PRM scopes_supported
- // 3. Client metadata scope (user-configured fallback)
- // The resolved scope is used consistently for both DCR and the authorization request.
- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
+ const resolvedScope = determineScope({
+ requestedScope: scope,
+ resourceMetadata,
+ authServerMetadata: metadata,
+ clientMetadata: provider.clientMetadata
+ });
// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
if (!clientInformation) {
@@ -716,7 +729,7 @@ export async function startAuthorization(authorizationServerUrl, { metadata, cli
if (scope) {
authorizationUrl.searchParams.set('scope', scope);
}
- if (scope?.includes('offline_access')) {
+ if (scope?.split(' ').includes('offline_access')) {
// if the request includes the OIDC-only "offline_access" scope,
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js
index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644
--- a/dist/esm/client/index.js
-32
View File
@@ -1,32 +0,0 @@
diff --git a/dist/unstable/httpapi/OpenApi.js b/dist/unstable/httpapi/OpenApi.js
index 8292ab7..9c4fd5e 100644
--- a/dist/unstable/httpapi/OpenApi.js
+++ b/dist/unstable/httpapi/OpenApi.js
@@ -335,7 +335,7 @@ export function fromApi(api) {
if (HttpApiSchema.isStreamSse(stream)) {
pathOps.push({
_tag: "schema",
- ast: SchemaAST.getAST(stream.events),
+ ast: stream.sseMode === "data" ? streamDataAst(stream.events) : SchemaAST.getAST(stream.events),
path: ["paths", path, method, "responses", String(status), "content", contentType, "schema"]
});
pathOps.push({
@@ -501,6 +501,18 @@ export function fromApi(api) {
return spec;
}
const reservedStreamFailureEvent = "effect/httpapi/stream/failure";
+function streamDataAst(events) {
+ const ast = SchemaAST.getAST(events);
+ if (!SchemaAST.isObjects(ast)) {
+ return ast;
+ }
+ const data = ast.propertySignatures.find(field => field.name === "data")?.type;
+ if (data === undefined) {
+ return ast;
+ }
+ const contentSchema = data.encoding?.at(-1)?.to.annotations?.contentSchema;
+ return SchemaAST.isAST(contentSchema) ? contentSchema : data;
+}
function extractSuccessResponseBodies(endpoint) {
return extractResponseBodies(HttpApiEndpoint.getSuccessSchemas(endpoint), HttpApiSchema.getStatusSuccess, resolveDescriptionOrIdentifier);
}