Просмотр исходного кода

fix(httpapi): preserve optional session fields (#24671)

Kit Langton 4 месяцев назад
Родитель
Сommit
576efed196

+ 25 - 11
packages/opencode/src/server/routes/instance/httpapi/session.ts

@@ -3,6 +3,7 @@ import { AppRuntime } from "@/effect/app-runtime"
 import { Agent } from "@/agent/agent"
 import { Bus } from "@/bus"
 import { Command } from "@/command"
+import { WorkspaceID } from "@/control-plane/schema"
 import { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 import { Instance } from "@/project/instance"
@@ -21,7 +22,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema"
 import { Snapshot } from "@/snapshot"
 import * as Log from "@opencode-ai/core/util/log"
 import { NamedError } from "@opencode-ai/core/util/error"
-import { Effect, Layer, Schema, Struct } from "effect"
+import { Effect, Layer, Option, Schema, SchemaGetter, Struct } from "effect"
 import * as Stream from "effect/Stream"
 import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 import {
@@ -44,6 +45,19 @@ const ListQuery = Schema.Struct({
   search: Schema.optional(Schema.String),
   limit: Schema.optional(Schema.NumberFromString),
 })
+const omitUndefined = <S extends Schema.Top>(schema: S) =>
+  Schema.optionalKey(schema).pipe(
+    Schema.decodeTo(Schema.optional(schema), {
+      decode: SchemaGetter.passthrough({ strict: false }),
+      encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
+    }),
+  )
+const SessionInfoResponse = Session.Info.mapFields(
+  Struct.evolve({
+    workspaceID: () => omitUndefined(WorkspaceID),
+    parentID: () => omitUndefined(SessionID),
+  }),
+)
 const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
 const MessagesQuery = Schema.Struct({
   limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
@@ -123,7 +137,7 @@ export const SessionApi = HttpApi.make("session")
       .add(
         HttpApiEndpoint.get("list", SessionPaths.list, {
           query: ListQuery,
-          success: Schema.Array(Session.Info),
+          success: Schema.Array(SessionInfoResponse),
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.list",
@@ -142,7 +156,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.get("get", SessionPaths.get, {
           params: { sessionID: SessionID },
-          success: Session.Info,
+          success: SessionInfoResponse,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.get",
@@ -152,7 +166,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.get("children", SessionPaths.children, {
           params: { sessionID: SessionID },
-          success: Schema.Array(Session.Info),
+          success: Schema.Array(SessionInfoResponse),
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.children",
@@ -204,7 +218,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.post("create", SessionPaths.create, {
           payload: [HttpApiSchema.NoContent, Session.CreateInput],
-          success: Session.Info,
+          success: SessionInfoResponse,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.create",
@@ -225,7 +239,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.patch("update", SessionPaths.update, {
           params: { sessionID: SessionID },
           payload: UpdatePayload,
-          success: Session.Info,
+          success: SessionInfoResponse,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.update",
@@ -236,7 +250,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.post("fork", SessionPaths.fork, {
           params: { sessionID: SessionID },
           payload: ForkPayload,
-          success: Session.Info,
+          success: SessionInfoResponse,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.fork",
@@ -268,7 +282,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.post("share", SessionPaths.share, {
           params: { sessionID: SessionID },
-          success: Session.Info,
+          success: SessionInfoResponse,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.share",
@@ -278,7 +292,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.delete("unshare", SessionPaths.share, {
           params: { sessionID: SessionID },
-          success: Session.Info,
+          success: SessionInfoResponse,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.unshare",
@@ -345,7 +359,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.post("revert", SessionPaths.revert, {
           params: { sessionID: SessionID },
           payload: RevertPayload,
-          success: Session.Info,
+          success: SessionInfoResponse,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.revert",
@@ -356,7 +370,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
           params: { sessionID: SessionID },
-          success: Session.Info,
+          success: SessionInfoResponse,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.unrevert",

+ 239 - 223
packages/opencode/test/server/httpapi-session.test.ts

@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, test } from "bun:test"
+import { afterEach, describe, expect } from "bun:test"
 import type { UpgradeWebSocket } from "hono/ws"
 import { Effect } from "effect"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -13,6 +13,7 @@ import { MessageV2 } from "../../src/session/message-v2"
 import * as Log from "@opencode-ai/core/util/log"
 import { resetDatabase } from "../fixture/db"
 import { tmpdir } from "../fixture/fixture"
+import { it } from "../lib/effect"
 
 void Log.init({ print: false })
 
@@ -32,44 +33,70 @@ function pathFor(path: string, params: Record<string, string>) {
   return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
 }
 
-async function createSession(directory: string, input?: Session.CreateInput) {
-  return Instance.provide({
-    directory,
-    fn: async () => runSession(Session.Service.use((svc) => svc.create(input))),
-  })
+function createSession(directory: string, input?: Session.CreateInput) {
+  return Effect.promise(
+    async () =>
+      await Instance.provide({
+        directory,
+        fn: () => runSession(Session.Service.use((svc) => svc.create(input))),
+      }),
+  )
+}
+
+function createTextMessage(directory: string, sessionID: SessionID, text: string) {
+  return Effect.promise(
+    async () =>
+      await Instance.provide({
+        directory,
+        fn: () =>
+          runSession(
+            Effect.gen(function* () {
+              const svc = yield* Session.Service
+              const info = yield* svc.updateMessage({
+                id: MessageID.ascending(),
+                role: "user",
+                sessionID,
+                agent: "build",
+                model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
+                time: { created: Date.now() },
+              })
+              const part = yield* svc.updatePart({
+                id: PartID.ascending(),
+                sessionID,
+                messageID: info.id,
+                type: "text",
+                text,
+              })
+              return { info, part }
+            }),
+          ),
+      }),
+  )
+}
+
+function request(path: string, init?: RequestInit) {
+  return Effect.promise(async () => app().request(path, init))
 }
 
-async function createTextMessage(directory: string, sessionID: SessionID, text: string) {
-  return Instance.provide({
-    directory,
-    fn: async () =>
-      runSession(
-        Effect.gen(function* () {
-          const svc = yield* Session.Service
-          const info = yield* svc.updateMessage({
-            id: MessageID.ascending(),
-            role: "user",
-            sessionID,
-            agent: "build",
-            model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
-            time: { created: Date.now() },
-          })
-          const part = yield* svc.updatePart({
-            id: PartID.ascending(),
-            sessionID,
-            messageID: info.id,
-            type: "text",
-            text,
-          })
-          return { info, part }
-        }),
-      ),
+function json<T>(response: Response) {
+  return Effect.promise(async () => {
+    if (response.status !== 200) throw new Error(await response.text())
+    return (await response.json()) as T
   })
 }
 
-async function json<T>(response: Response) {
-  if (response.status !== 200) throw new Error(await response.text())
-  return (await response.json()) as T
+function requestJson<T>(path: string, init?: RequestInit) {
+  return request(path, init).pipe(Effect.flatMap(json<T>))
+}
+
+function withTmp<A, E, R>(
+  options: Parameters<typeof tmpdir>[0],
+  fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
+) {
+  return Effect.acquireRelease(
+    Effect.promise(() => tmpdir(options)),
+    (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+  ).pipe(Effect.flatMap(fn))
 }
 
 afterEach(async () => {
@@ -79,210 +106,199 @@ afterEach(async () => {
 })
 
 describe("session HttpApi", () => {
-  test("serves read routes through Hono bridge", async () => {
-    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
-    const headers = { "x-opencode-directory": tmp.path }
-    const parent = await createSession(tmp.path, { title: "parent" })
-    const child = await createSession(tmp.path, { title: "child", parentID: parent.id })
-    const message = await createTextMessage(tmp.path, parent.id, "hello")
-    await createTextMessage(tmp.path, parent.id, "world")
-
-    expect(
-      (await json<Session.Info[]>(await app().request(`${SessionPaths.list}?roots=true`, { headers }))).map(
-        (item) => item.id,
-      ),
-    ).toContain(parent.id)
-
-    expect(await json<Record<string, unknown>>(await app().request(SessionPaths.status, { headers }))).toEqual({})
-
-    expect(
-      await json<Session.Info>(await app().request(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers })),
-    ).toMatchObject({ id: parent.id, title: "parent" })
-
-    expect(
-      (
-        await json<Session.Info[]>(
-          await app().request(pathFor(SessionPaths.children, { sessionID: parent.id }), { headers }),
-        )
-      ).map((item) => item.id),
-    ).toEqual([child.id])
-
-    expect(
-      await json<unknown[]>(await app().request(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers })),
-    ).toEqual([])
-
-    expect(
-      await json<unknown[]>(await app().request(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers })),
-    ).toEqual([])
-
-    const messages = await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, {
-      headers,
-    })
-    const messagePage = await json<MessageV2.WithParts[]>(messages)
-    const nextCursor = messages.headers.get("x-next-cursor")
-    expect(nextCursor).toBeTruthy()
-    expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
-
-    expect(
-      (
-        await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, {
+  it.live(
+    "serves read routes through Hono bridge",
+    withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const headers = { "x-opencode-directory": tmp.path }
+        const parent = yield* createSession(tmp.path, { title: "parent" })
+        const child = yield* createSession(tmp.path, { title: "child", parentID: parent.id })
+        const message = yield* createTextMessage(tmp.path, parent.id, "hello")
+        yield* createTextMessage(tmp.path, parent.id, "world")
+
+        const listed = yield* requestJson<Session.Info[]>(`${SessionPaths.list}?roots=true`, { headers })
+        expect(listed.map((item) => item.id)).toContain(parent.id)
+        expect(Object.hasOwn(listed[0]!, "parentID")).toBe(false)
+
+        expect(yield* requestJson<Record<string, unknown>>(SessionPaths.status, { headers })).toEqual({})
+
+        expect(
+          yield* requestJson<Session.Info>(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers }),
+        ).toMatchObject({ id: parent.id, title: "parent" })
+
+        expect(
+          (yield* requestJson<Session.Info[]>(pathFor(SessionPaths.children, { sessionID: parent.id }), {
+            headers,
+          })).map((item) => item.id),
+        ).toEqual([child.id])
+
+        expect(
+          yield* requestJson<unknown[]>(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers }),
+        ).toEqual([])
+
+        expect(
+          yield* requestJson<unknown[]>(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers }),
+        ).toEqual([])
+
+        const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, {
           headers,
         })
-      ).status,
-    ).toBe(400)
-    expect(
-      (
-        await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, {
+        const messagePage = yield* json<MessageV2.WithParts[]>(messages)
+        const nextCursor = messages.headers.get("x-next-cursor")
+        expect(nextCursor).toBeTruthy()
+        expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
+
+        expect(
+          (yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, {
+            headers,
+          })).status,
+        ).toBe(400)
+        expect(
+          (yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, {
+            headers,
+          })).status,
+        ).toBe(400)
+
+        expect(
+          yield* requestJson<MessageV2.WithParts>(
+            pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }),
+            { headers },
+          ),
+        ).toMatchObject({ info: { id: message.info.id } })
+      }),
+    ),
+  )
+
+  it.live(
+    "serves lifecycle mutation routes through Hono bridge",
+    withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
+      Effect.gen(function* () {
+        const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
+
+        const createdEmpty = yield* requestJson<Session.Info>(SessionPaths.create, {
+          method: "POST",
           headers,
         })
-      ).status,
-    ).toBe(400)
+        expect(createdEmpty.id).toBeTruthy()
 
-    expect(
-      await json<MessageV2.WithParts>(
-        await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }), {
+        const created = yield* requestJson<Session.Info>(SessionPaths.create, {
+          method: "POST",
           headers,
-        }),
-      ),
-    ).toMatchObject({ info: { id: message.info.id } })
-  })
-
-  test("serves lifecycle mutation routes through Hono bridge", async () => {
-    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false, share: "disabled" } })
-    const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
-
-    const createdEmpty = await json<Session.Info>(
-      await app().request(SessionPaths.create, {
-        method: "POST",
-        headers,
-      }),
-    )
-    expect(createdEmpty.id).toBeTruthy()
-
-    const created = await json<Session.Info>(
-      await app().request(SessionPaths.create, {
-        method: "POST",
-        headers,
-        body: JSON.stringify({ title: "created" }),
-      }),
-    )
-    expect(created.title).toBe("created")
-
-    const updated = await json<Session.Info>(
-      await app().request(pathFor(SessionPaths.update, { sessionID: created.id }), {
-        method: "PATCH",
-        headers,
-        body: JSON.stringify({ title: "updated", time: { archived: 1 } }),
-      }),
-    )
-    expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } })
-
-    const forked = await json<Session.Info>(
-      await app().request(pathFor(SessionPaths.fork, { sessionID: created.id }), {
-        method: "POST",
-        headers,
-        body: JSON.stringify({}),
-      }),
-    )
-    expect(forked.id).not.toBe(created.id)
-
-    expect(
-      await json<boolean>(
-        await app().request(pathFor(SessionPaths.abort, { sessionID: created.id }), { method: "POST", headers }),
-      ),
-    ).toBe(true)
-
-    expect(
-      await json<boolean>(
-        await app().request(pathFor(SessionPaths.remove, { sessionID: created.id }), { method: "DELETE", headers }),
-      ),
-    ).toBe(true)
-  })
+          body: JSON.stringify({ title: "created" }),
+        })
+        expect(created.title).toBe("created")
 
-  test("serves message mutation routes through Hono bridge", async () => {
-    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
-    const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
-    const session = await createSession(tmp.path, { title: "messages" })
-    const first = await createTextMessage(tmp.path, session.id, "first")
-    const second = await createTextMessage(tmp.path, session.id, "second")
-
-    const updated = await json<MessageV2.Part>(
-      await app().request(
-        pathFor(SessionPaths.updatePart, {
-          sessionID: session.id,
-          messageID: first.info.id,
-          partID: first.part.id,
-        }),
-        {
+        const updated = yield* requestJson<Session.Info>(pathFor(SessionPaths.update, { sessionID: created.id }), {
           method: "PATCH",
           headers,
-          body: JSON.stringify({ ...first.part, text: "updated" }),
-        },
-      ),
-    )
-    expect(updated).toMatchObject({ id: first.part.id, type: "text", text: "updated" })
-
-    expect(
-      await json<boolean>(
-        await app().request(
-          pathFor(SessionPaths.deletePart, {
-            sessionID: session.id,
-            messageID: first.info.id,
-            partID: first.part.id,
-          }),
-          { method: "DELETE", headers },
-        ),
-      ),
-    ).toBe(true)
-
-    expect(
-      await json<boolean>(
-        await app().request(pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID: second.info.id }), {
-          method: "DELETE",
-          headers,
-        }),
-      ),
-    ).toBe(true)
-  })
-
-  test("serves remaining non-LLM session mutation routes through Hono bridge", async () => {
-    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
-    const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
-    const session = await createSession(tmp.path, { title: "remaining" })
+          body: JSON.stringify({ title: "updated", time: { archived: 1 } }),
+        })
+        expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } })
 
-    expect(
-      await json<Session.Info>(
-        await app().request(pathFor(SessionPaths.revert, { sessionID: session.id }), {
-          method: "POST",
-          headers,
-          body: JSON.stringify({ messageID: MessageID.ascending() }),
-        }),
-      ),
-    ).toMatchObject({ id: session.id })
-
-    expect(
-      await json<Session.Info>(
-        await app().request(pathFor(SessionPaths.unrevert, { sessionID: session.id }), {
+        const forked = yield* requestJson<Session.Info>(pathFor(SessionPaths.fork, { sessionID: created.id }), {
           method: "POST",
           headers,
-        }),
-      ),
-    ).toMatchObject({ id: session.id })
-
-    expect(
-      await json<boolean>(
-        await app().request(
-          pathFor(SessionPaths.permissions, {
+          body: JSON.stringify({}),
+        })
+        expect(forked.id).not.toBe(created.id)
+
+        expect(
+          yield* requestJson<boolean>(pathFor(SessionPaths.abort, { sessionID: created.id }), {
+            method: "POST",
+            headers,
+          }),
+        ).toBe(true)
+
+        expect(
+          yield* requestJson<boolean>(pathFor(SessionPaths.remove, { sessionID: created.id }), {
+            method: "DELETE",
+            headers,
+          }),
+        ).toBe(true)
+      }),
+    ),
+  )
+
+  it.live(
+    "serves message mutation routes through Hono bridge",
+    withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
+        const session = yield* createSession(tmp.path, { title: "messages" })
+        const first = yield* createTextMessage(tmp.path, session.id, "first")
+        const second = yield* createTextMessage(tmp.path, session.id, "second")
+
+        const updated = yield* requestJson<MessageV2.Part>(
+          pathFor(SessionPaths.updatePart, {
             sessionID: session.id,
-            permissionID: String(PermissionID.ascending()),
+            messageID: first.info.id,
+            partID: first.part.id,
           }),
           {
-            method: "POST",
+            method: "PATCH",
             headers,
-            body: JSON.stringify({ response: "once" }),
+            body: JSON.stringify({ ...first.part, text: "updated" }),
           },
-        ),
-      ),
-    ).toBe(true)
-  })
+        )
+        expect(updated).toMatchObject({ id: first.part.id, type: "text", text: "updated" })
+
+        expect(
+          yield* requestJson<boolean>(
+            pathFor(SessionPaths.deletePart, {
+              sessionID: session.id,
+              messageID: first.info.id,
+              partID: first.part.id,
+            }),
+            { method: "DELETE", headers },
+          ),
+        ).toBe(true)
+
+        expect(
+          yield* requestJson<boolean>(
+            pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID: second.info.id }),
+            { method: "DELETE", headers },
+          ),
+        ).toBe(true)
+      }),
+    ),
+  )
+
+  it.live(
+    "serves remaining non-LLM session mutation routes through Hono bridge",
+    withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
+        const session = yield* createSession(tmp.path, { title: "remaining" })
+
+        expect(
+          yield* requestJson<Session.Info>(pathFor(SessionPaths.revert, { sessionID: session.id }), {
+            method: "POST",
+            headers,
+            body: JSON.stringify({ messageID: MessageID.ascending() }),
+          }),
+        ).toMatchObject({ id: session.id })
+
+        expect(
+          yield* requestJson<Session.Info>(pathFor(SessionPaths.unrevert, { sessionID: session.id }), {
+            method: "POST",
+            headers,
+          }),
+        ).toMatchObject({ id: session.id })
+
+        expect(
+          yield* requestJson<boolean>(
+            pathFor(SessionPaths.permissions, {
+              sessionID: session.id,
+              permissionID: String(PermissionID.ascending()),
+            }),
+            {
+              method: "POST",
+              headers,
+              body: JSON.stringify({ response: "once" }),
+            },
+          ),
+        ).toBe(true)
+      }),
+    ),
+  )
 })