Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a075d495 |
@@ -244,9 +244,9 @@ jobs:
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||
target: x86_64-unknown-linux-gnu
|
||||
platform_flag: --linux
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404-arm"
|
||||
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||
target: aarch64-unknown-linux-gnu
|
||||
platform_flag: --linux --arm64
|
||||
platform_flag: --linux
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -209,8 +209,7 @@ export function createChildStoreManager(input: {
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
const type = this.session_status[id]?.type
|
||||
return (type ?? "idle") !== "idle"
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
|
||||
@@ -128,17 +128,4 @@ describe("formatServerError", () => {
|
||||
["Modelo nao encontrado: x/y", "Voce quis dizer: x/y2, x/y3", "Revise provider/model no config"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("unwraps SDK-wrapped errors from cause.body", () => {
|
||||
const body = {
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
message: "Missing host",
|
||||
},
|
||||
} satisfies ConfigInvalidError
|
||||
|
||||
const wrapped = new Error("ConfigInvalidError", { cause: { body, status: 400 } })
|
||||
|
||||
expect(formatServerError(wrapped, language.t)).toBe("Arquivo de config em config invalido: Missing host")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,22 +26,14 @@ function tr(translator: Translator | undefined, key: string, text: string, vars?
|
||||
}
|
||||
|
||||
export function formatServerError(error: unknown, translate?: Translator, fallback?: string) {
|
||||
const unwrapped = unwrapNamedError(error)
|
||||
if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate)
|
||||
if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate)
|
||||
if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate)
|
||||
if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate)
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error) return error
|
||||
if (fallback) return fallback
|
||||
return tr(translate, "error.chain.unknown", "Unknown error")
|
||||
}
|
||||
|
||||
function unwrapNamedError(error: unknown): unknown {
|
||||
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
|
||||
return (error.cause as Record<string, unknown>).body
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
const o = error as Record<string, unknown>
|
||||
|
||||
@@ -26,4 +26,3 @@ out/
|
||||
|
||||
resources/opencode-cli*
|
||||
resources/icons
|
||||
resources/*.metainfo.xml
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { resolveChannel } from "./utils"
|
||||
|
||||
const arg = process.argv[2]
|
||||
const channel = arg === "dev" || arg === "beta" || arg === "prod" ? arg : resolveChannel()
|
||||
|
||||
const appId = channel === "prod" ? "ai.opencode.desktop" : `ai.opencode.desktop.${channel}`
|
||||
const productName = channel === "prod" ? "OpenCode" : `OpenCode ${channel.charAt(0).toUpperCase() + channel.slice(1)}`
|
||||
const summary = `Open source AI coding agent${channel !== "prod" ? ` (${channel})` : ""}`
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>${appId}</id>
|
||||
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
|
||||
<name>${productName}</name>
|
||||
<summary>${summary}</summary>
|
||||
|
||||
<developer id="ly.anoma">
|
||||
<name>Anomaly Innovations Inc.</name>
|
||||
</developer>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
OpenCode is an open source agent that helps you write and run code with any AI model.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">${appId}.desktop</launchable>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<url type="bugtracker">https://github.com/anomalyco/opencode/issues</url>
|
||||
<url type="homepage">https://opencode.ai</url>
|
||||
<url type="vcs-browser">https://github.com/anomalyco/opencode</url>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/anomalyco/opencode/b75d4d1c5ec449585d515c756fc81f080a157a9a/packages/web/src/assets/lander/screenshot.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
</component>
|
||||
`
|
||||
|
||||
await Bun.write(`resources/${appId}.metainfo.xml`, xml)
|
||||
console.log(`Generated metainfo for ${channel} at resources/${appId}.metainfo.xml`)
|
||||
@@ -5,6 +5,5 @@ import { resolveChannel } from "./utils"
|
||||
|
||||
const channel = resolveChannel()
|
||||
await $`bun ./scripts/copy-icons.ts ${channel}`
|
||||
await $`bun ./scripts/copy-metainfo.ts ${channel}`
|
||||
|
||||
await $`cd ../opencode && bun script/build-node.ts`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
@@ -117,7 +118,10 @@ export const layer = Layer.effect(
|
||||
},
|
||||
})
|
||||
|
||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||
// Convert permission layers to rulesets and merge them
|
||||
// Each layer's rules come after the previous, so later configs override earlier ones
|
||||
const layers = ConfigPermission.toLayers(cfg.permission)
|
||||
const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
const agents: Record<string, Info> = {
|
||||
build: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { Session } from "@/session/session"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable } from "../../session/session.sql"
|
||||
import { Project } from "@/project/project"
|
||||
@@ -163,9 +162,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
|
||||
filteredSessions,
|
||||
(session) =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* svc
|
||||
.messages({ sessionID: session.id })
|
||||
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed([])))
|
||||
const messages = yield* svc.messages({ sessionID: session.id })
|
||||
|
||||
const sessionCost = session.cost ?? 0
|
||||
const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
@@ -33,11 +33,7 @@ function configIssues(input: Record<string, unknown>): ConfigIssue[] {
|
||||
return Array.isArray(input.issues)
|
||||
? input.issues.filter((issue): issue is ConfigIssue => {
|
||||
if (!isRecord(issue)) return false
|
||||
return (
|
||||
typeof issue.message === "string" &&
|
||||
Array.isArray(issue.path) &&
|
||||
issue.path.every((x) => typeof x === "string")
|
||||
)
|
||||
return typeof issue.message === "string" && Array.isArray(issue.path) && issue.path.every((x) => typeof x === "string")
|
||||
})
|
||||
: []
|
||||
}
|
||||
|
||||
@@ -55,6 +55,16 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||
if (target.instructions && source.instructions) {
|
||||
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
|
||||
}
|
||||
// Accumulate permission layers for later merging as rulesets.
|
||||
// This preserves the ordering semantics: later rules override earlier rules.
|
||||
// Each layer keeps the raw shape the user wrote on disk; consumers should use
|
||||
// ConfigPermission.toLayers to normalise.
|
||||
if (source.permission) {
|
||||
merged.permission = [
|
||||
...ConfigPermission.toLayers(target.permission),
|
||||
...ConfigPermission.toLayers(source.permission),
|
||||
]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -228,7 +238,12 @@ export const Info = Schema.Struct({
|
||||
description: "Additional instruction files or patterns to include",
|
||||
}),
|
||||
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||
permission: Schema.optional(ConfigPermission.Info),
|
||||
permission: Schema.optional(
|
||||
Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]),
|
||||
).annotate({
|
||||
description:
|
||||
"Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.",
|
||||
}),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
attachment: Schema.optional(ConfigAttachment.Info).annotate({
|
||||
description: "Attachment processing configuration, including image size limits and resizing behavior",
|
||||
@@ -704,11 +719,12 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info
|
||||
result.permission = [...ConfigPermission.toLayers(result.permission), envPermission]
|
||||
}
|
||||
|
||||
if (result.tools) {
|
||||
const perms: Record<string, ConfigPermission.Action> = {}
|
||||
const perms: ConfigPermission.Info = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch") {
|
||||
@@ -717,7 +733,8 @@ export const layer = Layer.effect(
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
// Tools permissions come before other permissions (they can be overridden)
|
||||
result.permission = [perms, ...ConfigPermission.toLayers(result.permission)]
|
||||
}
|
||||
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
|
||||
@@ -56,3 +56,11 @@ export const Info = InputSchema.pipe(
|
||||
).annotate({ identifier: "PermissionConfig" })
|
||||
type _Info = Schema.Schema.Type<typeof InputObject>
|
||||
export type Info = { -readonly [K in keyof _Info]: _Info[K] }
|
||||
|
||||
// Top-level config accepts either a single permission object or an array of
|
||||
// layered configs. Internal merging produces arrays; this helper normalises
|
||||
// either shape into the array form expected by consumers.
|
||||
export function toLayers(value: Info | Info[] | undefined): Info[] {
|
||||
if (!value) return []
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { NotFoundError as StorageNotFoundError } from "@/storage/storage"
|
||||
import { Effect } from "effect"
|
||||
import * as ApiError from "../errors"
|
||||
|
||||
export function mapStorageNotFound<A, R>(self: Effect.Effect<A, StorageNotFoundError, R>) {
|
||||
return self.pipe(Effect.mapError((error) => ApiError.notFound(error.message)))
|
||||
type StorageNotFound = InstanceType<typeof StorageNotFoundError>
|
||||
|
||||
export function mapStorageNotFound<A, R>(self: Effect.Effect<A, StorageNotFound, R>) {
|
||||
return self.pipe(Effect.mapError((error) => ApiError.notFound(error.data.message)))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Cause, Effect, Option, Schema, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
@@ -101,16 +102,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
}
|
||||
yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
|
||||
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
|
||||
return yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID }))
|
||||
return yield* session.messages({ sessionID: ctx.params.sessionID })
|
||||
}
|
||||
|
||||
const page = yield* SessionError.mapStorageNotFound(
|
||||
MessageV2.pageEffect({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit,
|
||||
before: ctx.query.before,
|
||||
}),
|
||||
)
|
||||
const page = MessageV2.page({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit,
|
||||
before: ctx.query.before,
|
||||
})
|
||||
if (!page.cursor) return page.items
|
||||
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
@@ -132,7 +131,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
params: { sessionID: SessionID; messageID: MessageID }
|
||||
}) {
|
||||
return yield* SessionError.mapStorageNotFound(
|
||||
MessageV2.getEffect({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
|
||||
Effect.try({
|
||||
try: () => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error)))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -250,7 +252,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
payload: typeof SummarizePayload.Type
|
||||
}) {
|
||||
yield* revertSvc.cleanup(yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)))
|
||||
const messages = yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID }))
|
||||
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
|
||||
const defaultAgent = yield* agentSvc.defaultAgent()
|
||||
const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
|
||||
|
||||
|
||||
@@ -24,14 +24,11 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
|
||||
const error = defect.defect
|
||||
log.error("failed", { error, cause: Cause.pretty(cause) })
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 404 }))
|
||||
}
|
||||
|
||||
if (error instanceof NamedError) {
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.jsonUnsafe(error.toObject(), {
|
||||
status: iife(() => {
|
||||
if (error instanceof NotFoundError) return 404
|
||||
if (error instanceof Provider.ModelNotFoundError) return 400
|
||||
if (error.name === "ProviderAuthValidationFailed") return 400
|
||||
if (error.name.startsWith("Worktree")) return 400
|
||||
@@ -51,7 +48,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.jsonUnsafe(
|
||||
new NamedError.Unknown({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
message: error instanceof Error && error.stack ? error.stack : String(error),
|
||||
}).toObject(),
|
||||
{ status: 500 },
|
||||
),
|
||||
|
||||
@@ -565,9 +565,7 @@ export const layer: Layer.Layer<
|
||||
if (processor.message.error) return "stop"
|
||||
if (result === "continue") {
|
||||
const summary = summaryText(
|
||||
(yield* session.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find(
|
||||
(item) => item.info.id === msg.id,
|
||||
) ?? {
|
||||
(yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? {
|
||||
info: msg,
|
||||
parts: [],
|
||||
},
|
||||
|
||||
@@ -956,17 +956,6 @@ export function page(input: { sessionID: SessionID; limit: number; before?: stri
|
||||
}
|
||||
}
|
||||
|
||||
export const pageEffect = Effect.fn("MessageV2.pageEffect")(function* (input: {
|
||||
sessionID: SessionID
|
||||
limit: number
|
||||
before?: string
|
||||
}) {
|
||||
return yield* Effect.try({
|
||||
try: () => page(input),
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error))))
|
||||
})
|
||||
|
||||
export function* stream(sessionID: SessionID) {
|
||||
const size = 50
|
||||
let before: string | undefined
|
||||
@@ -1011,16 +1000,6 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With
|
||||
}
|
||||
}
|
||||
|
||||
export const getEffect = Effect.fn("MessageV2.getEffect")(function* (input: {
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
}) {
|
||||
return yield* Effect.try({
|
||||
try: () => get(input),
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.catch((error) => (NotFoundError.isInstance(error) ? Effect.fail(error) : Effect.die(error))))
|
||||
})
|
||||
|
||||
export function filterCompacted(msgs: Iterable<WithParts>) {
|
||||
const result = [] as WithParts[]
|
||||
const completed = new Set<string>()
|
||||
|
||||
@@ -1077,9 +1077,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}),
|
||||
}
|
||||
}
|
||||
const match = yield* sessions
|
||||
.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
|
||||
.pipe(Effect.orDie)
|
||||
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
|
||||
if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
|
||||
return yield* provider.defaultModel()
|
||||
})
|
||||
@@ -1617,9 +1615,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
)
|
||||
|
||||
const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user").pipe(Effect.orDie)
|
||||
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user")
|
||||
if (Option.isSome(match)) return match.value
|
||||
const msgs = yield* sessions.messages({ sessionID, limit: 1 }).pipe(Effect.orDie)
|
||||
const msgs = yield* sessions.messages({ sessionID, limit: 1 })
|
||||
if (msgs.length > 0) return msgs[0]
|
||||
throw new Error("Impossible")
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
||||
yield* state.assertNotBusy(input.sessionID)
|
||||
const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)
|
||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||
let lastUser: MessageV2.User | undefined
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
|
||||
@@ -103,7 +103,7 @@ export const layer = Layer.effect(
|
||||
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
|
||||
if (!session.revert) return
|
||||
const sessionID = session.id
|
||||
const msgs = yield* sessions.messages({ sessionID }).pipe(Effect.orDie)
|
||||
const msgs = yield* sessions.messages({ sessionID })
|
||||
const messageID = session.revert.messageID
|
||||
const remove = [] as MessageV2.WithParts[]
|
||||
let target: MessageV2.WithParts | undefined
|
||||
|
||||
@@ -448,7 +448,7 @@ export class BusyError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type NotFound = NotFoundError
|
||||
export type NotFound = InstanceType<typeof NotFoundError>
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||
@@ -474,7 +474,7 @@ export interface Interface {
|
||||
readonly clearRevert: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect<void>
|
||||
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[], NotFound>
|
||||
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
|
||||
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
|
||||
readonly remove: (sessionID: SessionID) => Effect.Effect<void, NotFound>
|
||||
readonly updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
|
||||
@@ -497,7 +497,7 @@ export interface Interface {
|
||||
readonly findMessage: (
|
||||
sessionID: SessionID,
|
||||
predicate: (msg: MessageV2.WithParts) => boolean,
|
||||
) => Effect.Effect<Option.Option<MessageV2.WithParts>, NotFound>
|
||||
) => Effect.Effect<Option.Option<MessageV2.WithParts>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
|
||||
@@ -757,25 +757,11 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
.pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => []))
|
||||
})
|
||||
|
||||
const messages: Interface["messages"] = Effect.fn("Session.messages")(function* (input) {
|
||||
const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) {
|
||||
if (input.limit) {
|
||||
return (yield* MessageV2.pageEffect({ sessionID: input.sessionID, limit: input.limit })).items
|
||||
return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items
|
||||
}
|
||||
|
||||
const size = 50
|
||||
const result = [] as MessageV2.WithParts[]
|
||||
let before: string | undefined
|
||||
while (true) {
|
||||
const page = yield* MessageV2.pageEffect({ sessionID: input.sessionID, limit: size, before })
|
||||
if (page.items.length === 0) break
|
||||
for (let i = page.items.length - 1; i >= 0; i--) {
|
||||
const item = page.items[i]
|
||||
if (item) result.push(item)
|
||||
}
|
||||
if (!page.more || !page.cursor) break
|
||||
before = page.cursor
|
||||
}
|
||||
return result.reverse()
|
||||
return Array.from(MessageV2.stream(input.sessionID)).reverse()
|
||||
})
|
||||
|
||||
const removeMessage = Effect.fn("Session.removeMessage")(function* (input: {
|
||||
@@ -813,18 +799,12 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
})
|
||||
|
||||
/** Finds the first message matching the predicate, searching newest-first. */
|
||||
const findMessage: Interface["findMessage"] = Effect.fn("Session.findMessage")(function* (sessionID, predicate) {
|
||||
const size = 50
|
||||
let before: string | undefined
|
||||
while (true) {
|
||||
const page = yield* MessageV2.pageEffect({ sessionID, limit: size, before })
|
||||
if (page.items.length === 0) break
|
||||
for (let i = page.items.length - 1; i >= 0; i--) {
|
||||
const item = page.items[i]
|
||||
if (item && predicate(item)) return Option.some(item)
|
||||
}
|
||||
if (!page.more || !page.cursor) break
|
||||
before = page.cursor
|
||||
const findMessage = Effect.fn("Session.findMessage")(function* (
|
||||
sessionID: SessionID,
|
||||
predicate: (msg: MessageV2.WithParts) => boolean,
|
||||
) {
|
||||
for (const item of MessageV2.stream(sessionID)) {
|
||||
if (predicate(item)) return Option.some(item)
|
||||
}
|
||||
return Option.none<MessageV2.WithParts>()
|
||||
})
|
||||
|
||||
@@ -102,7 +102,7 @@ export const layer = Layer.effect(
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
}) {
|
||||
const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)
|
||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||
if (!all.length) return
|
||||
|
||||
const diffs = yield* computeDiff({ messages: all })
|
||||
|
||||
@@ -272,7 +272,7 @@ export const layer = Layer.effect(
|
||||
log.info("full sync", { sessionID })
|
||||
const info = yield* session.get(sessionID)
|
||||
const diffs = yield* session.diff(sessionID)
|
||||
const messages = yield* session.messages({ sessionID })
|
||||
const messages = yield* Effect.sync(() => Array.from(MessageV2.stream(sessionID)))
|
||||
const models = yield* Effect.forEach(
|
||||
Array.from(
|
||||
new Map(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
@@ -14,22 +15,11 @@ type Migration = (
|
||||
git: Git.Interface,
|
||||
) => Effect.Effect<void, AppFileSystem.Error>
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("NotFoundError", {
|
||||
export const NotFoundError = NamedError.create("NotFoundError", {
|
||||
message: Schema.String,
|
||||
}) {
|
||||
static isInstance(input: unknown): input is NotFoundError {
|
||||
return input instanceof NotFoundError
|
||||
}
|
||||
})
|
||||
|
||||
toObject() {
|
||||
return {
|
||||
name: "NotFoundError" as const,
|
||||
data: { message: this.message },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Error = AppFileSystem.Error | NotFoundError
|
||||
export type Error = AppFileSystem.Error | InstanceType<typeof NotFoundError>
|
||||
|
||||
const RootFile = Schema.Struct({
|
||||
path: Schema.optional(
|
||||
@@ -255,7 +245,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const fail = (target: string): Effect.Effect<never, NotFoundError> =>
|
||||
const fail = (target: string): Effect.Effect<never, InstanceType<typeof NotFoundError>> =>
|
||||
Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
|
||||
|
||||
const wrap = <A>(target: string, body: Effect.Effect<A, AppFileSystem.Error>) =>
|
||||
|
||||
@@ -6,9 +6,16 @@ import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { MessageID, PartID } from "../session/schema"
|
||||
import { type SessionID, MessageID, PartID } from "../session/schema"
|
||||
import EXIT_DESCRIPTION from "./plan-exit.txt"
|
||||
|
||||
function getLastModel(sessionID: SessionID) {
|
||||
for (const item of MessageV2.stream(sessionID)) {
|
||||
if (item.info.role === "user" && item.info.model) return item.info.model
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const Parameters = Schema.Struct({})
|
||||
|
||||
export const PlanExitTool = Tool.define(
|
||||
@@ -44,10 +51,7 @@ export const PlanExitTool = Tool.define(
|
||||
|
||||
if (answers[0]?.[0] === "No") yield* new Question.RejectedError()
|
||||
|
||||
const messages = yield* session.messages({ sessionID: ctx.sessionID }).pipe(Effect.orDie)
|
||||
const lastUser = messages.findLast((item) => item.info.role === "user" && item.info.model)
|
||||
const model =
|
||||
lastUser?.info.role === "user" && lastUser.info.model ? lastUser.info.model : yield* provider.defaultModel()
|
||||
const model = getLastModel(ctx.sessionID) ?? (yield* provider.defaultModel())
|
||||
|
||||
const msg: MessageV2.User = {
|
||||
id: MessageID.ascending(),
|
||||
|
||||
@@ -86,7 +86,7 @@ export const TaskTool = Tool.define(
|
||||
],
|
||||
}))
|
||||
|
||||
const msg = yield* MessageV2.getEffect({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe(Effect.orDie)
|
||||
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
|
||||
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
|
||||
|
||||
const model = next.model ?? {
|
||||
|
||||
@@ -39,18 +39,6 @@ describe("cli.error", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves multiline JSONC diagnostics for tagged config errors", () => {
|
||||
const data = {
|
||||
path: "/tmp/opencode.jsonc",
|
||||
message:
|
||||
'\n--- JSONC Input ---\n{\n "model": \n}\n--- Errors ---\nValueExpected at line 3, column 1\n Line 3: }\n ^\n--- End ---',
|
||||
}
|
||||
const expected = `Config file at ${data.path} is not valid JSON(C): ${data.message}`
|
||||
|
||||
expect(FormatError({ name: "ConfigJsonError", data })).toBe(expected)
|
||||
expect(FormatError({ _tag: "ConfigJsonError", ...data })).toBe(expected)
|
||||
})
|
||||
|
||||
test("formats account transport errors clearly", () => {
|
||||
const error = new AccountTransportError({
|
||||
method: "POST",
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigManaged } from "@/config/managed"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { ConfigParse } from "../../src/config/parse"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -276,6 +278,40 @@ test("updates global config and omits empty shell key in json", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("global config update preserves single-object permission shape on disk", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
permission: { bash: "ask" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = tmp.path
|
||||
await clear(true)
|
||||
|
||||
try {
|
||||
// Updating an unrelated key must not rewrite `permission` from object to array form.
|
||||
await saveGlobal({ shell: "zsh" })
|
||||
|
||||
const written = await Filesystem.readJson<{ permission?: unknown; shell?: string }>(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
)
|
||||
expect(written.shell).toBe("zsh")
|
||||
expect(Array.isArray(written.permission)).toBe(false)
|
||||
expect(written.permission).toEqual({ bash: "ask" })
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("updates global config and omits empty shell key in jsonc", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -1713,7 +1749,10 @@ test("permission config preserves user key order", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(Object.keys(config.permission!)).toEqual([
|
||||
// load() goes through the merge pipeline, producing the layered array form
|
||||
expect(config.permission).toHaveLength(1)
|
||||
const perm = (config.permission as ConfigPermission.Info[])[0]
|
||||
expect(Object.keys(perm)).toEqual([
|
||||
"*",
|
||||
"edit",
|
||||
"write",
|
||||
@@ -1729,6 +1768,129 @@ test("permission config preserves user key order", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Global bash "rm *" deny is inherited, but user's top-level "*" ask comes after and overrides it
|
||||
test("user top-level catchall overrides inherited bash rules", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "rm *": "deny" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
"*": "ask",
|
||||
bash: { "ls *": "allow" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const layers = ConfigPermission.toLayers(config.permission)
|
||||
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// No top-level catchall, so global bash "rm *" deny is preserved
|
||||
test("inherited bash rules apply when no user top-level catchall", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "rm *": "deny" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "ls *": "allow" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const layers = ConfigPermission.toLayers(config.permission)
|
||||
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// User's bash "*" catchall overrides global "rm *" deny
|
||||
test("user bash catchall overrides inherited bash rules", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "rm *": "deny" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "*": "ask", "ls *": "allow" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const layers = ConfigPermission.toLayers(config.permission)
|
||||
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
|
||||
|
||||
// Non-bash permissions should use the top-level "*" rule
|
||||
expect(Permission.evaluate("read", "foo.txt", ruleset).action).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
@@ -1742,7 +1904,8 @@ test("config parser preserves permission order while rejecting unknown top-level
|
||||
"test",
|
||||
)
|
||||
|
||||
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
||||
// ConfigParse.schema preserves the raw shape the user wrote
|
||||
expect(Object.keys(config.permission as ConfigPermission.Info)).toEqual(["bash", "*", "edit"])
|
||||
try {
|
||||
ConfigParse.schema(Config.Info, { invalid_field: true }, "test")
|
||||
throw new Error("expected config parse to fail")
|
||||
@@ -2579,11 +2742,12 @@ test("parseManagedPlist parses permission rules", async () => {
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
expect(config.permission?.["*"]).toBe("ask")
|
||||
expect(config.permission?.grep).toBe("allow")
|
||||
expect(config.permission?.webfetch).toBe("ask")
|
||||
expect(config.permission?.["~/.ssh/*"]).toBe("deny")
|
||||
const bash = config.permission?.bash as Record<string, string>
|
||||
const perm = config.permission as ConfigPermission.Info
|
||||
expect(perm?.["*"]).toBe("ask")
|
||||
expect(perm?.grep).toBe("allow")
|
||||
expect(perm?.webfetch).toBe("ask")
|
||||
expect(perm?.["~/.ssh/*"]).toBe("deny")
|
||||
const bash = perm?.bash as Record<string, string>
|
||||
expect(bash?.["rm -rf *"]).toBe("deny")
|
||||
expect(bash?.["curl *"]).toBe("deny")
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import { Permission } from "../src/permission"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { WithInstance } from "../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "./fixture/fixture"
|
||||
@@ -163,7 +164,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
// general and orchestrator-fast should be allowed, code-reviewer denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
@@ -188,7 +191,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
// general and code-reviewer should be ask, orchestrator-* denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
@@ -213,7 +218,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
// Unspecified agents default to "ask"
|
||||
@@ -240,7 +247,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
|
||||
// Verify task permissions
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
@@ -278,7 +287,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
|
||||
// Last matching rule wins - "*" deny is last, so all agents are denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
|
||||
@@ -309,7 +320,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
|
||||
// Evaluate uses findLast - "general" allow comes after "*" deny
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))
|
||||
|
||||
describe("HttpApi error middleware", () => {
|
||||
it.live("returns a safe body for unknown 500 defects", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* HttpRouter.add("GET", "/boom", Effect.die(new Error("secret stack marker"))).pipe(
|
||||
Layer.provide(errorLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const response = yield* HttpClientRequest.get("/boom").pipe(HttpClient.execute)
|
||||
const body = yield* response.json
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(body).toEqual({
|
||||
name: "UnknownError",
|
||||
data: { message: "Unexpected server error. Check server logs for details." },
|
||||
})
|
||||
expect(JSON.stringify(body)).not.toContain("secret stack marker")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -168,8 +168,7 @@ function withContext<A, E>(
|
||||
)
|
||||
return { info, part }
|
||||
}),
|
||||
messages: (sessionID) =>
|
||||
run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))),
|
||||
messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))),
|
||||
todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))),
|
||||
worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))),
|
||||
worktreeRemove: (directory) =>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -12,20 +11,6 @@ void Log.init({ print: false })
|
||||
|
||||
const it = testEffect(SessionNs.defaultLayer)
|
||||
|
||||
function expectNotFound(fn: () => unknown, message: string) {
|
||||
let thrown: unknown
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(NotFoundError)
|
||||
if (thrown instanceof NotFoundError) {
|
||||
expect(thrown._tag).toBe("NotFoundError")
|
||||
expect(thrown.message).toBe(message)
|
||||
}
|
||||
}
|
||||
|
||||
const withSession = <A, E, R>(
|
||||
fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
@@ -201,16 +186,7 @@ describe("MessageV2.page", () => {
|
||||
it.instance("throws NotFoundError for non-existent session", () =>
|
||||
Effect.gen(function* () {
|
||||
const fake = "non-existent-session" as SessionID
|
||||
expectNotFound(() => MessageV2.page({ sessionID: fake, limit: 10 }), `Session not found: ${fake}`)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("fails pageEffect with NotFoundError for non-existent session", () =>
|
||||
Effect.gen(function* () {
|
||||
const fake = "non-existent-session" as SessionID
|
||||
const error = yield* Effect.flip(MessageV2.pageEffect({ sessionID: fake, limit: 10 }))
|
||||
expect(error).toBeInstanceOf(NotFoundError)
|
||||
expect(error.message).toBe(`Session not found: ${fake}`)
|
||||
expect(() => MessageV2.page({ sessionID: fake, limit: 10 })).toThrow("NotFoundError")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -495,19 +471,7 @@ describe("MessageV2.get", () => {
|
||||
it.instance("throws NotFoundError for non-existent message", () =>
|
||||
withSession(({ sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
const messageID = MessageID.ascending()
|
||||
expectNotFound(() => MessageV2.get({ sessionID, messageID }), `Message not found: ${messageID}`)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("fails getEffect with NotFoundError for non-existent message", () =>
|
||||
withSession(({ sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
const messageID = MessageID.ascending()
|
||||
const error = yield* Effect.flip(MessageV2.getEffect({ sessionID, messageID }))
|
||||
expect(error).toBeInstanceOf(NotFoundError)
|
||||
expect(error.message).toBe(`Message not found: ${messageID}`)
|
||||
expect(() => MessageV2.get({ sessionID, messageID: MessageID.ascending() })).toThrow("NotFoundError")
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -519,7 +483,7 @@ describe("MessageV2.get", () => {
|
||||
const b = yield* session.create({})
|
||||
const [id] = yield* fill(a.id, 1)
|
||||
|
||||
expectNotFound(() => MessageV2.get({ sessionID: b.id, messageID: id }), `Message not found: ${id}`)
|
||||
expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError")
|
||||
const result = MessageV2.get({ sessionID: a.id, messageID: id })
|
||||
expect(result.info.id).toBe(id)
|
||||
|
||||
@@ -582,50 +546,6 @@ describe("MessageV2.get", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("Session.messages", () => {
|
||||
it.instance("returns all messages in chronological order across pages", () =>
|
||||
withSession(({ session, sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
const ids = yield* fill(sessionID, 55)
|
||||
const result = yield* session.messages({ sessionID })
|
||||
expect(result.map((item) => item.info.id)).toEqual(ids)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("fails with NotFoundError for non-existent session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionNs.Service
|
||||
const fake = "non-existent-session" as SessionID
|
||||
const error = yield* Effect.flip(session.messages({ sessionID: fake }))
|
||||
expect(error).toBeInstanceOf(NotFoundError)
|
||||
expect(error.message).toBe(`Session not found: ${fake}`)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Session.findMessage", () => {
|
||||
it.instance("searches newest-first", () =>
|
||||
withSession(({ session, sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
const ids = yield* fill(sessionID, 3)
|
||||
const result = yield* session.findMessage(sessionID, () => true)
|
||||
expect(Option.isSome(result) ? result.value.info.id : undefined).toBe(ids.at(-1))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("fails with NotFoundError for non-existent session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionNs.Service
|
||||
const fake = "non-existent-session" as SessionID
|
||||
const error = yield* Effect.flip(session.findMessage(fake, () => true))
|
||||
expect(error).toBeInstanceOf(NotFoundError)
|
||||
expect(error.message).toBe(`Session not found: ${fake}`)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("MessageV2.filterCompacted", () => {
|
||||
it.instance("returns all messages when no compaction", () =>
|
||||
withSession(({ sessionID }) =>
|
||||
|
||||
@@ -74,23 +74,20 @@ describe("Storage", () => {
|
||||
it.live("maps missing reads to NotFoundError", () =>
|
||||
Effect.gen(function* () {
|
||||
const { root, svc } = yield* scope()
|
||||
const error = yield* Effect.flip(svc.read([...root, "missing", "value"]))
|
||||
expect(error).toBeInstanceOf(Storage.NotFoundError)
|
||||
expect(error._tag).toBe("NotFoundError")
|
||||
expect(error.message).toContain(path.join(...root, "missing", "value") + ".json")
|
||||
const exit = yield* svc.read([...root, "missing", "value"]).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("update on missing key throws NotFoundError", () =>
|
||||
Effect.gen(function* () {
|
||||
const { root, svc } = yield* scope()
|
||||
const error = yield* Effect.flip(
|
||||
svc.update<{ value: number }>([...root, "missing", "key"], (draft) => {
|
||||
const exit = yield* svc
|
||||
.update<{ value: number }>([...root, "missing", "key"], (draft) => {
|
||||
draft.value += 1
|
||||
}),
|
||||
)
|
||||
expect(error).toBeInstanceOf(Storage.NotFoundError)
|
||||
expect(error._tag).toBe("NotFoundError")
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1263,7 +1263,10 @@ export type Config = {
|
||||
}
|
||||
instructions?: Array<string>
|
||||
layout?: LayoutConfig
|
||||
permission?: PermissionConfig
|
||||
/**
|
||||
* Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.
|
||||
*/
|
||||
permission?: PermissionConfig | Array<PermissionConfig>
|
||||
tools?: {
|
||||
[key: string]: boolean
|
||||
}
|
||||
|
||||
@@ -12407,7 +12407,18 @@
|
||||
"$ref": "#/components/schemas/LayoutConfig"
|
||||
},
|
||||
"permission": {
|
||||
"$ref": "#/components/schemas/PermissionConfig"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PermissionConfig"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PermissionConfig"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones."
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { onMount, splitProps, type ComponentProps } from "solid-js"
|
||||
import { splitProps, type ComponentProps } from "solid-js"
|
||||
|
||||
const icons = {
|
||||
"align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
@@ -105,41 +105,6 @@ const icons = {
|
||||
"arrow-undo-down": `<path d="M4.08333 11.0859L1.75 8.7526L4.08333 6.41927M2.33333 8.7526L12.5417 8.7526L12.5417 3.21094L7 3.21094" stroke="currentColor" stroke-width="1" stroke-linecap="square"/>`,
|
||||
}
|
||||
|
||||
const spriteID = "opencode-icon-sprite"
|
||||
const symbol = (name: keyof typeof icons) => `opencode-icon-${name}`
|
||||
let spriteInserted = false
|
||||
|
||||
function viewBox(name: keyof typeof icons) {
|
||||
return name === "magnifying-glass" || name === "arrow-undo-down" ? "0 0 16 16" : "0 0 20 20"
|
||||
}
|
||||
|
||||
function ensureSprite() {
|
||||
if (spriteInserted) return
|
||||
if (typeof document === "undefined") return
|
||||
if (document.getElementById(spriteID)) {
|
||||
spriteInserted = true
|
||||
return
|
||||
}
|
||||
const body = document.body as HTMLElement | null
|
||||
if (!body) return
|
||||
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
|
||||
svg.id = spriteID
|
||||
svg.setAttribute("aria-hidden", "true")
|
||||
svg.setAttribute("width", "0")
|
||||
svg.setAttribute("height", "0")
|
||||
svg.style.position = "absolute"
|
||||
svg.style.overflow = "hidden"
|
||||
svg.innerHTML = Object.entries(icons)
|
||||
.map(([name, path]) => {
|
||||
const key = name as keyof typeof icons
|
||||
return `<symbol id="${symbol(key)}" viewBox="${viewBox(key)}">${path}</symbol>`
|
||||
})
|
||||
.join("")
|
||||
body.insertBefore(svg, body.firstChild)
|
||||
spriteInserted = true
|
||||
}
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
name: keyof typeof icons
|
||||
size?: "small" | "normal" | "medium" | "large"
|
||||
@@ -147,8 +112,8 @@ export interface IconProps extends ComponentProps<"svg"> {
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
|
||||
onMount(ensureSprite)
|
||||
|
||||
const viewBox = () =>
|
||||
local.name === "magnifying-glass" || local.name === "arrow-undo-down" ? "0 0 16 16" : "0 0 20 20"
|
||||
return (
|
||||
<div data-component="icon" data-size={local.size || "normal"}>
|
||||
<svg
|
||||
@@ -158,12 +123,11 @@ export function Icon(props: IconProps) {
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
fill="none"
|
||||
viewBox={viewBox(local.name)}
|
||||
viewBox={viewBox()}
|
||||
innerHTML={icons[local.name as keyof typeof icons]}
|
||||
aria-hidden="true"
|
||||
{...others}
|
||||
>
|
||||
<use href={`#${symbol(local.name)}`} />
|
||||
</svg>
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user