Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7acb326a5a | ||
|
|
e1c1193f3e | ||
|
|
29250a0efb | ||
|
|
c6e6bdf59f | ||
|
|
d80e1199ca | ||
|
|
10ea59066f | ||
|
|
79d6b10d7c | ||
|
|
6e78f36a0f | ||
|
|
16866e1180 | ||
|
|
6d130e5deb | ||
|
|
e30d8173c1 |
@@ -1,4 +1,5 @@
|
||||
import { Config } from "effect"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
@@ -10,6 +11,13 @@ function falsy(key: string) {
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
// Channels where new experiments default to ON (unstable / internal users).
|
||||
// Stable channels (`prod`, `latest`) stay opt-in.
|
||||
const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
function unstableDefault(key: string) {
|
||||
return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel))
|
||||
}
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
@@ -48,6 +56,9 @@ export const Flag = {
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
|
||||
// Default-on for dev/beta/local; opt-in for stable. Set
|
||||
// OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on.
|
||||
OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"),
|
||||
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
|
||||
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
|
||||
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# OpenAPI Translation Cleanup Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface.
|
||||
|
||||
The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation.
|
||||
|
||||
## Current Culprit
|
||||
|
||||
`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility.
|
||||
|
||||
The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime.
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- Do not break the generated JavaScript SDK without an explicit versioned migration plan.
|
||||
- Runtime route schemas are the source of truth for accepted params, payloads, and responses.
|
||||
- `/doc`, generated SDK types, and runtime validation must agree for every endpoint.
|
||||
- Prefer endpoint or schema annotations over post-generation spec surgery.
|
||||
- Remove one category of rewrite at a time, with focused compatibility checks.
|
||||
|
||||
## PR Checklist
|
||||
|
||||
Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started.
|
||||
|
||||
Current combined PR scope:
|
||||
|
||||
- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`.
|
||||
- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes.
|
||||
- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs.
|
||||
|
||||
### PR 1: Add OpenAPI/Runtime Query Drift Tests
|
||||
|
||||
- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`.
|
||||
- `[x]` Import `OpenApi.fromApi` and `PublicApi`.
|
||||
- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`.
|
||||
- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes.
|
||||
- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema.
|
||||
- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params.
|
||||
- `[x]` Keep this part test-only.
|
||||
|
||||
Verification:
|
||||
|
||||
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 2: Delete Spec-Only Workspace Query Injection
|
||||
|
||||
- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||
- `[x]` Delete `InstanceQueryParameters`.
|
||||
- `[x]` Delete the `isInstanceRoute` constant.
|
||||
- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation.
|
||||
- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`.
|
||||
- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes.
|
||||
- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only.
|
||||
|
||||
Notes:
|
||||
|
||||
- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params.
|
||||
- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing.
|
||||
|
||||
Expected code shape:
|
||||
|
||||
```ts
|
||||
for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
|
||||
```
|
||||
|
||||
Verification:
|
||||
|
||||
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
|
||||
- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `[x]` `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK.
|
||||
- `[x]` `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers
|
||||
|
||||
- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
|
||||
- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time.
|
||||
- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`.
|
||||
- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`.
|
||||
- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform.
|
||||
- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter.
|
||||
- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly.
|
||||
|
||||
Verification:
|
||||
|
||||
- Focused HTTP tests for changed query fields.
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated SDK request param types before deleting each override.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 4: Move Path Parameter Patterns Into ID Schemas
|
||||
|
||||
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
|
||||
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
|
||||
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
|
||||
- Delete one path override only after generated OpenAPI is unchanged for that param.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- `sessionID`
|
||||
- `messageID`
|
||||
- `partID`
|
||||
- `permissionID`
|
||||
- `ptyID`
|
||||
|
||||
Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated path param types and patterns.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 5: Replace Built-In Error Rewrites With Declared API Errors
|
||||
|
||||
- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`.
|
||||
- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there.
|
||||
- Update handlers to fail with the declared API errors at the boundary.
|
||||
- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible.
|
||||
- Do this group by group, starting with one small route group.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- `groups/config.ts` `PATCH /config` bad request.
|
||||
- `groups/session.ts` endpoints that already translate domain not-found errors.
|
||||
- `groups/file.ts` if any handler currently relies on built-in error shape.
|
||||
|
||||
Verification:
|
||||
|
||||
- Focused HTTP tests asserting response body shape for changed error paths.
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect SDK error union diff.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
|
||||
### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It
|
||||
|
||||
- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`.
|
||||
- Decide whether SDK should expose auth in generated operation metadata.
|
||||
- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code.
|
||||
- If removing it, update SDK generation expectations and docs in the same PR.
|
||||
|
||||
Verification:
|
||||
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated client call signatures and error unions.
|
||||
- Do not merge if auth churn changes normal SDK call ergonomics unintentionally.
|
||||
|
||||
### PR 7: Tackle Component Shape Rewrites One At A Time
|
||||
|
||||
- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`.
|
||||
- For each rewrite, make a tiny PR that removes or narrows only that rewrite.
|
||||
- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially.
|
||||
- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source.
|
||||
- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated SDK type-name and optionality diffs.
|
||||
|
||||
## Upstream Middleware Query Support
|
||||
|
||||
Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation.
|
||||
|
||||
Target in `effect-smol`:
|
||||
|
||||
- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation.
|
||||
- Make runtime request decoding include middleware query schemas.
|
||||
- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware.
|
||||
|
||||
Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`.
|
||||
|
||||
## Suggested PR Order
|
||||
|
||||
1. Add drift detection tests only.
|
||||
2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas.
|
||||
3. Convert query type overrides into route/schema-level helpers where possible.
|
||||
4. Convert path parameter overrides into schema annotations or upstream fixes.
|
||||
5. Replace built-in error response rewrites with explicit declared API errors by route group.
|
||||
6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable.
|
||||
|
||||
## Verification Checklist Per PR
|
||||
|
||||
- Focused HTTP tests for changed routes.
|
||||
- OpenAPI drift tests.
|
||||
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
|
||||
- `./packages/sdk/js/script/build.ts` from repo root.
|
||||
- Inspect generated SDK diff for public API churn.
|
||||
- `bun typecheck` from `packages/opencode`.
|
||||
@@ -2,13 +2,10 @@ export * as ConfigParse from "./parse"
|
||||
|
||||
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
|
||||
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import z from "zod"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { InvalidError, JsonError } from "./error"
|
||||
|
||||
const log = Log.create({ service: "config.parse" })
|
||||
|
||||
type ZodSchema<T> = z.ZodType<T>
|
||||
|
||||
export function jsonc(text: string, filepath: string): unknown {
|
||||
@@ -53,70 +50,34 @@ export function effectSchema<S extends EffectSchema.Decoder<unknown, never>>(
|
||||
data: unknown,
|
||||
source: string,
|
||||
): DeepMutable<S["Type"]> {
|
||||
// The user's config lives on disk and may legitimately be stale, hand-edited,
|
||||
// or carry leftover keys from older versions. Crashing the whole load on a
|
||||
// single bad field would make opencode unstartable for those users (see Ben
|
||||
// Matthews / Discord, v1.14.45). Strip the malformed top-level fields and
|
||||
// keep going — log every drop so users can see what was ignored and fix it.
|
||||
const cleaned = stripUnknownTopLevelKeys(schema, data, source)
|
||||
return decodeWithFieldTolerance(schema, cleaned, source)
|
||||
}
|
||||
|
||||
function stripUnknownTopLevelKeys(schema: EffectSchema.Top, data: unknown, source: string): unknown {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) return data
|
||||
const extra = topLevelExtraKeys(schema, data)
|
||||
if (extra.length === 0) return data
|
||||
log.warn("ignoring unrecognized config keys", { source, keys: extra })
|
||||
const obj = data as Record<string, unknown>
|
||||
return Object.fromEntries(Object.entries(obj).filter(([key]) => !extra.includes(key)))
|
||||
}
|
||||
if (extra.length) {
|
||||
throw new InvalidError({
|
||||
path: source,
|
||||
issues: [
|
||||
{
|
||||
code: "unrecognized_keys",
|
||||
keys: extra,
|
||||
path: [],
|
||||
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
|
||||
} as z.core.$ZodIssue,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function decodeWithFieldTolerance<S extends EffectSchema.Decoder<unknown, never>>(
|
||||
schema: S,
|
||||
data: unknown,
|
||||
source: string,
|
||||
): DeepMutable<S["Type"]> {
|
||||
// Try a clean decode first. If it succeeds we're done — common path.
|
||||
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
|
||||
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
|
||||
const error = Cause.squash(decoded.cause)
|
||||
const issues = EffectSchema.isSchemaError(error)
|
||||
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
|
||||
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[])
|
||||
|
||||
// Identify malformed top-level fields. Anything with a non-empty path is a
|
||||
// field-scoped issue we can drop and retry. Issues with an empty path are
|
||||
// root-level (e.g. data is not an object at all) and can't be field-recovered.
|
||||
const badFields = collectTopLevelFieldNames(issues)
|
||||
if (badFields.size === 0 || typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
throw new InvalidError({ path: source, issues }, { cause: error })
|
||||
}
|
||||
|
||||
log.warn("ignoring invalid config fields", {
|
||||
source,
|
||||
fields: [...badFields],
|
||||
summary: issues
|
||||
.filter((issue) => issue.path && issue.path.length > 0)
|
||||
.map((issue) => `${issue.path?.join(".")}: ${issue.message}`)
|
||||
.slice(0, 8),
|
||||
})
|
||||
|
||||
const obj = data as Record<string, unknown>
|
||||
const cleaned = Object.fromEntries(Object.entries(obj).filter(([key]) => !badFields.has(key)))
|
||||
// Retry without the bad fields. If THIS fails, we're past field-tolerance —
|
||||
// fall back to the original strict error so the user sees the real cause.
|
||||
const retry = EffectSchema.decodeUnknownExit(schema)(cleaned, { errors: "all", propertyOrder: "original" })
|
||||
if (Exit.isSuccess(retry)) return retry.value as DeepMutable<S["Type"]>
|
||||
throw new InvalidError({ path: source, issues }, { cause: error })
|
||||
}
|
||||
|
||||
function collectTopLevelFieldNames(issues: z.core.$ZodIssue[]): Set<string> {
|
||||
const names = new Set<string>()
|
||||
for (const issue of issues) {
|
||||
const head = issue.path?.[0]
|
||||
if (typeof head === "string") names.add(head)
|
||||
}
|
||||
return names
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: source,
|
||||
issues: EffectSchema.isSchemaError(error)
|
||||
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
|
||||
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]),
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
||||
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "*.md" {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ToolSchema,
|
||||
type Tool as MCPToolDef,
|
||||
ToolListChangedNotificationSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
@@ -36,6 +37,15 @@ import { withStatics } from "@opencode-ai/core/schema"
|
||||
const log = Log.create({ service: "mcp" })
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
const TolerantToolSchema = ToolSchema.extend({
|
||||
outputSchema: z.unknown().optional(),
|
||||
})
|
||||
|
||||
const TolerantListToolsResultSchema = z.looseObject({
|
||||
tools: z.array(TolerantToolSchema),
|
||||
nextCursor: z.string().optional(),
|
||||
})
|
||||
|
||||
export const Resource = Schema.Struct({
|
||||
name: Schema.String,
|
||||
uri: Schema.String,
|
||||
@@ -119,6 +129,38 @@ function remoteURL(key: string, value: string) {
|
||||
log.warn("invalid remote mcp url", { key })
|
||||
}
|
||||
|
||||
function isOutputSchemaValidationError(error: Error) {
|
||||
return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
|
||||
error.message,
|
||||
)
|
||||
}
|
||||
|
||||
function listTools(key: string, client: MCPClient, timeout: number) {
|
||||
return Effect.tryPromise({
|
||||
try: () => client.listTools(undefined, { timeout }),
|
||||
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
||||
}).pipe(
|
||||
Effect.map((result) => result.tools),
|
||||
Effect.catch((error) => {
|
||||
if (!isOutputSchemaValidationError(error)) return Effect.fail(error)
|
||||
|
||||
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error })
|
||||
return Effect.tryPromise({
|
||||
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
|
||||
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
result.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Convert MCP tool definition to AI SDK Tool type
|
||||
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
|
||||
const inputSchema = mcpTool.inputSchema
|
||||
@@ -151,11 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
|
||||
}
|
||||
|
||||
function defs(key: string, client: MCPClient, timeout?: number) {
|
||||
return Effect.tryPromise({
|
||||
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
|
||||
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
||||
}).pipe(
|
||||
Effect.map((result) => result.tools),
|
||||
return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
|
||||
Effect.catch((err) => {
|
||||
log.error("failed to get tools from client", { key, error: err })
|
||||
return Effect.succeed(undefined)
|
||||
|
||||
@@ -234,8 +234,8 @@ export const FileDiff = Schema.Struct({
|
||||
// populates patch, but loosening matches the sibling schema so a
|
||||
// future code path that omits it can't crash /instance/vcs/diff.
|
||||
patch: Schema.optional(Schema.String),
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
additions: Schema.Finite,
|
||||
deletions: Schema.Finite,
|
||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||
})
|
||||
.annotate({ identifier: "VcsFileDiff" })
|
||||
@@ -244,8 +244,8 @@ export type FileDiff = Schema.Schema.Type<typeof FileDiff>
|
||||
|
||||
export const FileStatus = Schema.Struct({
|
||||
file: Schema.String,
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
additions: Schema.Finite,
|
||||
deletions: Schema.Finite,
|
||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||
})
|
||||
.annotate({ identifier: "VcsFileStatus" })
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as Stream from "effect/Stream"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { WorkspaceRoutingQuery } from "./middleware/workspace-routing"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add(
|
||||
HttpApiGroup.make("event")
|
||||
.add(
|
||||
HttpApiEndpoint.get("subscribe", EventPaths.event, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/config"
|
||||
@@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config")
|
||||
HttpApiGroup.make("config")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Config.Info, "Get config info"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("update", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Config.Info,
|
||||
success: described(Config.Info, "Successfully updated config"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Provider.ConfigProvidersResult, "List of providers"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -4,12 +4,17 @@ import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
import { QueryBoolean } from "./query"
|
||||
|
||||
const ConsoleStateResponse = Schema.Struct({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
@@ -48,12 +53,6 @@ export const ToolListQuery = Schema.Struct({
|
||||
model: ModelID,
|
||||
})
|
||||
|
||||
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
const WorktreeList = Schema.Array(Schema.String)
|
||||
export const SessionListQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
@@ -82,6 +81,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
HttpApiGroup.make("experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
||||
error: HttpApiError.InternalServerError,
|
||||
}).annotateMerge(
|
||||
@@ -92,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
||||
error: HttpApiError.InternalServerError,
|
||||
}).annotateMerge(
|
||||
@@ -102,6 +103,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ConsoleSwitchPayload,
|
||||
success: described(Schema.Boolean, "Switch success"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -125,6 +127,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ToolIDs, "Tool IDs"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
@@ -136,6 +139,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(WorktreeList, "List of worktree directories"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -145,6 +149,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.optional(Worktree.CreateInput),
|
||||
success: described(Worktree.Info, "Worktree created"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -156,6 +161,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Worktree.RemoveInput,
|
||||
success: described(Schema.Boolean, "Worktree removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -167,6 +173,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Worktree.ResetInput,
|
||||
success: described(Schema.Boolean, "Worktree reset"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -189,6 +196,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
export const FileQuery = Schema.Struct({
|
||||
@@ -97,6 +101,7 @@ export const FileApi = HttpApi.make("file")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", FilePaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(File.Info), "File status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -8,7 +8,11 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const PathInfo = Schema.Struct({
|
||||
@@ -55,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
HttpApiGroup.make("instance")
|
||||
.add(
|
||||
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Instance disposed"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -64,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("path", InstancePaths.path, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: PathInfo,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -74,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Vcs.Info, "VCS info"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -84,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -103,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(
|
||||
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
|
||||
"Raw VCS diff",
|
||||
@@ -115,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Vcs.ApplyInput,
|
||||
success: described(Vcs.ApplyResult, "VCS patch applied"),
|
||||
error: ApiVcsApplyError,
|
||||
@@ -126,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("command", InstancePaths.command, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Command.Info), "List of commands"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -135,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("agent", InstancePaths.agent, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Agent.Info), "List of agents"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -144,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("skill", InstancePaths.skill, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Skill.Info), "List of skills"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -153,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(LSP.Status), "LSP server status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -162,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Format.Status), "Formatter status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
export const AddPayload = Schema.Struct({
|
||||
@@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
HttpApiGroup.make("mcp")
|
||||
.add(
|
||||
HttpApiEndpoint.get("status", McpPaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("add", McpPaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: AddPayload,
|
||||
success: described(StatusMap, "MCP server added successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("authStart", McpPaths.auth, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(AuthStartResponse, "OAuth flow started"),
|
||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: AuthCallbackPayload,
|
||||
success: described(MCP.Status, "OAuth authentication completed"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(MCP.Status, "OAuth authentication completed"),
|
||||
error: [UnsupportedOAuthError, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(AuthRemoveResponse, "OAuth credentials removed"),
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
@@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "MCP server connected successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp")
|
||||
),
|
||||
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
|
||||
params: { name: Schema.String },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "MCP server disconnected successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/permission"
|
||||
@@ -18,6 +18,7 @@ export const PermissionApi = HttpApi.make("permission")
|
||||
HttpApiGroup.make("permission")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -28,6 +29,7 @@ export const PermissionApi = HttpApi.make("permission")
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: PermissionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ReplyPayload,
|
||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/project"
|
||||
@@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
HttpApiGroup.make("project")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Project.Info), "List of projects"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("current", `${root}/current`, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Project.Info, "Current project information"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Project.Info, "Project information after git initialization"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
),
|
||||
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
|
||||
params: { projectID: ProjectID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: UpdatePayload,
|
||||
success: described(Project.Info, "Updated project information"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/provider"
|
||||
@@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider")
|
||||
HttpApiGroup.make("provider")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Provider.ListResult, "List of providers"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("auth", `${root}/auth`, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ProviderAuth.Methods, "Provider auth methods"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider")
|
||||
),
|
||||
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
|
||||
params: { providerID: ProviderID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ProviderAuth.AuthorizeInput,
|
||||
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider")
|
||||
),
|
||||
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
|
||||
params: { providerID: ProviderID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ProviderAuth.CallbackInput,
|
||||
success: described(Schema.Boolean, "OAuth callback processed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
|
||||
@@ -37,6 +41,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
HttpApiGroup.make("pty")
|
||||
.add(
|
||||
HttpApiEndpoint.get("shells", PtyPaths.shells, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(ShellItem), "List of shells"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -46,6 +51,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", PtyPaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Pty.Info), "List of sessions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -55,6 +61,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", PtyPaths.create, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Pty.CreateInput,
|
||||
success: described(Pty.Info, "Created session"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -67,6 +74,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
),
|
||||
HttpApiEndpoint.get("get", PtyPaths.get, {
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Pty.Info, "Session info"),
|
||||
error: ApiNotFoundError,
|
||||
}).annotateMerge(
|
||||
@@ -78,6 +86,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
),
|
||||
HttpApiEndpoint.put("update", PtyPaths.update, {
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Pty.UpdateInput,
|
||||
success: described(Pty.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
@@ -90,6 +99,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Session removed"),
|
||||
error: ApiNotFoundError,
|
||||
}).annotateMerge(
|
||||
@@ -101,6 +111,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
),
|
||||
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
|
||||
params: { ptyID: PtyID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
|
||||
error: [HttpApiError.Forbidden, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -129,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
.add(
|
||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||
params: Params,
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Connected session"),
|
||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
|
||||
export const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/question"
|
||||
@@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question")
|
||||
HttpApiGroup.make("question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Question.Request), "List of pending questions"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question")
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: QuestionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ReplyPayload,
|
||||
success: described(Schema.Boolean, "Question answered successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question")
|
||||
),
|
||||
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
|
||||
params: { requestID: QuestionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Question rejected successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -10,21 +10,20 @@ import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Schema, SchemaGetter, Struct } from "effect"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
import { QueryBoolean } from "./query"
|
||||
|
||||
const root = "/session"
|
||||
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
export const ListQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
scope: Schema.optional(Schema.Literals(["project"])),
|
||||
@@ -116,6 +115,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", SessionPaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(StatusMap, "Get session status"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
@@ -127,6 +127,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.get("get", SessionPaths.get, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Session.Info, "Get session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -138,6 +139,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.get("children", SessionPaths.children, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Session.Info), "List of children"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -149,6 +151,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.get("todo", SessionPaths.todo, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Todo.Info), "Todo list"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -183,6 +186,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.get("message", SessionPaths.message, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(MessageV2.WithParts, "Message"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -193,6 +197,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", SessionPaths.create, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: [HttpApiSchema.NoContent, Session.CreateInput],
|
||||
success: described(Session.Info, "Successfully created session"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -205,6 +210,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Successfully deleted session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -216,6 +222,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.patch("update", SessionPaths.update, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: UpdatePayload,
|
||||
success: described(Session.Info, "Successfully updated session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
@@ -228,6 +235,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ForkPayload,
|
||||
success: described(Session.Info, "200"),
|
||||
error: ApiNotFoundError,
|
||||
@@ -240,6 +248,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("abort", SessionPaths.abort, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Aborted session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -251,6 +260,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("init", SessionPaths.init, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: InitPayload,
|
||||
success: described(Schema.Boolean, "200"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -264,6 +274,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("share", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Session.Info, "Successfully shared session"),
|
||||
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -275,6 +286,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Session.Info, "Successfully unshared session"),
|
||||
error: [HttpApiError.InternalServerError, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
@@ -286,6 +298,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: SummarizePayload,
|
||||
success: described(Schema.Boolean, "Summarized session"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
@@ -298,6 +311,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: PromptPayload,
|
||||
success: described(MessageV2.WithParts, "Created message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -310,6 +324,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: PromptPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -323,6 +338,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("command", SessionPaths.command, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: CommandPayload,
|
||||
success: described(MessageV2.WithParts, "Created message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -335,6 +351,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("shell", SessionPaths.shell, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ShellPayload,
|
||||
success: described(MessageV2.WithParts, "Created message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -347,6 +364,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("revert", SessionPaths.revert, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: RevertPayload,
|
||||
success: described(Session.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -360,6 +378,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Session.Info, "Updated session"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -371,6 +390,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
|
||||
params: { sessionID: SessionID, permissionID: PermissionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: PermissionResponsePayload,
|
||||
success: described(Schema.Boolean, "Permission processed successfully"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
@@ -384,6 +404,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
|
||||
params: { sessionID: SessionID, messageID: MessageID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Successfully deleted message"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -396,6 +417,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Successfully deleted part"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
@@ -406,6 +428,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
),
|
||||
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
|
||||
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: MessageV2.Part,
|
||||
success: described(MessageV2.Part, "Successfully updated part"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/sync"
|
||||
@@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync")
|
||||
HttpApiGroup.make("sync")
|
||||
.add(
|
||||
HttpApiEndpoint.post("start", SyncPaths.start, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Workspace sync started"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("replay", SyncPaths.replay, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ReplayPayload,
|
||||
success: described(ReplayResponse, "Replayed sync events"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("steal", SyncPaths.steal, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: SessionPayload,
|
||||
success: described(SessionPayload, "Session stolen into workspace"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("history", SyncPaths.history, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: HistoryPayload,
|
||||
success: described(Schema.Array(HistoryEvent), "Sync events"),
|
||||
error: HttpApiError.BadRequest,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { ApiNotFoundError } from "../errors"
|
||||
import { described } from "./metadata"
|
||||
|
||||
@@ -54,6 +54,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
HttpApiGroup.make("tui")
|
||||
.add(
|
||||
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiEvent.PromptAppend.properties,
|
||||
success: described(Schema.Boolean, "Prompt processed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -65,6 +66,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Help dialog opened successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -74,6 +76,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Session dialog opened successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -83,6 +86,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Theme dialog opened successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -92,6 +96,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("openModels", TuiPaths.openModels, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Model dialog opened successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -101,6 +106,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Prompt submitted successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -110,6 +116,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Boolean, "Prompt cleared successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -119,6 +126,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: CommandPayload,
|
||||
success: described(Schema.Boolean, "Command executed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -130,6 +138,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiEvent.ToastShow.properties,
|
||||
success: described(Schema.Boolean, "Toast notification shown successfully"),
|
||||
}).annotateMerge(
|
||||
@@ -140,6 +149,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("publish", TuiPaths.publish, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiPublishPayload,
|
||||
success: described(Schema.Boolean, "Event published successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -151,6 +161,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiEvent.SessionSelect.properties,
|
||||
success: described(Schema.Boolean, "Session selected successfully"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
@@ -162,6 +173,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(TuiRequestPayload, "Next TUI request"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -171,6 +183,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.Unknown,
|
||||
success: described(Schema.Boolean, "Response submitted successfully"),
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -3,46 +3,31 @@ import { SessionMessage } from "@/v2/session-message"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
|
||||
|
||||
export const MessagesQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
|
||||
).annotate({
|
||||
description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
cursor: Schema.optional(
|
||||
Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "V2SessionMessagesQuery" })
|
||||
|
||||
export const MessageGroup = HttpApiGroup.make("v2.message")
|
||||
.add(
|
||||
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
|
||||
params: { sessionID: SessionID },
|
||||
query: Schema.Union([
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description:
|
||||
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
cursor: Schema.optional(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description:
|
||||
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
|
||||
}),
|
||||
cursor: Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Never),
|
||||
}),
|
||||
]).annotate({ identifier: "V2SessionMessagesQuery" }),
|
||||
query: MessagesQuery,
|
||||
success: Schema.Struct({
|
||||
items: Schema.Array(SessionMessage.Message),
|
||||
cursor: Schema.Struct({
|
||||
|
||||
@@ -1,67 +1,39 @@
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@/v2/session-message"
|
||||
import { Prompt } from "@/v2/session-prompt"
|
||||
import { SessionV2 } from "@/v2/session"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
|
||||
import { QueryBoolean } from "../query"
|
||||
|
||||
export const SessionsQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
|
||||
).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
path: Schema.optional(Schema.String),
|
||||
roots: Schema.optional(QueryBoolean),
|
||||
start: Schema.optional(Schema.NumberFromString),
|
||||
search: Schema.optional(Schema.String),
|
||||
cursor: Schema.optional(
|
||||
Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.",
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "V2SessionsQuery" })
|
||||
|
||||
export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("sessions", "/api/session", {
|
||||
query: Schema.Union([
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
directory: Schema.String.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
workspace: WorkspaceID.pipe(Schema.optional),
|
||||
roots: Schema.Literals(["true", "false"])
|
||||
.pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
.pipe(Schema.optional),
|
||||
start: Schema.NumberFromString.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
cursor: Schema.optional(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
limit: Schema.optional(
|
||||
Schema.NumberFromString.check(
|
||||
Schema.isInt(),
|
||||
Schema.isGreaterThanOrEqualTo(1),
|
||||
Schema.isLessThanOrEqualTo(200),
|
||||
),
|
||||
).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
cursor: Schema.String.annotate({
|
||||
description:
|
||||
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Never),
|
||||
directory: Schema.optional(Schema.Never),
|
||||
path: Schema.optional(Schema.Never),
|
||||
workspace: Schema.optional(Schema.Never),
|
||||
roots: Schema.optional(Schema.Never),
|
||||
start: Schema.optional(Schema.Never),
|
||||
search: Schema.optional(Schema.Never),
|
||||
}),
|
||||
]).annotate({ identifier: "V2SessionsQuery" }),
|
||||
query: SessionsQuery,
|
||||
success: Schema.Struct({
|
||||
items: Schema.Array(SessionV2.Info),
|
||||
cursor: Schema.Struct({
|
||||
@@ -82,6 +54,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.Struct({
|
||||
prompt: Prompt,
|
||||
delivery: SessionV2.Delivery.pipe(Schema.optional),
|
||||
@@ -98,6 +71,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -110,6 +84,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -122,6 +97,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.Array(SessionMessage.Message),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op
|
||||
import { ApiVcsApplyError } from "./instance"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/experimental/workspace"
|
||||
@@ -40,6 +40,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
HttpApiGroup.make("workspace")
|
||||
.add(
|
||||
HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -49,6 +50,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("list", WorkspacePaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Workspace.Info), "Workspaces"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -58,6 +60,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", WorkspacePaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: CreatePayload,
|
||||
success: described(Workspace.Info, "Workspace created"),
|
||||
error: HttpApiError.BadRequest,
|
||||
@@ -69,6 +72,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(HttpApiSchema.NoContent, "Workspace list synced"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -78,6 +82,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", WorkspacePaths.status, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -88,6 +93,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
|
||||
params: { id: Workspace.Info.fields.id },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
@@ -98,6 +104,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: WarpPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Session warped"),
|
||||
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
|
||||
|
||||
@@ -34,6 +34,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
|
||||
return handlers.handle(
|
||||
"messages",
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({})
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
|
||||
@@ -22,6 +22,31 @@ type SessionCursor = typeof SessionCursor.Type
|
||||
|
||||
const decodeCursor = Schema.decodeUnknownSync(SessionCursor)
|
||||
|
||||
function hasCursorFilter(query: {
|
||||
readonly order?: unknown
|
||||
readonly path?: unknown
|
||||
readonly roots?: unknown
|
||||
readonly start?: unknown
|
||||
readonly search?: unknown
|
||||
}) {
|
||||
return (
|
||||
query.order !== undefined ||
|
||||
query.path !== undefined ||
|
||||
query.roots !== undefined ||
|
||||
query.start !== undefined ||
|
||||
query.search !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function hasCursorRoutingMismatch(
|
||||
query: { readonly directory?: string; readonly workspace?: string },
|
||||
decoded: SessionCursor | undefined,
|
||||
) {
|
||||
if (!decoded) return false
|
||||
if (query.directory !== undefined && query.directory !== decoded.directory) return true
|
||||
return query.workspace !== undefined && query.workspace !== decoded.workspaceID
|
||||
}
|
||||
|
||||
const sessionCursor = {
|
||||
encode(
|
||||
session: SessionV2.Info,
|
||||
@@ -46,10 +71,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
.handle(
|
||||
"sessions",
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({})
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({})
|
||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||
const filters = decoded ?? {
|
||||
directory: ctx.query.directory,
|
||||
|
||||
@@ -24,6 +24,8 @@ export const WorkspaceRoutingQueryFields = {
|
||||
workspace: Schema.optional(Schema.String),
|
||||
}
|
||||
|
||||
export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields)
|
||||
|
||||
type RemoteTarget = Extract<Target, { type: "remote" }>
|
||||
|
||||
type RequestPlan = Data.TaggedEnum<{
|
||||
|
||||
@@ -51,47 +51,37 @@ type OpenApiResponse = {
|
||||
content?: Record<string, { schema?: OpenApiSchema }>
|
||||
}
|
||||
|
||||
// Instance routes use middleware for directory/workspace resolution, but HttpApi
|
||||
// doesn't surface middleware query params in the spec. Inject them explicitly.
|
||||
const InstanceQueryParameters = [
|
||||
{
|
||||
name: "directory",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
{
|
||||
name: "workspace",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
] satisfies OpenApiParameter[]
|
||||
|
||||
// Query schemas describe decoded Effect values, but the generated SDK needs the
|
||||
// public call shape. These keep SDK callers passing numbers/booleans while the
|
||||
// server still decodes string query params at runtime.
|
||||
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
|
||||
const QueryBooleanParameters = new Set(["roots", "archived"])
|
||||
const QueryParameterSchemas = {
|
||||
const QueryParameterSchemas: Record<string, OpenApiSchema> = {
|
||||
"GET /experimental/session start": { type: "number" },
|
||||
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
|
||||
"GET /experimental/session cursor": { type: "number" },
|
||||
"GET /experimental/session limit": { type: "number" },
|
||||
"GET /session start": { type: "number" },
|
||||
"GET /session limit": { type: "number" },
|
||||
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
|
||||
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
} satisfies Record<string, OpenApiSchema>
|
||||
"GET /api/session limit": { type: "number" },
|
||||
"GET /api/session start": { type: "number" },
|
||||
"GET /api/session/{sessionID}/message limit": { type: "number" },
|
||||
}
|
||||
|
||||
const PathParameterSchemas = {
|
||||
const PathParameterSchemas: Record<string, OpenApiSchema> = {
|
||||
sessionID: { type: "string", pattern: "^ses.*" },
|
||||
messageID: { type: "string", pattern: "^msg.*" },
|
||||
partID: { type: "string", pattern: "^prt.*" },
|
||||
permissionID: { type: "string", pattern: "^per.*" },
|
||||
ptyID: { type: "string", pattern: "^pty.*" },
|
||||
} satisfies Record<string, OpenApiSchema>
|
||||
}
|
||||
|
||||
const LegacyComponentDescriptions = {
|
||||
const LegacyComponentDescriptions: Record<string, string> = {
|
||||
LogLevel: "Log level",
|
||||
ServerConfig: "Server configuration for opencode serve and web commands",
|
||||
LayoutConfig: "@deprecated Always uses stretch layout.",
|
||||
} satisfies Record<string, string>
|
||||
}
|
||||
|
||||
function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
const spec = input as OpenApiSpec
|
||||
@@ -122,7 +112,6 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
delete spec.components?.securitySchemes
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
||||
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
if (!operation) continue
|
||||
@@ -183,14 +172,8 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
|
||||
},
|
||||
}
|
||||
}
|
||||
if (!isInstanceRoute) continue
|
||||
operation.parameters = [
|
||||
...InstanceQueryParameters,
|
||||
...(operation.parameters ?? []).filter(
|
||||
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
|
||||
),
|
||||
]
|
||||
for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
|
||||
const route = `${method.toUpperCase()} ${path}`
|
||||
for (const param of operation.parameters ?? []) normalizeParameter(param, route)
|
||||
}
|
||||
}
|
||||
return input
|
||||
@@ -292,7 +275,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) {
|
||||
|
||||
function normalizeComponentDescriptions(spec: OpenApiSpec) {
|
||||
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
|
||||
const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions]
|
||||
const description = LegacyComponentDescriptions[name]
|
||||
if (description) {
|
||||
schema.description = description
|
||||
continue
|
||||
@@ -438,7 +421,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) {
|
||||
}
|
||||
}
|
||||
// Simplest fix: generate the raw spec (without transform) to get correct schemas
|
||||
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec
|
||||
const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi)
|
||||
const rawSchemas = raw.components?.schemas
|
||||
if (!rawSchemas) return
|
||||
for (const name of selfRefs) {
|
||||
@@ -507,15 +490,11 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||
return
|
||||
}
|
||||
if (param.in === "query") {
|
||||
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
|
||||
const override = QueryParameterSchemas[`${route} ${param.name}`]
|
||||
if (override) {
|
||||
param.schema = override
|
||||
return
|
||||
}
|
||||
if (QueryNumberParameters.has(param.name)) {
|
||||
param.schema = { type: "number" }
|
||||
return
|
||||
}
|
||||
if (QueryBooleanParameters.has(param.name)) {
|
||||
param.schema = {
|
||||
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
|
||||
@@ -527,7 +506,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
|
||||
}
|
||||
|
||||
function pathParameterSchema(route: string, name: string) {
|
||||
if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas]
|
||||
if (name in PathParameterSchemas) return PathParameterSchemas[name]
|
||||
if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||
if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
|
||||
if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" }
|
||||
|
||||
@@ -2,7 +2,16 @@ import { Provider } from "@/provider/provider"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Context, Effect, Layer, Record } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
||||
import {
|
||||
streamText,
|
||||
wrapLanguageModel,
|
||||
type LanguageModelUsage,
|
||||
type ModelMessage,
|
||||
type ProviderMetadata as AiProviderMetadata,
|
||||
type Tool,
|
||||
tool,
|
||||
jsonSchema,
|
||||
} from "ai"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
@@ -27,7 +36,48 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
|
||||
const log = Log.create({ service: "llm" })
|
||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||
type Result = Awaited<ReturnType<typeof streamText>>
|
||||
export type ProviderMetadata = AiProviderMetadata
|
||||
export type Usage = LanguageModelUsage
|
||||
|
||||
export type ToolOutput = {
|
||||
title: string
|
||||
metadata: Record<string, any>
|
||||
output: string
|
||||
attachments?: MessageV2.FilePart[]
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| { type: "start" }
|
||||
| { type: "reasoning-start"; id: string; providerMetadata?: ProviderMetadata }
|
||||
| { type: "reasoning-delta"; id: string; text: string; providerMetadata?: ProviderMetadata }
|
||||
| { type: "reasoning-end"; id: string; providerMetadata?: ProviderMetadata }
|
||||
| { type: "tool-input-start"; id: string; toolName: string; providerExecuted?: boolean }
|
||||
| { type: "tool-input-delta"; id: string; delta?: string }
|
||||
| { type: "tool-input-end"; id: string }
|
||||
| { type: "tool-call"; toolCallId: string; toolName: string; input: any; providerMetadata?: ProviderMetadata }
|
||||
| { type: "tool-result"; toolCallId: string; output: ToolOutput }
|
||||
| { type: "tool-error"; toolCallId: string; error: unknown }
|
||||
| { type: "error"; error: unknown }
|
||||
| { type: "start-step" }
|
||||
| {
|
||||
type: "finish-step"
|
||||
finishReason: string
|
||||
rawFinishReason?: string
|
||||
usage: Usage
|
||||
response?: unknown
|
||||
providerMetadata?: ProviderMetadata
|
||||
}
|
||||
| { type: "text-start"; id?: string; providerMetadata?: ProviderMetadata }
|
||||
| { type: "text-delta"; id?: string; text: string; providerMetadata?: ProviderMetadata }
|
||||
| { type: "text-end"; id?: string; providerMetadata?: ProviderMetadata }
|
||||
| {
|
||||
type: "finish"
|
||||
finishReason?: string
|
||||
rawFinishReason?: string
|
||||
usage?: Usage
|
||||
totalUsage?: Usage
|
||||
providerMetadata?: ProviderMetadata
|
||||
}
|
||||
|
||||
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
|
||||
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
||||
@@ -52,8 +102,6 @@ export type StreamRequest = StreamInput & {
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
|
||||
|
||||
export interface Interface {
|
||||
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
||||
}
|
||||
@@ -427,7 +475,9 @@ const live: Layer.Layer<
|
||||
|
||||
const result = yield* run({ ...input, abort: ctrl.signal })
|
||||
|
||||
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
|
||||
return Stream.fromAsyncIterable(result.fullStream as AsyncIterable<Event>, (e) =>
|
||||
e instanceof Error ? e : new Error(String(e)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof Reasonin
|
||||
const filePartSourceBase = {
|
||||
text: Schema.Struct({
|
||||
value: Schema.String,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
start: Schema.Finite,
|
||||
end: Schema.Finite,
|
||||
}).annotate({ identifier: "FilePartSourceText" }),
|
||||
}
|
||||
|
||||
@@ -270,13 +270,13 @@ export const StepFinishPart = Schema.Struct({
|
||||
snapshot: Schema.optional(Schema.String),
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
total: Schema.optional(NonNegativeInt),
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
total: Schema.optional(Schema.Finite),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.Finite,
|
||||
cache: Schema.Struct({
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -554,13 +554,13 @@ export const Assistant = Schema.Struct({
|
||||
summary: Schema.optional(Schema.Boolean),
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
total: Schema.optional(NonNegativeInt),
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
total: Schema.optional(Schema.Finite),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.Finite,
|
||||
cache: Schema.Struct({
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
}),
|
||||
structured: Schema.optional(Schema.Any),
|
||||
|
||||
@@ -172,12 +172,12 @@ export const Info = Schema.Struct({
|
||||
cost: Schema.Finite,
|
||||
summary: Schema.optional(Schema.Boolean),
|
||||
tokens: Schema.Struct({
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.Finite,
|
||||
cache: Schema.Struct({
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -388,7 +388,7 @@ export const layer: Layer.Layer<
|
||||
text: value.output.output,
|
||||
},
|
||||
...(value.output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
type: "file",
|
||||
type: "file" as const,
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
name: item.filename,
|
||||
@@ -578,7 +578,7 @@ export const layer: Layer.Layer<
|
||||
return
|
||||
|
||||
default:
|
||||
slog.info("unhandled", { event: value.type, value })
|
||||
slog.info("unhandled", { event: (value as { type: string }).type, value })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
@@ -142,9 +142,9 @@ function sessionPath(worktree: string, cwd: string) {
|
||||
}
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
files: NonNegativeInt,
|
||||
additions: Schema.Finite,
|
||||
deletions: Schema.Finite,
|
||||
files: Schema.Finite,
|
||||
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
|
||||
})
|
||||
|
||||
@@ -353,7 +353,7 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
|
||||
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => {
|
||||
const safe = (value: number) => {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return value
|
||||
return Math.max(0, value)
|
||||
}
|
||||
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
||||
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ConfigMarkdown } from "@/config/markdown"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
|
||||
|
||||
const log = Log.create({ service: "skill" })
|
||||
const CLAUDE_EXTERNAL_DIR = ".claude"
|
||||
@@ -25,6 +26,15 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||
const SKILL_PATTERN = "**/SKILL.md"
|
||||
|
||||
// Built-in skill that ships with opencode. The model's intuition for what an
|
||||
// opencode.json should look like is often wrong, and opencode hard-fails on
|
||||
// invalid config, so users hit cryptic startup errors. Loading this skill
|
||||
// when the model is asked to touch opencode's own config files gives it the
|
||||
// actual schemas instead of guesses.
|
||||
const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode"
|
||||
const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION =
|
||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself."
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
@@ -230,6 +240,16 @@ export const layer = Layer.effect(
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Skill.state")(function* () {
|
||||
const s: State = { skills: {}, dirs: new Set() }
|
||||
// Register the built-in skill BEFORE disk discovery so a user-disk
|
||||
// skill with the same name can override it.
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) {
|
||||
s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = {
|
||||
name: CUSTOMIZE_OPENCODE_SKILL_NAME,
|
||||
description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION,
|
||||
location: "<built-in>",
|
||||
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
|
||||
}
|
||||
}
|
||||
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
|
||||
return s
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
<!--
|
||||
Built-in skill. Name and description are registered in code at
|
||||
packages/opencode/src/skill/index.ts (see SKILL_NAME and SKILL_DESCRIPTION).
|
||||
The body below becomes the skill's content.
|
||||
-->
|
||||
|
||||
# Customizing opencode
|
||||
|
||||
opencode validates its own config strictly and refuses to start when a field
|
||||
is wrong. The shapes below are the accepted shapes. When in doubt, fetch
|
||||
`https://opencode.ai/config.json` (the JSON Schema) and validate against it.
|
||||
|
||||
Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"`
|
||||
so the user's editor catches mistakes as they type.
|
||||
|
||||
## Where files live
|
||||
|
||||
| Scope | Path |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) |
|
||||
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
|
||||
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
|
||||
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
|
||||
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
|
||||
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
|
||||
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
|
||||
|
||||
Configs from each scope are deep-merged. Project overrides global. Unknown
|
||||
top-level keys in `opencode.json` are rejected with `ConfigInvalidError`.
|
||||
|
||||
## opencode.json
|
||||
|
||||
Every field is optional.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"username": "string",
|
||||
"model": "provider/model-id",
|
||||
"small_model": "provider/model-id",
|
||||
"default_agent": "agent-name",
|
||||
"shell": "/bin/zsh",
|
||||
"logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR",
|
||||
"share": "manual" | "auto" | "disabled",
|
||||
"autoupdate": true | false | "notify",
|
||||
"snapshot": true,
|
||||
"instructions": ["AGENTS.md", "docs/style.md"],
|
||||
|
||||
"skills": {
|
||||
"paths": [".opencode/skills", "/abs/path/to/skills"],
|
||||
"urls": ["https://example.com/.well-known/skills/"]
|
||||
},
|
||||
|
||||
"agent": {
|
||||
"my-agent": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"mode": "subagent",
|
||||
"description": "...",
|
||||
"permission": { "edit": "deny" }
|
||||
}
|
||||
},
|
||||
|
||||
"command": {
|
||||
"deploy": { "description": "...", "prompt": "..." }
|
||||
},
|
||||
|
||||
"provider": {
|
||||
"anthropic": { "options": { "apiKey": "..." } }
|
||||
},
|
||||
"disabled_providers": ["openai"],
|
||||
"enabled_providers": ["anthropic"],
|
||||
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@playwright/mcp"],
|
||||
"enabled": true,
|
||||
"env": {}
|
||||
},
|
||||
"remote-thing": {
|
||||
"type": "remote",
|
||||
"url": "https://...",
|
||||
"headers": { "Authorization": "Bearer ..." }
|
||||
}
|
||||
},
|
||||
|
||||
"plugin": [
|
||||
"opencode-gemini-auth",
|
||||
"opencode-foo@1.2.3",
|
||||
"./local-plugin.ts",
|
||||
["opencode-bar", { "option": "value" }]
|
||||
],
|
||||
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": { "git *": "allow", "*": "ask" }
|
||||
},
|
||||
|
||||
"formatter": false,
|
||||
"lsp": false,
|
||||
|
||||
"experimental": {
|
||||
"primary_tools": ["edit"],
|
||||
"mcp_timeout": 30000
|
||||
},
|
||||
|
||||
"tool_output": { "max_lines": 200, "max_bytes": 8192 },
|
||||
|
||||
"compaction": { "auto": true, "tail_turns": 15 }
|
||||
}
|
||||
```
|
||||
|
||||
Shape notes worth being explicit about:
|
||||
|
||||
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
|
||||
- `skills` is an object with `paths` and/or `urls`, not an array.
|
||||
- `agent` is an object keyed by agent name, not an array.
|
||||
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
|
||||
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
|
||||
- `permission` is either a string action or an object keyed by tool name.
|
||||
|
||||
## Skills
|
||||
|
||||
opencode's skill loader scans for `**/SKILL.md` inside skill directories. The
|
||||
file is named `SKILL.md` exactly, and lives in its own folder named after the
|
||||
skill:
|
||||
|
||||
```
|
||||
.opencode/skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
Frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say.
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
(skill body in markdown: instructions, examples, references)
|
||||
```
|
||||
|
||||
- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name.
|
||||
- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics.
|
||||
- Optional: `license`, `compatibility`, `metadata` (string-string map).
|
||||
|
||||
Register skills from non-default locations via `skills.paths` (scanned
|
||||
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
|
||||
skills).
|
||||
|
||||
## Agents
|
||||
|
||||
Two ways to define an agent. Use the file form for anything non-trivial.
|
||||
|
||||
### Inline (in `opencode.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"my-reviewer": {
|
||||
"description": "Reviews PRs for style violations.",
|
||||
"mode": "subagent",
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"permission": { "edit": "deny", "bash": "ask" },
|
||||
"prompt": "You are a strict PR reviewer..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File
|
||||
|
||||
```
|
||||
.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md
|
||||
```
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews PRs for style violations.
|
||||
mode: subagent
|
||||
model: anthropic/claude-sonnet-4-6
|
||||
permission:
|
||||
edit: deny
|
||||
bash: ask
|
||||
---
|
||||
|
||||
You are a strict PR reviewer. Focus on...
|
||||
```
|
||||
|
||||
The file body becomes the agent's `prompt`. Do not also put `prompt:` in the
|
||||
frontmatter.
|
||||
|
||||
`mode` is one of `"primary"`, `"subagent"`, `"all"`.
|
||||
|
||||
Allowed top-level frontmatter fields: `name, model, variant, description, mode,
|
||||
hidden, color, steps, options, permission, disable, temperature, top_p`. Any
|
||||
unknown field is silently routed into `options`.
|
||||
|
||||
To disable a built-in agent: `agent: { build: { disable: true } }`, or in a
|
||||
file, `disable: true` in frontmatter.
|
||||
|
||||
`default_agent` must point to a non-hidden, primary-mode agent.
|
||||
|
||||
### Built-in agents
|
||||
|
||||
opencode ships with `build`, `plan`, `general`, `explore`, plus optionally
|
||||
`scout` (gated on `OPENCODE_EXPERIMENTAL_SCOUT`). Hidden internal agents:
|
||||
`compaction`, `title`, `summary`. To override a built-in's fields, define the
|
||||
same key in `agent: { <name>: { ... } }`.
|
||||
|
||||
## Plugins
|
||||
|
||||
`plugin:` is an array. Each entry is one of:
|
||||
|
||||
```json
|
||||
"plugin": [
|
||||
"opencode-gemini-auth", // npm spec, latest
|
||||
"opencode-foo@1.2.3", // npm spec, pinned
|
||||
"./local-plugin.ts", // file path, relative to the declaring config
|
||||
"file:///abs/path/plugin.js", // file URL
|
||||
["opencode-bar", { "key": "val" }] // tuple form with options
|
||||
]
|
||||
```
|
||||
|
||||
Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in
|
||||
`.opencode/plugin/` or `.opencode/plugins/`.
|
||||
|
||||
A plugin module exports `default` (or any named export) of type
|
||||
`Plugin = (input: PluginInput, options?) => Promise<Hooks>`. The export is a
|
||||
function, not a plain object literal, and the function returns an object
|
||||
(return `{}` if there is nothing to register).
|
||||
|
||||
```ts
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default (async ({ client, project, directory, $ }) => {
|
||||
return {
|
||||
config: (cfg) => {
|
||||
// cfg is the live merged config; mutate fields here.
|
||||
},
|
||||
"tool.execute.before": async (input, output) => {
|
||||
// mutate output.args before the tool runs
|
||||
},
|
||||
}
|
||||
}) satisfies Plugin
|
||||
```
|
||||
|
||||
Hook surface (mutate `output` in place; return `void`):
|
||||
|
||||
- `event(input)`: every bus event
|
||||
- `config(cfg)`: once on init with the merged config
|
||||
- `chat.message`, `chat.params`, `chat.headers`
|
||||
- `tool.execute.before`, `tool.execute.after`
|
||||
- `tool.definition`
|
||||
- `command.execute.before`
|
||||
- `shell.env`
|
||||
- `permission.ask`
|
||||
- `experimental.chat.messages.transform`, `experimental.chat.system.transform`,
|
||||
`experimental.session.compacting`, `experimental.compaction.autocontinue`,
|
||||
`experimental.text.complete`
|
||||
|
||||
Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
|
||||
`auth: { ... }`, `provider: { ... }`.
|
||||
|
||||
## MCP servers
|
||||
|
||||
`mcp:` is an object keyed by server name. Each server is discriminated by
|
||||
`type`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@playwright/mcp"],
|
||||
"enabled": true,
|
||||
"env": { "BROWSER": "chromium" }
|
||||
},
|
||||
"github": {
|
||||
"type": "remote",
|
||||
"url": "https://...",
|
||||
"enabled": true,
|
||||
"headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
|
||||
},
|
||||
"old-server": { "enabled": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`command` is an array of strings. `type` is required. Use `enabled: false` to
|
||||
disable a server inherited from a parent config.
|
||||
|
||||
## Permissions
|
||||
|
||||
```json
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": { "git *": "allow", "rm *": "deny", "*": "ask" },
|
||||
"external_directory": { "~/secrets/**": "deny", "*": "allow" }
|
||||
}
|
||||
```
|
||||
|
||||
Actions: `"allow"`, `"ask"`, `"deny"`.
|
||||
|
||||
Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an
|
||||
object `{ pattern: action }`. Within an object, **insertion order matters**.
|
||||
opencode evaluates the LAST matching rule, so put broad rules first and narrow
|
||||
rules last.
|
||||
|
||||
`permission: "allow"` (a string at the top level) is shorthand for "allow
|
||||
everything" and is rarely what the user wants.
|
||||
|
||||
Known permission keys: `read, edit, glob, grep, list, bash, task,
|
||||
external_directory, todowrite, question, webfetch, websearch, codesearch,
|
||||
repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite,
|
||||
question, webfetch, websearch, codesearch, doom_loop`) only accept a flat
|
||||
action, not a per-pattern object.
|
||||
|
||||
`external_directory` patterns are filesystem paths (use `~/`, absolute paths,
|
||||
or globs like `~/projects/**`).
|
||||
|
||||
Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on
|
||||
the `plan` agent's permission ruleset (`edit: deny *`).
|
||||
|
||||
## Escape hatches
|
||||
|
||||
When a user's config is broken and opencode won't start, these env vars help:
|
||||
|
||||
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
|
||||
and start from globals only. Run from the project directory, opencode loads,
|
||||
the user edits the broken file, then they restart without the flag.
|
||||
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
|
||||
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
|
||||
inject inline JSON as a final local-scope merge.
|
||||
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
|
||||
- `OPENCODE_PURE=1`: skip external plugins entirely.
|
||||
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
|
||||
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
|
||||
`~/.claude/` and `~/.agents/`.
|
||||
|
||||
## When proposing edits
|
||||
|
||||
- Validate against the schema before writing. If you are unsure of a field's
|
||||
exact shape, fetch `https://opencode.ai/config.json` rather than guessing.
|
||||
- Preserve `$schema` and any existing fields the user did not ask to change.
|
||||
- For agent, skill, and plugin definitions, prefer creating new files in the
|
||||
correct location over inlining everything in `opencode.json`.
|
||||
- If the user's existing config is malformed, point them at the env-var escape
|
||||
hatch above so they can edit from inside opencode without breaking their
|
||||
session.
|
||||
- opencode hard-fails on invalid config by design. There is no graceful
|
||||
degradation, so get the shape right the first time.
|
||||
@@ -25,8 +25,8 @@ export const FileDiff = Schema.Struct({
|
||||
// session response and broke session loading on Desktop.
|
||||
file: Schema.optional(Schema.String),
|
||||
patch: Schema.optional(Schema.String),
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
additions: Schema.Finite,
|
||||
deletions: Schema.Finite,
|
||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||
})
|
||||
.annotate({ identifier: "SnapshotFileDiff" })
|
||||
|
||||
@@ -118,12 +118,12 @@ export namespace Step {
|
||||
finish: Schema.String,
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
input: NonNegativeInt,
|
||||
output: NonNegativeInt,
|
||||
reasoning: NonNegativeInt,
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.Finite,
|
||||
cache: Schema.Struct({
|
||||
read: NonNegativeInt,
|
||||
write: NonNegativeInt,
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
}),
|
||||
snapshot: Schema.String.pipe(Schema.optional),
|
||||
@@ -305,7 +305,7 @@ export namespace Tool {
|
||||
|
||||
export const RetryError = Schema.Struct({
|
||||
message: Schema.String,
|
||||
statusCode: NonNegativeInt.pipe(Schema.optional),
|
||||
statusCode: Schema.Finite.pipe(Schema.optional),
|
||||
isRetryable: Schema.Boolean,
|
||||
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
responseBody: Schema.String.pipe(Schema.optional),
|
||||
@@ -320,7 +320,7 @@ export const Retried = EventV2.define({
|
||||
aggregate: "sessionID",
|
||||
schema: {
|
||||
...Base,
|
||||
attempt: NonNegativeInt,
|
||||
attempt: Schema.Finite,
|
||||
error: RetryError,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -558,22 +558,20 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("config loader is tolerant: drops unknown fields, keeps the rest", async () => {
|
||||
test("validates config schema and throws on invalid fields", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "kept",
|
||||
invalid_field: "should be dropped, not crash the app",
|
||||
invalid_field: "should cause error",
|
||||
})
|
||||
},
|
||||
})
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.username).toBe("kept")
|
||||
expect((config as Record<string, unknown>).invalid_field).toBeUndefined()
|
||||
// Strict schema should throw an error for invalid fields
|
||||
await expect(load()).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1683,70 +1681,7 @@ test("permission config preserves user key order", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Discord bug report (Ben Matthews, v1.14.45): a malformed `skills:` field
|
||||
// (array instead of object) made the WHOLE config fail to load, the server
|
||||
// returned 500, and the desktop app couldn't start. Per Kit:
|
||||
// "for all of these things that we load from the user's computer, they
|
||||
// should be kind of tolerant. ... It shouldn't break opencode."
|
||||
// The contract: drop the malformed top-level field, log a warning, keep
|
||||
// the rest of the config so the app starts.
|
||||
test("config parser is tolerant: drops malformed top-level fields, keeps the rest", () => {
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "ben",
|
||||
// Wrong shape — schema expects { paths?, urls? }, user has an array
|
||||
// (looks like the LOADED skills list got pasted into the config).
|
||||
skills: [
|
||||
{ name: "scss-layout-accessibility", path: ".opencode/skills/scss-layout-accessibility.md" },
|
||||
{ name: "testing", path: ".opencode/skills/testing.md" },
|
||||
],
|
||||
},
|
||||
"test",
|
||||
)
|
||||
|
||||
// Pre-fix this throws ConfigInvalidError and the user can't start opencode.
|
||||
// Post-fix the bad field is dropped and the rest of the config loads.
|
||||
expect(config.username).toBe("ben")
|
||||
expect(config.skills).toBeUndefined()
|
||||
})
|
||||
|
||||
test("config parser is tolerant: drops unrecognized top-level keys instead of throwing", () => {
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "ben",
|
||||
// Typo or stale key — pre-fix this threw `unrecognized_keys`.
|
||||
autoshrare: true,
|
||||
},
|
||||
"test",
|
||||
)
|
||||
|
||||
expect(config.username).toBe("ben")
|
||||
expect((config as Record<string, unknown>).autoshrare).toBeUndefined()
|
||||
})
|
||||
|
||||
test("config parser is tolerant: drops multiple bad fields in one pass", () => {
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "ben",
|
||||
skills: ["wrong shape"],
|
||||
autoshare: 42, // wrong type — schema wants string literal | undefined
|
||||
not_a_real_key: "ignore me",
|
||||
},
|
||||
"test",
|
||||
)
|
||||
|
||||
expect(config.username).toBe("ben")
|
||||
expect(config.skills).toBeUndefined()
|
||||
expect(config.autoshare).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Effect config parser preserves permission order while dropping unknown top-level keys", () => {
|
||||
test("Effect config parser preserves permission order while rejecting unknown top-level keys", () => {
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
{
|
||||
@@ -1760,10 +1695,13 @@ test("Effect config parser preserves permission order while dropping unknown top
|
||||
)
|
||||
|
||||
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
||||
// Tolerant parser: unknown keys are stripped (with a warning log) instead
|
||||
// of failing the entire config load.
|
||||
const stripped = ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
|
||||
expect((stripped as Record<string, unknown>).invalid_field).toBeUndefined()
|
||||
try {
|
||||
ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
|
||||
throw new Error("expected config parse to fail")
|
||||
} catch (err) {
|
||||
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
|
||||
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
|
||||
}
|
||||
})
|
||||
|
||||
// MCP config merging tests
|
||||
|
||||
@@ -7,8 +7,9 @@ import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
|
||||
// Per-client state for controlling mock behavior
|
||||
interface MockClientState {
|
||||
tools: Array<{ name: string; description?: string; inputSchema: object }>
|
||||
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
|
||||
listToolsCalls: number
|
||||
requestCalls: number
|
||||
listToolsShouldFail: boolean
|
||||
listToolsError: string
|
||||
listPromptsShouldFail: boolean
|
||||
@@ -36,6 +37,7 @@ function getOrCreateClientState(name?: string): MockClientState {
|
||||
state = {
|
||||
tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
|
||||
listToolsCalls: 0,
|
||||
requestCalls: 0,
|
||||
listToolsShouldFail: false,
|
||||
listToolsError: "listTools failed",
|
||||
listPromptsShouldFail: false,
|
||||
@@ -139,6 +141,12 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
return { tools: this._state?.tools ?? [] }
|
||||
}
|
||||
|
||||
async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) {
|
||||
if (this._state) this._state.requestCalls++
|
||||
if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] })
|
||||
throw new Error(`unsupported request: ${request.method}`)
|
||||
}
|
||||
|
||||
async listPrompts() {
|
||||
if (this._state?.listPromptsShouldFail) {
|
||||
throw new Error("listPrompts failed")
|
||||
@@ -205,6 +213,11 @@ function withInstance(
|
||||
}
|
||||
}
|
||||
|
||||
function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
|
||||
if ("status" in status) return status.status
|
||||
return status[server]?.status
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Test: tools() are cached after connect
|
||||
// ========================================================================
|
||||
@@ -433,6 +446,59 @@ test(
|
||||
),
|
||||
)
|
||||
|
||||
test(
|
||||
"falls back when MCP output schema refs fail SDK tool discovery",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "stitch-like-server"
|
||||
const serverState = getOrCreateClientState("stitch-like-server")
|
||||
serverState.listToolsShouldFail = true
|
||||
serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #"
|
||||
serverState.tools = [
|
||||
{
|
||||
name: "render_screen",
|
||||
description: "renders a screen",
|
||||
inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] },
|
||||
outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } },
|
||||
},
|
||||
]
|
||||
|
||||
const addResult = yield* mcp.add("stitch-like-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
expect(statusName(addResult.status, "stitch-like-server")).toBe("connected")
|
||||
|
||||
const tools = yield* mcp.tools()
|
||||
expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true)
|
||||
expect(serverState.listToolsCalls).toBe(1)
|
||||
expect(serverState.requestCalls).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test(
|
||||
"does not fall back for non-schema MCP tool discovery errors",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "broken-server"
|
||||
const serverState = getOrCreateClientState("broken-server")
|
||||
serverState.listToolsShouldFail = true
|
||||
serverState.listToolsError = "transport closed"
|
||||
|
||||
const addResult = yield* mcp.add("broken-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
expect(statusName(addResult.status, "broken-server")).toBe("failed")
|
||||
expect(serverState.listToolsCalls).toBe(1)
|
||||
expect(serverState.requestCalls).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
// Test: disabled server via config
|
||||
// ========================================================================
|
||||
|
||||
@@ -35,6 +35,11 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
|
||||
process.env["XDG_STATE_HOME"] = path.join(dir, "state")
|
||||
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
|
||||
process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true"
|
||||
// Tests assert exact skill counts from disk discovery; the built-in
|
||||
// customize-opencode skill is opt-in for stable channels and on by default
|
||||
// for unstable channels (including "local" where CI runs). Disable it here
|
||||
// so disk-discovery tests aren't off-by-one.
|
||||
process.env["OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false"
|
||||
|
||||
// Set test home directory to isolate tests from user's actual home directory
|
||||
// This prevents tests from picking up real user configs/skills from ~/.claude/skills
|
||||
|
||||
@@ -38,6 +38,10 @@ import { type Scenario } from "./types"
|
||||
|
||||
void (await import("@opencode-ai/core/util/log")).init({ print: false })
|
||||
|
||||
function cursor(input: Record<string, unknown>) {
|
||||
return Buffer.from(JSON.stringify(input)).toString("base64url")
|
||||
}
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
http.protected
|
||||
.get("/global/health", "global.health")
|
||||
@@ -520,7 +524,10 @@ const scenarios: Scenario[] = [
|
||||
yield* ctx.worktreeRemove(ctx.state.directory)
|
||||
}),
|
||||
),
|
||||
http.protected.get("/experimental/session", "experimental.session.list").json(200, array),
|
||||
http.protected
|
||||
.get("/experimental/session", "experimental.session.list")
|
||||
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
|
||||
.json(200, array),
|
||||
http.protected.get("/experimental/resource", "experimental.resource.list").json(),
|
||||
http.protected
|
||||
.post("/sync/history", "sync.history.list")
|
||||
@@ -598,6 +605,64 @@ const scenarios: Scenario[] = [
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session", "v2.session.list.filters")
|
||||
.at((ctx) => ({
|
||||
path: `/api/session?${new URLSearchParams({
|
||||
limit: "2",
|
||||
order: "asc",
|
||||
path: ".",
|
||||
roots: "false",
|
||||
start: "0",
|
||||
search: "missing",
|
||||
directory: ctx.directory ?? "",
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session", "v2.session.list.cursor")
|
||||
.at((ctx) => ({
|
||||
path: `/api/session?${new URLSearchParams({
|
||||
limit: "2",
|
||||
directory: ctx.directory ?? "",
|
||||
cursor: cursor({
|
||||
id: "ses_httpapi_missing",
|
||||
time: 0,
|
||||
order: "desc",
|
||||
direction: "next",
|
||||
directory: ctx.directory,
|
||||
}),
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session", "v2.session.list.cursor.invalid")
|
||||
.at((ctx) => ({
|
||||
path: `/api/session?${new URLSearchParams({
|
||||
cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }),
|
||||
search: "not-allowed-with-cursor",
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(400, undefined, "none"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/context", "v2.session.context")
|
||||
.at((ctx) => ({
|
||||
@@ -620,6 +685,53 @@ const scenarios: Scenario[] = [
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/message", "v2.session.messages.params")
|
||||
.at((ctx) => ({
|
||||
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
|
||||
limit: "2",
|
||||
order: "asc",
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor")
|
||||
.at((ctx) => ({
|
||||
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
|
||||
limit: "2",
|
||||
directory: ctx.directory ?? "",
|
||||
cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }),
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid")
|
||||
.at((ctx) => ({
|
||||
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
|
||||
cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }),
|
||||
order: "asc",
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(400, undefined, "none"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid")
|
||||
.at((ctx) => ({
|
||||
|
||||
@@ -1,14 +1,73 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
import {
|
||||
FilePaths,
|
||||
FileQuery,
|
||||
FindFileQuery,
|
||||
FindTextQuery,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import {
|
||||
ExperimentalPaths,
|
||||
SessionListQuery as ExperimentalSessionListQuery,
|
||||
ToolListQuery,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import {
|
||||
ListQuery as SessionListQuery,
|
||||
MessagesQuery,
|
||||
SessionPaths,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
|
||||
import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session"
|
||||
import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
type Method = "get" | "post" | "put" | "delete" | "patch"
|
||||
type QuerySchema = { readonly fields: Record<string, unknown> }
|
||||
type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string }
|
||||
type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema }
|
||||
type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
|
||||
|
||||
const openApiDriftRoutes = [
|
||||
{ method: "get", path: SessionPaths.list, query: SessionListQuery },
|
||||
{ method: "get", path: SessionPaths.messages, query: MessagesQuery },
|
||||
{ method: "get", path: FilePaths.findFile, query: FindFileQuery },
|
||||
{ method: "get", path: FilePaths.findText, query: FindTextQuery },
|
||||
{ method: "get", path: FilePaths.list, query: FileQuery },
|
||||
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
|
||||
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
|
||||
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
|
||||
{ method: "get", path: "/api/session", query: V2SessionsQuery },
|
||||
{ method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
|
||||
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
|
||||
|
||||
const numericSdkQueryParams = [
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } },
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } },
|
||||
{ method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } },
|
||||
{ method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
|
||||
{ method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } },
|
||||
{ method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } },
|
||||
{
|
||||
method: "get",
|
||||
path: SessionPaths.messages,
|
||||
name: "limit",
|
||||
schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||
},
|
||||
{ method: "get", path: "/api/session", name: "limit", schema: { type: "number" } },
|
||||
{ method: "get", path: "/api/session", name: "start", schema: { type: "number" } },
|
||||
{ method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
|
||||
] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
@@ -27,6 +86,33 @@ function withTmp<A, E, R>(
|
||||
).pipe(Effect.flatMap(fn))
|
||||
}
|
||||
|
||||
function openApiPath(path: string) {
|
||||
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
|
||||
}
|
||||
|
||||
function queryParameters(operation: OpenApiOperation | undefined) {
|
||||
return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
|
||||
}
|
||||
|
||||
function queryParameter(operation: OpenApiOperation | undefined, name: string) {
|
||||
return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name)
|
||||
}
|
||||
|
||||
function assertAdvertisedQueryParamsAreRuntimeFields(input: {
|
||||
readonly method: Method
|
||||
readonly operation: OpenApiOperation | undefined
|
||||
readonly path: string
|
||||
readonly query: QuerySchema
|
||||
}) {
|
||||
const runtimeFields = new Set(Object.keys(input.query.fields))
|
||||
const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name))
|
||||
|
||||
expect(
|
||||
advertisedOnly,
|
||||
`${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`,
|
||||
).toEqual([])
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
@@ -44,6 +130,68 @@ describe("httpapi query schema drift", () => {
|
||||
expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
|
||||
}
|
||||
|
||||
it.effect(
|
||||
"boolean query schema accepts only true and false strings",
|
||||
Effect.sync(() => {
|
||||
const decode = Schema.decodeUnknownSync(QueryBoolean)
|
||||
const encode = Schema.encodeUnknownSync(QueryBoolean)
|
||||
|
||||
expect(decode("true")).toBe(true)
|
||||
expect(decode("false")).toBe(false)
|
||||
expect(encode(true)).toBe("true")
|
||||
expect(encode(false)).toBe("false")
|
||||
|
||||
for (const input of ["1", "yes", "True", "", true, false]) {
|
||||
expect(() => decode(input)).toThrow()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"OpenAPI workspace query params are declared by runtime query schemas",
|
||||
Effect.sync(() => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
for (const route of openApiDriftRoutes) {
|
||||
assertAdvertisedQueryParamsAreRuntimeFields({
|
||||
...route,
|
||||
operation: spec.paths[openApiPath(route.path)]?.[route.method],
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"OpenAPI numeric query params preserve generated SDK call shapes",
|
||||
Effect.sync(() => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
for (const expected of numericSdkQueryParams) {
|
||||
expect(
|
||||
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
|
||||
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
|
||||
).toEqual(expected.schema)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"drift assertion catches spec-only workspace query params",
|
||||
Effect.sync(() => {
|
||||
expect(() =>
|
||||
assertAdvertisedQueryParamsAreRuntimeFields({
|
||||
method: "get",
|
||||
operation: {
|
||||
parameters: [
|
||||
{ name: "directory", in: "query" },
|
||||
{ name: "workspace", in: "query" },
|
||||
],
|
||||
},
|
||||
path: "/fixture",
|
||||
query: { fields: {} },
|
||||
}),
|
||||
).toThrow("advertises query params not accepted by runtime schema")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"session list accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Regression: a stored step-finish part with a negative token count made the
|
||||
// messages endpoint 400. Some providers reported `outputTokens` excluding
|
||||
// reasoning while also reporting `reasoningTokens` separately, so the
|
||||
// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to
|
||||
// negative. The pre-fix `safe()` clamp only guarded against non-finite. The
|
||||
// strict `NonNegativeInt` schema then made every load of the message list
|
||||
// fail to encode, killing Desktop boot for every user with such a row.
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import * as Database from "@/storage/db"
|
||||
import { PartTable } from "@/session/session.sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function seedNegativeTokenSession(directory: string) {
|
||||
return Effect.promise(async () =>
|
||||
WithInstance.provide({
|
||||
directory,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
// Bypass the schema with a direct SQL update to install the
|
||||
// negative `output` value we want to test loading.
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never,
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
)
|
||||
|
||||
return info.id
|
||||
}).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("messages endpoint tolerates legacy negative token counts", () => {
|
||||
it.live(
|
||||
"returns 200 even when a step-finish part has tokens.output < 0",
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = yield* seedNegativeTokenSession(tmp.path)
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import path from "path"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
@@ -20,7 +21,7 @@ import { SessionSummary } from "../../src/session/summary"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { provideTmpdirServer, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { raw, reply, TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
@@ -173,6 +174,37 @@ const env = Layer.mergeAll(
|
||||
|
||||
const it = testEffect(env)
|
||||
|
||||
function llmEvents() {
|
||||
const queue: Array<LLM.Event[]> = []
|
||||
|
||||
return {
|
||||
push(...events: LLM.Event[]) {
|
||||
queue.push(events)
|
||||
},
|
||||
layer: Layer.succeed(
|
||||
LLM.Service,
|
||||
LLM.Service.of({
|
||||
stream: () => Stream.make(...(queue.shift() ?? [])),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const directLLM = llmEvents()
|
||||
const directDeps = Layer.mergeAll(
|
||||
Session.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
AgentSvc.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
directLLM.layer,
|
||||
Provider.defaultLayer,
|
||||
status,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const directEnv = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(directDeps))
|
||||
const directIt = testEffect(directEnv)
|
||||
|
||||
const boot = Effect.fn("test.boot")(function* () {
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
@@ -184,6 +216,82 @@ const boot = Effect.fn("test.boot")(function* () {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
directIt.instance(
|
||||
"session.processor effect tests consume explicit llm events",
|
||||
Effect.gen(function* () {
|
||||
const { directory: dir } = yield* TestInstance
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
directLLM.push(
|
||||
{ type: "start" },
|
||||
{ type: "start-step" },
|
||||
{ type: "reasoning-start", id: "reason-0" },
|
||||
{ type: "reasoning-delta", id: "reason-0", text: "think" },
|
||||
{ type: "reasoning-end", id: "reason-0" },
|
||||
{ type: "text-start", id: "text-0" },
|
||||
{ type: "text-delta", id: "text-0", text: "hello" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{
|
||||
type: "finish-step",
|
||||
finishReason: "stop",
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "finish", finishReason: "stop" },
|
||||
)
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "hi")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const parts = MessageV2.parts(msg.id)
|
||||
const reasoning = parts.find((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
||||
const text = parts.find((part): part is MessageV2.TextPart => part.type === "text")
|
||||
const finish = parts.find((part): part is MessageV2.StepFinishPart => part.type === "step-finish")
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(reasoning?.text).toBe("think")
|
||||
expect(text?.text).toBe("hello")
|
||||
expect(finish?.reason).toBe("stop")
|
||||
}),
|
||||
{ git: true, config: providerCfg("http://localhost:1/v1") },
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests capture llm input cleanly", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
|
||||
@@ -4163,6 +4163,13 @@ export class Session3 extends HeyApiClient {
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
path?: string
|
||||
roots?: boolean | "true" | "false"
|
||||
start?: number
|
||||
search?: string
|
||||
cursor?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -4173,6 +4180,13 @@ export class Session3 extends HeyApiClient {
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "query", key: "limit" },
|
||||
{ in: "query", key: "order" },
|
||||
{ in: "query", key: "path" },
|
||||
{ in: "query", key: "roots" },
|
||||
{ in: "query", key: "start" },
|
||||
{ in: "query", key: "search" },
|
||||
{ in: "query", key: "cursor" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -4331,6 +4345,9 @@ export class Session3 extends HeyApiClient {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
cursor?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -4342,6 +4359,9 @@ export class Session3 extends HeyApiClient {
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "query", key: "limit" },
|
||||
{ in: "query", key: "order" },
|
||||
{ in: "query", key: "cursor" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6235,6 +6235,16 @@ export type V2SessionListData = {
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
path?: string
|
||||
roots?: boolean | "true" | "false"
|
||||
start?: number
|
||||
search?: string
|
||||
/**
|
||||
* Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.
|
||||
*/
|
||||
cursor?: string
|
||||
}
|
||||
url: "/api/session"
|
||||
}
|
||||
@@ -6352,6 +6362,12 @@ export type V2SessionMessagesData = {
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
limit?: number
|
||||
order?: "asc" | "desc"
|
||||
/**
|
||||
* Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.
|
||||
*/
|
||||
cursor?: string
|
||||
}
|
||||
url: "/api/session/{sessionID}/message"
|
||||
}
|
||||
|
||||
+1178
-1128
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user