Source HTTP API ID path patterns (#26623)

This commit is contained in:
Kit Langton
2026-05-09 22:58:47 -04:00
committed by GitHub
parent 2f11c9f7ed
commit 67b9c9c027
25 changed files with 127 additions and 90 deletions

View File

@@ -105,11 +105,11 @@ Verification:
Concrete first targets:
- `sessionID`
- `messageID`
- `partID`
- `permissionID`
- `ptyID`
- `[x]` `sessionID`
- `[x]` `messageID`
- `[x]` `partID`
- `[x]` `permissionID`
- `[x]` `ptyID`
Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params.

View File

@@ -4,7 +4,9 @@ import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty"))
.annotate({ [ZodOverride]: Identifier.schema("pty") })
.pipe(Schema.brand("PtyID"))
export type PtyID = typeof ptyIdSchema.Type

View File

@@ -69,14 +69,6 @@ const QueryParameterSchemas: Record<string, OpenApiSchema> = {
"GET /api/session/{sessionID}/message limit": { type: "number" },
}
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.*" },
}
const LegacyComponentDescriptions: Record<string, string> = {
LogLevel: "Log level",
ServerConfig: "Server configuration for opencode serve and web commands",
@@ -506,11 +498,8 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
}
function pathParameterSchema(route: string, name: string) {
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.*" }
if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" }
return undefined
}

View File

@@ -4,32 +4,44 @@ import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe(
Schema.brand("SessionID"),
withStatics((s) => ({
descending: (id?: string) => s.make(Identifier.descending("session", id)),
zod: zod(s),
})),
)
export const SessionID = Schema.String.check(Schema.isStartsWith("ses"))
.annotate({
[ZodOverride]: Identifier.schema("session"),
})
.pipe(
Schema.brand("SessionID"),
withStatics((s) => ({
descending: (id?: string) => s.make(Identifier.descending("session", id)),
zod: zod(s),
})),
)
export type SessionID = Schema.Schema.Type<typeof SessionID>
export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe(
Schema.brand("MessageID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
zod: zod(s),
})),
)
export const MessageID = Schema.String.check(Schema.isStartsWith("msg"))
.annotate({
[ZodOverride]: Identifier.schema("message"),
})
.pipe(
Schema.brand("MessageID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
zod: zod(s),
})),
)
export type MessageID = Schema.Schema.Type<typeof MessageID>
export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe(
Schema.brand("PartID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
zod: zod(s),
})),
)
export const PartID = Schema.String.check(Schema.isStartsWith("prt"))
.annotate({
[ZodOverride]: Identifier.schema("part"),
})
.pipe(
Schema.brand("PartID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
zod: zod(s),
})),
)
export type PartID = Schema.Schema.Type<typeof PartID>

View File

@@ -7,8 +7,8 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema"
function createTextPart(text: string): MessageV2.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "text" as const,
text,
}
@@ -17,8 +17,8 @@ function createTextPart(text: string): MessageV2.Part {
function createReasoningPart(text: string): MessageV2.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "reasoning" as const,
text,
time: { start: 0 },
@@ -29,8 +29,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
if (status === "completed") {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "tool" as const,
callID: "c1",
tool,
@@ -46,8 +46,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
}
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "tool" as const,
callID: "c1",
tool,
@@ -62,8 +62,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
function createStepStartPart(): MessageV2.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "step-start" as const,
}
}
@@ -71,8 +71,8 @@ function createStepStartPart(): MessageV2.Part {
function createStepFinishPart(): MessageV2.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "step-finish" as const,
reason: "done",
cost: 0,

View File

@@ -22,8 +22,9 @@ function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
)
}
function uid() {
return SessionID.make(crypto.randomUUID())
function legacySessionID() {
// Global-session migration covers persisted IDs from before prefixed session IDs.
return crypto.randomUUID() as SessionID
}
function seed(opts: { id: SessionID; dir: string; project: ProjectID }) {
@@ -73,7 +74,7 @@ describe("migrateFromGlobal", () => {
expect(pre.id).toBe(ProjectID.global)
// 2. Seed a session under "global" with matching directory
const id = uid()
const id = legacySessionID()
seed({ id, dir: tmp.path, project: ProjectID.global })
// 3. Make a commit so the project gets a real ID
@@ -100,7 +101,7 @@ describe("migrateFromGlobal", () => {
// 3. Seed a session under "global" with matching directory.
// This simulates a session created before git init that wasn't
// present when the real project row was first created.
const id = uid()
const id = legacySessionID()
seed({ id, dir: tmp.path, project: ProjectID.global })
// 4. Call fromDirectory again — project row already exists,
@@ -121,7 +122,7 @@ describe("migrateFromGlobal", () => {
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = uid()
const id = legacySessionID()
seed({ id, dir: "", project: ProjectID.global })
await run((svc) => svc.fromDirectory(tmp.path))
@@ -139,7 +140,7 @@ describe("migrateFromGlobal", () => {
ensureGlobal()
// Seed a session under "global" but for a DIFFERENT directory
const id = uid()
const id = legacySessionID()
seed({ id, dir: "/some/other/dir", project: ProjectID.global })
await run((svc) => svc.fromDirectory(tmp.path))

View File

@@ -22,6 +22,7 @@ import {
MessagesQuery,
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 { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session"
import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query"
@@ -33,7 +34,12 @@ 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 OpenApiSchema = {
readonly maximum?: number
readonly minimum?: number
readonly pattern?: string
readonly type?: string
}
type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema }
type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
@@ -68,6 +74,16 @@ const numericSdkQueryParams = [
{ method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
const pathParamPatterns = [
{ method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" },
{ method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" },
{ method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" },
{ method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" },
{ method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" },
{ method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" },
{ method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" },
] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
function app() {
return Server.Default().app
}
@@ -98,6 +114,10 @@ function queryParameter(operation: OpenApiOperation | undefined, name: string) {
return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name)
}
function pathParameter(operation: OpenApiOperation | undefined, name: string) {
return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name)
}
function assertAdvertisedQueryParamsAreRuntimeFields(input: {
readonly method: Method
readonly operation: OpenApiOperation | undefined
@@ -173,6 +193,19 @@ describe("httpapi query schema drift", () => {
}),
)
it.effect(
"OpenAPI path parameter patterns come from runtime schemas",
Effect.sync(() => {
const spec = OpenApi.fromApi(PublicApi)
for (const expected of pathParamPatterns) {
expect(
pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
).toEqual({ type: "string", pattern: expected.pattern })
}
}),
)
it.effect(
"drift assertion catches spec-only workspace query params",
Effect.sync(() => {

View File

@@ -287,7 +287,7 @@ describe("HttpApi UI fallback", () => {
})
test("keeps matched API routes ahead of the UI fallback", async () => {
const response = await Server.Default().app.request("/session/nope")
const response = await Server.Default().app.request("/session/ses_nope")
expect(response.status).toBe(404)
})

View File

@@ -61,7 +61,7 @@ const tmpWithFiles = (files: Record<string, string>) =>
function loaded(filepath: string): MessageV2.WithParts[] {
const sessionID = SessionID.make("session-loaded-1")
const messageID = MessageID.make("message-loaded-1")
const messageID = MessageID.make("msg_message-loaded-1")
return [
{
@@ -78,7 +78,7 @@ function loaded(filepath: string): MessageV2.WithParts[] {
},
parts: [
{
id: PartID.make("part-loaded-1"),
id: PartID.make("prt_part-loaded-1"),
messageID,
sessionID,
type: "tool",
@@ -106,7 +106,7 @@ describe("Instruction.resolve", () => {
const system = yield* svc.systemPaths()
expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true)
const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1"))
const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1"))
expect(results).toEqual([])
}),
),
@@ -122,7 +122,7 @@ describe("Instruction.resolve", () => {
const results = yield* svc.resolve(
[],
path.join(dir, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
MessageID.make("msg_message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
@@ -138,7 +138,7 @@ describe("Instruction.resolve", () => {
const system = yield* svc.systemPaths()
expect(system.has(filepath)).toBe(false)
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3"))
expect(results).toEqual([])
}),
),
@@ -149,7 +149,7 @@ describe("Instruction.resolve", () => {
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
const id = MessageID.make("msg_message-claim-1")
const first = yield* svc.resolve([], filepath, id)
const second = yield* svc.resolve([], filepath, id)
@@ -166,7 +166,7 @@ describe("Instruction.resolve", () => {
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
const id = MessageID.make("msg_message-claim-2")
const first = yield* svc.resolve([], filepath, id)
yield* svc.clear(id)
@@ -185,7 +185,7 @@ describe("Instruction.resolve", () => {
const svc = yield* Instruction.Service
const agents = path.join(dir, "subdir", "AGENTS.md")
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-3")
const id = MessageID.make("msg_message-claim-3")
const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])

View File

@@ -354,7 +354,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-1"),
id: MessageID.make("msg_user-1"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -438,7 +438,7 @@ describe("session.llm.stream", () => {
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-service-abort"),
id: MessageID.make("msg_user-service-abort"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -529,7 +529,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-tools"),
id: MessageID.make("msg_user-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -644,7 +644,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-2"),
id: MessageID.make("msg_user-2"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -759,7 +759,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-data-url"),
id: MessageID.make("msg_user-data-url"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -880,7 +880,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-3"),
id: MessageID.make("msg_user-3"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -995,7 +995,7 @@ describe("session.llm.stream", () => {
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-anthropic-tools"),
id: MessageID.make("msg_user-anthropic-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -1239,7 +1239,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-4"),
id: MessageID.make("msg_user-4"),
sessionID,
role: "user",
time: { created: Date.now() },

View File

@@ -102,9 +102,9 @@ function assistantInfo(
function basePart(messageID: string, id: string) {
return {
id: PartID.make(id),
id: PartID.make(id.startsWith("prt") ? id : `prt_${id}`),
sessionID,
messageID: MessageID.make(messageID),
messageID: MessageID.make(messageID.startsWith("msg") ? messageID : `msg_${messageID}`),
}
}

View File

@@ -27,7 +27,7 @@ const runtime = ManagedRuntime.make(
const baseCtx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -17,7 +17,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
sessionID: SessionID.make("ses_test-edit-session"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -12,7 +12,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -23,7 +23,7 @@ const it = testEffect(
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -23,7 +23,7 @@ const it = testEffect(
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -20,7 +20,7 @@ afterEach(async () => {
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -10,7 +10,7 @@ import { testEffect } from "../lib/effect"
const ctx = {
sessionID: SessionID.make("ses_test-session"),
messageID: MessageID.make("test-message"),
messageID: MessageID.make("msg_test-message"),
callID: "test-call",
agent: "test-agent",
abort: AbortSignal.any([]),

View File

@@ -24,7 +24,7 @@ afterEach(async () => {
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -19,7 +19,7 @@ afterEach(async () => {
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "scout",
abort: AbortSignal.any([]),

View File

@@ -18,7 +18,7 @@ afterEach(async () => {
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "scout",
abort: AbortSignal.any([]),

View File

@@ -36,7 +36,7 @@ const initShell = initBash
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -14,7 +14,7 @@ import { testEffect } from "../lib/effect"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -13,7 +13,7 @@ const projectRoot = path.join(import.meta.dir, "../..")
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("message"),
messageID: MessageID.make("msg_message"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),

View File

@@ -18,7 +18,7 @@ import { testEffect } from "../lib/effect"
const ctx = {
sessionID: SessionID.make("ses_test-write-session"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),