Context: PR #26569 narrowly fixed a crash where the generated SDK sent GET /session/{sessionID}/message?limit=80&directory=... because public.ts manually injected InstanceQueryParameters (directory/workspace) into OpenAPI, but the runtime MessagesQuery schema omitted directory, causing empty 400. This change eliminates the drift by: 1. Creating a shared schema helper in query.ts that adds directory/workspace fields to all instance route query schemas. 2. Updating all instance route query schemas to use the helper: - session.ts: MessagesQuery, ListQuery - file.ts: FileQuery, FindTextQuery, FindFileQuery, FindSymbolQuery - experimental.ts: ToolListQuery, SessionListQuery - control.ts: LogQuery (already correct, now uses helper) - instance.ts, v2/session.ts, v2/message.ts 3. Adding reproducer tests in httpapi-query-schema-drift.test.ts that verify the runtime accepts directory/workspace params on affected routes. The OpenAPI spec generation in public.ts still manually injects params for backward compatibility with the legacy SDK format, but now the runtime schemas match, eliminating the validation errors. Verification: - bun typecheck passes - 4 drift reproducer tests pass - 24 httpapi tests pass across session, file, experimental, workspace-routing
192 lines
6.3 KiB
TypeScript
192 lines
6.3 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test"
|
|
import { Effect } from "effect"
|
|
import { Instance } from "../../src/project/instance"
|
|
import { WithInstance } from "../../src/project/with-instance"
|
|
import { Server } from "../../src/server/server"
|
|
import { Session as SessionNs } from "@/session/session"
|
|
import { MessageV2 } from "../../src/session/message-v2"
|
|
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
|
import * as Log from "@opencode-ai/core/util/log"
|
|
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
|
|
|
void Log.init({ print: false })
|
|
|
|
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
|
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
|
}
|
|
|
|
const svc = {
|
|
...SessionNs,
|
|
create(input?: SessionNs.CreateInput) {
|
|
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
|
},
|
|
remove(id: SessionID) {
|
|
return run(SessionNs.Service.use((svc) => svc.remove(id)))
|
|
},
|
|
updateMessage<T extends MessageV2.Info>(msg: T) {
|
|
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
|
},
|
|
updatePart<T extends MessageV2.Part>(part: T) {
|
|
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
|
},
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await disposeAllInstances()
|
|
})
|
|
|
|
async function withoutWatcher<T>(fn: () => Promise<T>) {
|
|
if (process.platform !== "win32") return fn()
|
|
const prev = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
|
process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true"
|
|
try {
|
|
return await fn()
|
|
} finally {
|
|
if (prev === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
|
else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = prev
|
|
}
|
|
}
|
|
|
|
async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
|
|
const ids = [] as MessageID[]
|
|
for (let i = 0; i < count; i++) {
|
|
const id = MessageID.ascending()
|
|
ids.push(id)
|
|
await svc.updateMessage({
|
|
id,
|
|
sessionID,
|
|
role: "user",
|
|
time: { created: time(i) },
|
|
agent: "test",
|
|
model: { providerID: "test", modelID: "test" },
|
|
tools: {},
|
|
mode: "",
|
|
} as unknown as MessageV2.Info)
|
|
await svc.updatePart({
|
|
id: PartID.ascending(),
|
|
sessionID,
|
|
messageID: id,
|
|
type: "text",
|
|
text: `m${i}`,
|
|
})
|
|
}
|
|
return ids
|
|
}
|
|
|
|
describe("session messages endpoint", () => {
|
|
test("returns cursor headers for older pages", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
await withoutWatcher(() =>
|
|
WithInstance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const session = await svc.create({})
|
|
const ids = await fill(session.id, 5)
|
|
const app = Server.Default().app
|
|
|
|
const a = await app.request(`/session/${session.id}/message?limit=2`)
|
|
expect(a.status).toBe(200)
|
|
const aBody = (await a.json()) as MessageV2.WithParts[]
|
|
expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2))
|
|
const cursor = a.headers.get("x-next-cursor")
|
|
expect(cursor).toBeTruthy()
|
|
expect(a.headers.get("link")).toContain('rel="next"')
|
|
|
|
const b = await app.request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`)
|
|
expect(b.status).toBe(200)
|
|
const bBody = (await b.json()) as MessageV2.WithParts[]
|
|
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
|
|
|
|
await svc.remove(session.id)
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
|
|
test("keeps full-history responses when limit is omitted", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
await withoutWatcher(() =>
|
|
WithInstance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const session = await svc.create({})
|
|
const ids = await fill(session.id, 3)
|
|
const app = Server.Default().app
|
|
|
|
const res = await app.request(`/session/${session.id}/message`)
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as MessageV2.WithParts[]
|
|
expect(body.map((item) => item.info.id)).toEqual(ids)
|
|
|
|
await svc.remove(session.id)
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
|
|
test("rejects invalid cursors and missing sessions", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
await withoutWatcher(() =>
|
|
WithInstance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const session = await svc.create({})
|
|
const app = Server.Default().app
|
|
|
|
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
|
|
expect(bad.status).toBe(400)
|
|
|
|
const miss = await app.request(`/session/ses_missing/message?limit=2`)
|
|
expect(miss.status).toBe(404)
|
|
|
|
await svc.remove(session.id)
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
|
|
test("does not truncate large legacy limit requests", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
await withoutWatcher(() =>
|
|
WithInstance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const session = await svc.create({})
|
|
await fill(session.id, 520)
|
|
const app = Server.Default().app
|
|
|
|
const res = await app.request(`/session/${session.id}/message?limit=510`)
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as MessageV2.WithParts[]
|
|
expect(body).toHaveLength(510)
|
|
|
|
await svc.remove(session.id)
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
|
|
test("accepts workspace routing query params with paginated message requests", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
await withoutWatcher(() =>
|
|
WithInstance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const session = await svc.create({})
|
|
await fill(session.id, 1)
|
|
const app = Server.Default().app
|
|
|
|
const directory = await app.request(
|
|
`/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`,
|
|
)
|
|
const workspace = await app.request(`/session/${session.id}/message?limit=80&workspace=wrk_test`)
|
|
|
|
expect(directory.status).toBe(200)
|
|
expect(workspace.status).toBe(200)
|
|
|
|
await svc.remove(session.id)
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
})
|