diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index eecdb1782c..101562634a 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -45,6 +45,7 @@ type Endpoint4_0Input = { readonly limit?: Endpoint4_0Request["query"]["limit"] readonly order?: Endpoint4_0Request["query"]["order"] readonly search?: Endpoint4_0Request["query"]["search"] + readonly parentID?: Endpoint4_0Request["query"]["parentID"] readonly directory?: Endpoint4_0Request["query"]["directory"] readonly project?: Endpoint4_0Request["query"]["project"] readonly subpath?: Endpoint4_0Request["query"]["subpath"] @@ -57,6 +58,7 @@ const Endpoint4_0 = (raw: RawClient["server.session"]) => (input?: Endpoint4_0In limit: input?.["limit"], order: input?.["order"], search: input?.["search"], + parentID: input?.["parentID"], directory: input?.["directory"], project: input?.["project"], subpath: input?.["subpath"], diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 3e3a802032..69aa3d3501 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -356,6 +356,7 @@ export function make(options: ClientOptions) { limit: input?.["limit"], order: input?.["order"], search: input?.["search"], + parentID: input?.["parentID"], directory: input?.["directory"], project: input?.["project"], subpath: input?.["subpath"], @@ -1361,7 +1362,11 @@ function encodePath(value: string): string { } function appendQuery(params: URLSearchParams, key: string, value: unknown): void { - if (value === undefined || value === null) return + if (value === undefined) return + if (value === null) { + params.append(key, "null") + return + } if (Array.isArray(value)) { for (const item of value) appendQuery(params, key, item) return diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 56a84703f2..01a359f3e8 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -218,6 +218,7 @@ export type SessionListInput = { readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined + readonly parentID?: string | null | undefined readonly directory?: string | undefined readonly project?: string | undefined readonly subpath?: string | undefined @@ -228,6 +229,7 @@ export type SessionListInput = { readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined + readonly parentID?: string | null | undefined readonly directory?: string | undefined readonly project?: string | undefined readonly subpath?: string | undefined @@ -238,6 +240,7 @@ export type SessionListInput = { readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined + readonly parentID?: string | null | undefined readonly directory?: string | undefined readonly project?: string | undefined readonly subpath?: string | undefined @@ -248,16 +251,29 @@ export type SessionListInput = { readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined + readonly parentID?: string | null | undefined readonly directory?: string | undefined readonly project?: string | undefined readonly subpath?: string | undefined readonly cursor?: string | undefined }["search"] + readonly parentID?: { + readonly workspace?: string | undefined + readonly limit?: number | undefined + readonly order?: "asc" | "desc" | undefined + readonly search?: string | undefined + readonly parentID?: string | null | undefined + readonly directory?: string | undefined + readonly project?: string | undefined + readonly subpath?: string | undefined + readonly cursor?: string | undefined + }["parentID"] readonly directory?: { readonly workspace?: string | undefined readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined + readonly parentID?: string | null | undefined readonly directory?: string | undefined readonly project?: string | undefined readonly subpath?: string | undefined @@ -268,6 +284,7 @@ export type SessionListInput = { readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined + readonly parentID?: string | null | undefined readonly directory?: string | undefined readonly project?: string | undefined readonly subpath?: string | undefined @@ -278,6 +295,7 @@ export type SessionListInput = { readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined + readonly parentID?: string | null | undefined readonly directory?: string | undefined readonly project?: string | undefined readonly subpath?: string | undefined @@ -288,6 +306,7 @@ export type SessionListInput = { readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined + readonly parentID?: string | null | undefined readonly directory?: string | undefined readonly project?: string | undefined readonly subpath?: string | undefined diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index abbba65d28..bf1b373c3b 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -247,7 +247,7 @@ test("session methods use the public HTTP contract", async () => { }, }) - const page = await client.session.list({ limit: 10, order: "desc" }) + const page = await client.session.list({ limit: 10, order: "desc", parentID: null }) const active = await client.session.active() const created = await client.session.create({ location: { directory: "/tmp/project" } }) await client.session.switchAgent({ sessionID: "ses_test", agent: "build" }) @@ -276,7 +276,7 @@ test("session methods use the public HTTP contract", async () => { expect(log).toEqual([modelSwitchedEvent, caughtUp]) expect(message).toEqual(modelSwitchedMessage) expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ - ["GET", "http://localhost:3000/api/session?limit=10&order=desc"], + ["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"], ["GET", "http://localhost:3000/api/session/active"], ["POST", "http://localhost:3000/api/session"], ["POST", "http://localhost:3000/api/session/ses_test/agent"], diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 35a86b530a..4c7e43066b 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -3,7 +3,7 @@ export * from "./session/schema" import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect" import { ListAnchor } from "@opencode-ai/schema/session" -import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm" +import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" import { WorkspaceV2 } from "./workspace" import { ModelV2 } from "./model" @@ -61,6 +61,7 @@ const ListInputBase = { search: Schema.String.pipe(Schema.optional), limit: PositiveInt.pipe(Schema.optional), order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional), + parentID: Schema.NullOr(SessionSchema.ID).pipe(Schema.optional), anchor: ListAnchor.pipe(Schema.optional), } @@ -358,12 +359,16 @@ const layer = Layer.effect( const direction = input.anchor?.direction ?? "next" const requestedOrder = input.order ?? "desc" const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder - const sortColumn = SessionTable.time_created + const sortColumn = SessionTable.time_updated const conditions: SQL[] = [] if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.parentID !== undefined) + conditions.push( + input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID), + ) if (input.anchor) { conditions.push( order === "asc" diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 27d8c6fe04..5afd2cd9c4 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -130,6 +130,53 @@ describe("SessionV2.create", () => { }), ) + it.effect("filters root sessions before applying the page limit", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const { db } = yield* Database.Service + const staleRoot = yield* session.create({ location, title: "stale root" }) + const root = yield* session.create({ location, title: "root" }) + const children = yield* Effect.forEach(Array.from({ length: 60 }), (_, index) => + session.create({ parentID: root.id, title: `child ${index}` }), + ) + + yield* Effect.forEach(children, (item, index) => + db + .update(SessionTable) + .set({ time_created: index + 100, time_updated: index + 20_000 }) + .where(eq(SessionTable.id, item.id)) + .run(), + ) + yield* db + .update(SessionTable) + .set({ time_created: 2, time_updated: 5_000 }) + .where(eq(SessionTable.id, staleRoot.id)) + .run() + yield* db + .update(SessionTable) + .set({ time_created: 1, time_updated: 10_000 }) + .where(eq(SessionTable.id, root.id)) + .run() + + const page = yield* session.list({ directory: location.directory, parentID: null, limit: 1, order: "desc" }) + + expect(page.data.map((item) => item.id)).toEqual([root.id]) + }), + ) + + it.effect("filters direct child sessions by parent ID", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const parent = yield* session.create({ location, title: "parent" }) + const child = yield* session.create({ parentID: parent.id, title: "child" }) + yield* session.create({ location, title: "other root" }) + + const page = yield* session.list({ parentID: parent.id }) + + expect(page.data.map((item) => item.id)).toEqual([child.id]) + }), + ) + it.effect("forks a session by replaying a durable fork event into copied projected rows", () => Effect.gen(function* () { const session = yield* SessionV2.Service diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 9bd5850bde..8f22b4553f 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -633,7 +633,7 @@ function renderPromiseClient(groups: ReadonlyArray) { if (group.endpoints[0]?.topLevel) return methods.join(", ") return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` }) - return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` + return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined) return\n if (value === null) {\n params.append(key, "null")\n return\n }\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` } function promiseTypePrefix(group: string, endpoint: string) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 261b670e46..4ee5749b1d 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -521,6 +521,39 @@ describe("HttpApiCodegen.generate", () => { } }) + test("serializes explicit null query values", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("list", "/session", { + query: { parentID: Schema.optional(Schema.NullOr(Schema.String)) }, + success: Schema.Struct({ data: Schema.Array(Schema.String) }), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + request = input instanceof Request ? input : new Request(input, init) + return Response.json({ data: [] }) + }, + }) + + await client.session.list({ parentID: null }) + + expect(request?.url).toBe("https://example.com/session?parentID=null") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + test("rejects with declared tagged errors and exports a type guard", async () => { const output = emitPromise( compileContract( diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index e1e73d6264..47c5380e75 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -47,6 +47,7 @@ export type Endpoint4_0Input = { readonly limit?: Endpoint4_0Request["query"]["limit"] readonly order?: Endpoint4_0Request["query"]["order"] readonly search?: Endpoint4_0Request["query"]["search"] + readonly parentID?: Endpoint4_0Request["query"]["parentID"] readonly directory?: Endpoint4_0Request["query"]["directory"] readonly project?: Endpoint4_0Request["query"]["project"] readonly subpath?: Endpoint4_0Request["query"]["subpath"] diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 4a8e5f2351..aca615220f 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -29,6 +29,18 @@ import { Revert } from "@opencode-ai/schema/revert" import { SessionEvent } from "@opencode-ai/schema/session-event" import { EventLog } from "@opencode-ai/schema/event-log" +const ParentIDFilter = Schema.Union([ + Session.ID, + Schema.Null.pipe( + Schema.encodeTo(Schema.Literal("null"), { + decode: SchemaGetter.transform(() => null), + encode: SchemaGetter.transform(() => "null" as const), + }), + ), +]).annotate({ + description: "Filter by parent session. Use null to return only root sessions.", +}) + const SessionsQueryFields = { workspace: Workspace.ID.pipe(Schema.optional), limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ @@ -38,6 +50,7 @@ const SessionsQueryFields = { description: "Session order for the first page. Use desc for newest first or asc for oldest first.", }), search: Schema.optional(Schema.String), + parentID: ParentIDFilter.pipe(Schema.optional), } const SessionsDirectoryQuery = Schema.Struct({ diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index d6e32fb932..edc49da7fe 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -104,6 +104,19 @@ if (logTypesPatched === generatedTypes) { } await Bun.write("./src/v2/gen/types.gen.ts", logTypesPatched) +const querySerializerPath = "./src/v2/gen/client/utils.gen.ts" +const querySerializerSource = await Bun.file(querySerializerPath).text() +const querySerializerPatched = querySerializerSource.replace( + /if \(value === undefined \|\| value === null\) \{\s*continue;?\s*\}/, + "if (value === undefined) {\n continue;\n }\n\n if (value === null) {\n search.push(`${name}=null`);\n continue;\n }", +) +if (querySerializerPatched === querySerializerSource) { + throw new Error( + `Query serializer null patch did not apply; @hey-api/openapi-ts output may have changed (${querySerializerPath})`, + ) +} +await Bun.write(querySerializerPath, querySerializerPatched) + const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text() const logSdkPatched = generatedSdk.replace( /(Read the session log[\s\S]*?parameters: \{[\s\S]*?after\?: )string(\s*\|\s*null)?/, @@ -196,5 +209,7 @@ function inlineTypedAllOfConstraints(value: unknown): void { function isConstraintSchema(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return false - return !Object.keys(value).some((key) => key === "$ref" || key === "type" || key === "allOf" || key === "anyOf" || key === "oneOf") + return !Object.keys(value).some( + (key) => key === "$ref" || key === "type" || key === "allOf" || key === "anyOf" || key === "oneOf", + ) } diff --git a/packages/sdk/js/src/v2/gen/client/utils.gen.ts b/packages/sdk/js/src/v2/gen/client/utils.gen.ts index 3b1dfb7871..49c07010ba 100644 --- a/packages/sdk/js/src/v2/gen/client/utils.gen.ts +++ b/packages/sdk/js/src/v2/gen/client/utils.gen.ts @@ -14,7 +14,12 @@ export const createQuerySerializer = ({ parameters = {}, ...args }: for (const name in queryParams) { const value = queryParams[name] - if (value === undefined || value === null) { + if (value === undefined) { + continue + } + + if (value === null) { + search.push(`${name}=null`) continue } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 827babbaed..f60731cea2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5742,6 +5742,7 @@ export class Session3 extends HeyApiClient { limit?: string | null order?: "asc" | "desc" | null search?: string | null + parentID?: string | "null" | null directory?: string | null project?: string | null subpath?: string | null @@ -5758,6 +5759,7 @@ export class Session3 extends HeyApiClient { { in: "query", key: "limit" }, { in: "query", key: "order" }, { in: "query", key: "search" }, + { in: "query", key: "parentID" }, { in: "query", key: "directory" }, { in: "query", key: "project" }, { in: "query", key: "subpath" }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6605e897ca..5111d22961 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -12540,6 +12540,7 @@ export type V2SessionListData = { */ order?: "asc" | "desc" | null search?: string | null + parentID?: string | "null" | null directory?: string | null project?: string | null subpath?: string | null diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 9f44b9dce7..f6dbabdbf8 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -51,7 +51,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl ...query, anchor: { id: first.id, - time: DateTime.toEpochMillis(first.time.created), + time: DateTime.toEpochMillis(first.time.updated), direction: "previous", }, }) @@ -61,7 +61,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl ...query, anchor: { id: last.id, - time: DateTime.toEpochMillis(last.time.created), + time: DateTime.toEpochMillis(last.time.updated), direction: "next", }, }) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 7aff0f8d79..fa68adb8ad 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -531,23 +531,33 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi let continued = false createEffect(() => { - // When using -c, session list is loaded in blocking phase, so we can navigate at "partial" if (continued || sync.status === "loading" || !args.continue) return - const match = data.session.list().find((session) => !session.parentID)?.id - if (match) { - continued = true - if (args.fork) { + continued = true + const location = data.location.default() + void sdk.api.session + .list({ + limit: 1, + order: "desc", + parentID: null, + directory: location.directory, + workspace: location.workspaceID, + }) + .then((response) => { + const match = response.data[0]?.id + if (!match) return + if (!args.fork) { + route.navigate({ type: "session", sessionID: match }) + return + } void sdk.client.session.fork({ sessionID: match }).then((result) => { if (result.data?.id) { route.navigate({ type: "session", sessionID: result.data.id }) - } else { - toast.show({ message: "Failed to fork session", variant: "error" }) + return } + toast.show({ message: "Failed to fork session", variant: "error" }) }) - } else { - route.navigate({ type: "session", sessionID: match }) - } - } + }) + .catch(toast.error) }) // Handle --session with --fork: wait for sync to be fully complete before forking diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index b6117c5ca6..9232acf6d9 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -36,6 +36,7 @@ export function DialogSessionList() { search: query, limit: 50, order: "desc", + parentID: null, directory: location.directory, workspace: location.workspaceID, })