feat(core): add command registry (#30624)
This commit is contained in:
@@ -43,6 +43,22 @@ function cursor(input: Record<string, unknown>) {
|
||||
return Buffer.from(JSON.stringify(input)).toString("base64url")
|
||||
}
|
||||
|
||||
function data(validate: (value: any) => void) {
|
||||
return (body: any) => {
|
||||
object(body)
|
||||
validate(body.data)
|
||||
}
|
||||
}
|
||||
|
||||
function locationData(validate: (value: any) => void) {
|
||||
return (body: any) => {
|
||||
object(body)
|
||||
object(body.location)
|
||||
object(body.location.project)
|
||||
validate(body.data)
|
||||
}
|
||||
}
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
http.protected
|
||||
.get("/global/health", "global.health")
|
||||
@@ -609,20 +625,48 @@ const scenarios: Scenario[] = [
|
||||
check(auth.test === undefined, "auth remove should delete provider from isolated auth file")
|
||||
}),
|
||||
),
|
||||
http.protected.get("/api/model", "v2.model.list").json(200, array),
|
||||
http.protected.get("/api/provider", "v2.provider.list").json(200, array),
|
||||
http.protected.get("/api/health", "v2.health.get").json(200, (body) => {
|
||||
object(body)
|
||||
check(body.healthy === true, "v2 server should report healthy")
|
||||
}),
|
||||
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)),
|
||||
http.protected
|
||||
.get("/api/event", "v2.event.subscribe")
|
||||
.stream()
|
||||
.status(
|
||||
200,
|
||||
(ctx, result) =>
|
||||
Effect.sync(() => {
|
||||
check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream")
|
||||
check(result.text.includes("server.connected"), "v2 event should emit initial connection event")
|
||||
check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location")
|
||||
}),
|
||||
"status",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/fs/read", "v2.fs.read")
|
||||
.seeded((ctx) => ctx.file("hello.txt", "hello\n"))
|
||||
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
|
||||
.json(200, object),
|
||||
http.protected.get("/api/fs/list", "v2.fs.list").json(200, array),
|
||||
.json(200, locationData(object)),
|
||||
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
|
||||
http.protected
|
||||
.get("/api/provider/{providerID}", "v2.provider.get")
|
||||
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array),
|
||||
http.protected.get("/api/question/request", "v2.question.request.list").json(200, array),
|
||||
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, (body) => {
|
||||
object(body)
|
||||
object(body.location)
|
||||
array(body.data)
|
||||
}),
|
||||
http.protected.get("/api/question/request", "v2.question.request.list").json(200, (body) => {
|
||||
object(body)
|
||||
object(body.location)
|
||||
array(body.data)
|
||||
}),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
|
||||
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
|
||||
@@ -630,7 +674,7 @@ const scenarios: Scenario[] = [
|
||||
path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, array),
|
||||
.json(200, data(array)),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
|
||||
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
|
||||
@@ -666,7 +710,10 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
|
||||
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, (body) => {
|
||||
object(body)
|
||||
array(body.data)
|
||||
}),
|
||||
http.protected
|
||||
.delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
|
||||
.at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() }))
|
||||
@@ -678,7 +725,7 @@ const scenarios: Scenario[] = [
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
array(body.data)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
@@ -701,7 +748,7 @@ const scenarios: Scenario[] = [
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
array(body.data)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
@@ -723,7 +770,7 @@ const scenarios: Scenario[] = [
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
array(body.data)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { original } from "./environment"
|
||||
import { runtime } from "./runtime"
|
||||
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
export function runScenario(options: Options) {
|
||||
return (scenario: Scenario) => {
|
||||
@@ -153,7 +154,7 @@ function withContext<A, E>(
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
modelID: ProviderV2.ModelID.make("test"),
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
},
|
||||
}
|
||||
const part: SessionV1.TextPart = {
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Schema } from "effect"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
|
||||
type OpenApiSchema = {
|
||||
readonly $ref?: string
|
||||
readonly items?: OpenApiSchema
|
||||
readonly properties?: Record<string, OpenApiSchema>
|
||||
}
|
||||
|
||||
type OpenApiSpec = {
|
||||
readonly components?: { readonly schemas?: Record<string, OpenApiSchema> }
|
||||
readonly paths: Record<
|
||||
string,
|
||||
{
|
||||
readonly get?: {
|
||||
readonly responses?: Record<string, { readonly content?: Record<string, { schema?: OpenApiSchema }> }>
|
||||
}
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
function responseSchema(spec: OpenApiSpec, path: string) {
|
||||
return spec.paths[path]?.get?.responses?.["200"]?.content?.["application/json"]?.schema
|
||||
}
|
||||
|
||||
function componentName(ref: string | undefined) {
|
||||
return ref?.replace("#/components/schemas/", "")
|
||||
}
|
||||
|
||||
describe("PublicApi v2 catalog redaction", () => {
|
||||
test("routes use redacted provider and model DTO schemas", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
const provider = responseSchema(spec, "/api/provider/{providerID}")
|
||||
const providers = responseSchema(spec, "/api/provider")
|
||||
const models = responseSchema(spec, "/api/model")
|
||||
|
||||
expect(componentName(provider?.$ref)).toBe("ProviderV2PublicInfo")
|
||||
expect(componentName(providers?.items?.$ref)).toBe("ProviderV2PublicInfo")
|
||||
expect(componentName(models?.items?.$ref)).toBe("ModelV2PublicInfo")
|
||||
|
||||
const providerProperties = spec.components?.schemas?.ProviderV2PublicInfo?.properties
|
||||
const modelProperties = spec.components?.schemas?.ModelV2PublicInfo?.properties
|
||||
expect(providerProperties).not.toHaveProperty("request")
|
||||
expect(modelProperties).not.toHaveProperty("request")
|
||||
expect(JSON.stringify(providerProperties)).not.toMatch(/settings|headers|body|data/)
|
||||
expect(JSON.stringify(modelProperties)).not.toMatch(/settings|headers|body/)
|
||||
})
|
||||
|
||||
test("DTOs sanitize provider and model API URLs", () => {
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const providers = [
|
||||
new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(providerID),
|
||||
api: {
|
||||
type: "native",
|
||||
url: "https://provider-user:provider-password@example.com:8443/provider/v1?api_key=provider-secret#fragment",
|
||||
settings: {},
|
||||
},
|
||||
}),
|
||||
new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(providerID),
|
||||
api: {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai",
|
||||
url: "https://provider-aisdk-user:provider-aisdk-password@example.com:8444/provider/aisdk?api_key=provider-aisdk-secret#fragment",
|
||||
},
|
||||
}),
|
||||
].map((provider) => Schema.encodeSync(ProviderV2.PublicInfo)(ProviderV2.toPublic(provider)))
|
||||
const models = [
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(providerID, ModelV2.ID.make("native")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("native"),
|
||||
type: "native",
|
||||
url: "https://native-user:native-password@example.com:9443/native/v1?api_key=native-secret#fragment",
|
||||
settings: {},
|
||||
},
|
||||
}),
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(providerID, ModelV2.ID.make("aisdk")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("aisdk"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai",
|
||||
url: "https://aisdk-user:aisdk-password@example.com:10443/aisdk/v1?api_key=aisdk-secret#fragment",
|
||||
},
|
||||
}),
|
||||
].map((model) => Schema.encodeSync(ModelV2.PublicInfo)(ModelV2.toPublic(model)))
|
||||
|
||||
expect(providers.map((provider) => provider.api)).toEqual([
|
||||
{ type: "native", url: "https://example.com:8443" },
|
||||
{ type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:8444" },
|
||||
])
|
||||
expect(models.map((model) => model.api)).toEqual([
|
||||
{ id: "native", type: "native", url: "https://example.com:9443" },
|
||||
{ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:10443" },
|
||||
])
|
||||
expect(JSON.stringify({ providers, models })).not.toMatch(/user|password|api_key|secret|fragment/)
|
||||
})
|
||||
|
||||
test("DTOs omit malformed API URLs", () => {
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const provider = Schema.encodeSync(ProviderV2.PublicInfo)(
|
||||
ProviderV2.toPublic(
|
||||
new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(providerID),
|
||||
api: { type: "native", url: "not a url?api_key=provider-secret", settings: {} },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const modelID = ModelV2.ID.make("aisdk")
|
||||
const model = Schema.encodeSync(ModelV2.PublicInfo)(
|
||||
ModelV2.toPublic(
|
||||
new ModelV2.Info({
|
||||
...ModelV2.Info.empty(providerID, modelID),
|
||||
api: { id: modelID, type: "aisdk", package: "@ai-sdk/openai", url: "model-secret" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(provider.api).toEqual({ type: "native" })
|
||||
expect(model.api).toEqual({ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai" })
|
||||
expect(JSON.stringify({ provider, model })).not.toMatch(/secret|api_key/)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,14 @@ import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
|
||||
type Method = "get" | "post" | "put" | "delete" | "patch"
|
||||
type OpenApiSchema = { readonly $ref?: string; readonly anyOf?: ReadonlyArray<OpenApiSchema> }
|
||||
type OpenApiSchema = {
|
||||
readonly $ref?: string
|
||||
readonly anyOf?: ReadonlyArray<OpenApiSchema>
|
||||
readonly type?: string
|
||||
readonly enum?: readonly unknown[]
|
||||
readonly properties?: Record<string, OpenApiSchema>
|
||||
readonly required?: readonly string[]
|
||||
}
|
||||
type OpenApiResponse = {
|
||||
readonly description?: string
|
||||
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
|
||||
@@ -20,7 +27,10 @@ type OpenApiOperation = {
|
||||
readonly security?: unknown
|
||||
}
|
||||
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
|
||||
type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
|
||||
type OpenApiSpec = {
|
||||
readonly paths: Record<string, OpenApiPathItem>
|
||||
readonly components: { readonly schemas: Record<string, OpenApiSchema> }
|
||||
}
|
||||
|
||||
const methods = ["get", "post", "put", "delete", "patch"] as const
|
||||
|
||||
@@ -56,6 +66,23 @@ function isBuiltInEndpointError(name: string) {
|
||||
}
|
||||
|
||||
describe("PublicApi OpenAPI v2 errors", () => {
|
||||
test("documents nested legacy global sync events", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
const schema = spec.components.schemas.SyncEventSessionCreated
|
||||
|
||||
expect(schema?.required).toEqual(["type", "id", "syncEvent"])
|
||||
expect(schema?.properties?.type?.enum).toEqual(["sync"])
|
||||
expect(schema?.properties?.syncEvent).toMatchObject({
|
||||
required: ["type", "id", "seq", "aggregateID", "data"],
|
||||
properties: {
|
||||
type: { enum: ["session.created.1"] },
|
||||
id: { type: "string" },
|
||||
seq: { type: "number" },
|
||||
aggregateID: { type: "string" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves /api auth responses", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SessionPaths,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
|
||||
import { MessagesQuery as V2MessagesQuery } from "@opencode-ai/server/groups/v2/message"
|
||||
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
@@ -32,7 +33,7 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
|
||||
@@ -25,6 +25,7 @@ import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixt
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { httpApiLayer } from "./httpapi-layer"
|
||||
|
||||
@@ -310,7 +311,7 @@ function seedMessage(directory: string, sessionID: string) {
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "test",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
tools: {},
|
||||
} satisfies SessionV1.User)
|
||||
const part = yield* svc.updatePart({
|
||||
@@ -392,7 +393,7 @@ describe("HttpApi SDK", () => {
|
||||
const url = new URL(request!.url)
|
||||
|
||||
expect(file.response.status).toBe(200)
|
||||
expect(file.data).toMatchObject({ content: "hello" })
|
||||
expect(file.data).toMatchObject({ data: { content: "hello" } })
|
||||
expect(url.searchParams.get("directory")).toBe(directory)
|
||||
expect(url.searchParams.get("workspace")).toBe(workspaceID)
|
||||
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
||||
|
||||
@@ -88,7 +88,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) {
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const part = yield* svc.updatePart({
|
||||
@@ -391,8 +391,9 @@ describe("session HttpApi", () => {
|
||||
yield* insertLegacyAssistantMessage(parent.id)
|
||||
|
||||
expect(
|
||||
(yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers }))
|
||||
.items,
|
||||
(yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, {
|
||||
headers,
|
||||
})).data,
|
||||
).toMatchObject([{ type: "assistant" }])
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
@@ -456,7 +457,7 @@ describe("session HttpApi", () => {
|
||||
})}`,
|
||||
{ headers },
|
||||
)
|
||||
const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next
|
||||
const sessionCursor = (yield* json<{ data: Session.Info[]; cursor: { next?: string } }>(sessionPage)).cursor.next
|
||||
expect(sessionCursor).toBeTruthy()
|
||||
expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
|
||||
order: "asc",
|
||||
@@ -483,10 +484,10 @@ describe("session HttpApi", () => {
|
||||
})
|
||||
|
||||
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
|
||||
const messageBody = yield* json<{ items: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
|
||||
const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
|
||||
const messageCursor = messageBody.cursor.next
|
||||
expect(messageCursor).toBeTruthy()
|
||||
expect(messageBody.items.map((message) => message.id)).toEqual([secondMessage.id])
|
||||
expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id])
|
||||
expect(JSON.parse(Buffer.from(messageCursor!, "base64url").toString("utf8"))).toEqual({
|
||||
id: secondMessage.id,
|
||||
order: "desc",
|
||||
@@ -497,7 +498,7 @@ describe("session HttpApi", () => {
|
||||
headers,
|
||||
})
|
||||
expect(
|
||||
(yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id),
|
||||
(yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id),
|
||||
).toEqual([firstMessage.id])
|
||||
|
||||
const legacyMessageCursor = Buffer.from(
|
||||
@@ -507,7 +508,7 @@ describe("session HttpApi", () => {
|
||||
headers,
|
||||
})
|
||||
expect(
|
||||
(yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id),
|
||||
(yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id),
|
||||
).toEqual([firstMessage.id])
|
||||
|
||||
const messageCursorWithOrder = yield* request(
|
||||
@@ -587,17 +588,17 @@ describe("session HttpApi", () => {
|
||||
const first = yield* recordPrompt()
|
||||
const retried = yield* recordPrompt()
|
||||
type PromptBody = { id: string; type: string; text: string }
|
||||
const firstBody = yield* json<PromptBody>(first)
|
||||
const retriedBody = yield* json<PromptBody>(retried)
|
||||
const firstBody = yield* json<{ data: PromptBody }>(first)
|
||||
const retriedBody = yield* json<{ data: PromptBody }>(retried)
|
||||
expect(first.status).toBe(200)
|
||||
expect(retried.status).toBe(200)
|
||||
expect(retriedBody).toEqual(firstBody)
|
||||
expect(firstBody).toMatchObject({ type: "user", text: "hello" })
|
||||
expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } })
|
||||
|
||||
const messages = yield* requestJson<{ items: PromptBody[] }>(`/api/session/${session.id}/message`, {
|
||||
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
|
||||
headers,
|
||||
})
|
||||
expect(messages.items).toHaveLength(0)
|
||||
expect(messages.data).toHaveLength(0)
|
||||
const admitted = yield* Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select()
|
||||
|
||||
82
packages/opencode/test/server/httpapi-v2-location.test.ts
Normal file
82
packages/opencode/test/server/httpapi-v2-location.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Context, Schema } from "effect"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
function request(route: string, directory: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost${route}`, {
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
const Event = Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.String,
|
||||
location: Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
|
||||
}),
|
||||
data: Schema.Unknown,
|
||||
})
|
||||
|
||||
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
const value = await reader.read()
|
||||
if (value.done) throw new Error("event stream closed")
|
||||
return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
|
||||
}
|
||||
|
||||
async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
|
||||
for (let index = 0; index < 20; index++) {
|
||||
const event = await readEvent(reader)
|
||||
if (event.type === type) return event
|
||||
}
|
||||
throw new Error(`timed out waiting for ${type}`)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("v2 location HttpApi", () => {
|
||||
test("returns command and skill snapshots with resolved locations", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
for (const route of ["/api/command", "/api/skill"]) {
|
||||
const response = await request(route, tmp.path)
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as { location: { directory: string; project: { id: string } }; data: unknown }
|
||||
expect(body.data).toBeArray()
|
||||
expect(body.location.directory).toBe(tmp.path)
|
||||
expect(body.location.project.id).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
test("streams native EventV2 payloads with resolved locations", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const response = await request("/api/event", tmp.path)
|
||||
const reader = response.body!.getReader()
|
||||
expect((await readEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const created = await request("/session", tmp.path, { method: "POST" })
|
||||
expect(created.status).toBe(200)
|
||||
expect(await readEventType(reader, "session.created")).toMatchObject({
|
||||
type: "session.created",
|
||||
location: { directory: tmp.path, project: { directory: tmp.path } },
|
||||
data: { sessionID: expect.any(String) },
|
||||
})
|
||||
await reader.cancel()
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
@@ -31,7 +32,7 @@ function seedNegativeTokenSession() {
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Storage } from "@/storage/storage"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { MessageID } from "@/session/schema"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -79,7 +80,7 @@ describe("session diff with missing patch (#26574)", () => {
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("model") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") },
|
||||
summary: {
|
||||
diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }],
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
@@ -18,7 +19,7 @@ const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
|
||||
|
||||
const model = {
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ProviderV2.ModelID.make("test"),
|
||||
modelID: ModelV2.ID.make("test"),
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
Reference in New Issue
Block a user