Explorar el Código

fix(session): omit undefined optional fields (#24676)

Kit Langton hace 3 meses
padre
commit
c4a2353ac3

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

@@ -3,7 +3,6 @@ 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"
@@ -22,7 +21,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, Option, Schema, SchemaGetter, Struct } from "effect"
+import { Effect, Layer, Schema, Struct } from "effect"
 import * as Stream from "effect/Stream"
 import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 import {
@@ -45,19 +44,6 @@ 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))),
@@ -137,7 +123,7 @@ export const SessionApi = HttpApi.make("session")
       .add(
         HttpApiEndpoint.get("list", SessionPaths.list, {
           query: ListQuery,
-          success: Schema.Array(SessionInfoResponse),
+          success: Schema.Array(Session.Info),
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.list",
@@ -156,7 +142,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.get("get", SessionPaths.get, {
           params: { sessionID: SessionID },
-          success: SessionInfoResponse,
+          success: Session.Info,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.get",
@@ -166,7 +152,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.get("children", SessionPaths.children, {
           params: { sessionID: SessionID },
-          success: Schema.Array(SessionInfoResponse),
+          success: Schema.Array(Session.Info),
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.children",
@@ -218,7 +204,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.post("create", SessionPaths.create, {
           payload: [HttpApiSchema.NoContent, Session.CreateInput],
-          success: SessionInfoResponse,
+          success: Session.Info,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.create",
@@ -239,7 +225,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.patch("update", SessionPaths.update, {
           params: { sessionID: SessionID },
           payload: UpdatePayload,
-          success: SessionInfoResponse,
+          success: Session.Info,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.update",
@@ -250,7 +236,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.post("fork", SessionPaths.fork, {
           params: { sessionID: SessionID },
           payload: ForkPayload,
-          success: SessionInfoResponse,
+          success: Session.Info,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.fork",
@@ -282,7 +268,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.post("share", SessionPaths.share, {
           params: { sessionID: SessionID },
-          success: SessionInfoResponse,
+          success: Session.Info,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.share",
@@ -292,7 +278,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.delete("unshare", SessionPaths.share, {
           params: { sessionID: SessionID },
-          success: SessionInfoResponse,
+          success: Session.Info,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.unshare",
@@ -359,7 +345,7 @@ export const SessionApi = HttpApi.make("session")
         HttpApiEndpoint.post("revert", SessionPaths.revert, {
           params: { sessionID: SessionID },
           payload: RevertPayload,
-          success: SessionInfoResponse,
+          success: Session.Info,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.revert",
@@ -370,7 +356,7 @@ export const SessionApi = HttpApi.make("session")
         ),
         HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
           params: { sessionID: SessionID },
-          success: SessionInfoResponse,
+          success: Session.Info,
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "session.unrevert",

+ 14 - 14
packages/opencode/src/session/session.ts

@@ -37,7 +37,7 @@ import { Permission } from "@/permission"
 import { Global } from "@opencode-ai/core/global"
 import { Effect, Layer, Option, Context, Schema, Types } from "effect"
 import { zod } from "@/util/effect-zod"
-import { withStatics } from "@/util/schema"
+import { optionalOmitUndefined, withStatics } from "@/util/schema"
 
 const log = Log.create({ service: "session" })
 
@@ -128,7 +128,7 @@ const Summary = Schema.Struct({
   additions: Schema.Number,
   deletions: Schema.Number,
   files: Schema.Number,
-  diffs: Schema.optional(Schema.Array(Snapshot.FileDiff)),
+  diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
 })
 
 const Share = Schema.Struct({
@@ -138,31 +138,31 @@ const Share = Schema.Struct({
 const Time = Schema.Struct({
   created: Schema.Number,
   updated: Schema.Number,
-  compacting: Schema.optional(Schema.Number),
-  archived: Schema.optional(Schema.Number),
+  compacting: optionalOmitUndefined(Schema.Number),
+  archived: optionalOmitUndefined(Schema.Number),
 })
 
 const Revert = Schema.Struct({
   messageID: MessageID,
-  partID: Schema.optional(PartID),
-  snapshot: Schema.optional(Schema.String),
-  diff: Schema.optional(Schema.String),
+  partID: optionalOmitUndefined(PartID),
+  snapshot: optionalOmitUndefined(Schema.String),
+  diff: optionalOmitUndefined(Schema.String),
 })
 
 export const Info = Schema.Struct({
   id: SessionID,
   slug: Schema.String,
   projectID: ProjectID,
-  workspaceID: Schema.optional(WorkspaceID),
+  workspaceID: optionalOmitUndefined(WorkspaceID),
   directory: Schema.String,
-  parentID: Schema.optional(SessionID),
-  summary: Schema.optional(Summary),
-  share: Schema.optional(Share),
+  parentID: optionalOmitUndefined(SessionID),
+  summary: optionalOmitUndefined(Summary),
+  share: optionalOmitUndefined(Share),
   title: Schema.String,
   version: Schema.String,
   time: Time,
-  permission: Schema.optional(Permission.Ruleset),
-  revert: Schema.optional(Revert),
+  permission: optionalOmitUndefined(Permission.Ruleset),
+  revert: optionalOmitUndefined(Revert),
 })
   .annotate({ identifier: "Session" })
   .pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -170,7 +170,7 @@ export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
 
 export const ProjectInfo = Schema.Struct({
   id: ProjectID,
-  name: Schema.optional(Schema.String),
+  name: optionalOmitUndefined(Schema.String),
   worktree: Schema.String,
 })
   .annotate({ identifier: "ProjectSummary" })

+ 15 - 1
packages/opencode/src/util/schema.ts

@@ -1,4 +1,5 @@
-import { Schema } from "effect"
+import { Option, Schema, SchemaGetter } from "effect"
+import { zod, ZodOverride } from "./effect-zod"
 
 /**
  * Integer greater than zero.
@@ -10,6 +11,19 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
  */
 export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
 
+/**
+ * Optional public JSON field that accepts explicit `undefined` internally but
+ * encodes it as an omitted key, matching `JSON.stringify` legacy responses.
+ */
+export const optionalOmitUndefined = <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)),
+    }),
+    Schema.annotate({ [ZodOverride]: zod(schema).optional() }),
+  )
+
 /**
  * Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable`
  * until `effect:core/x228my` ("Types.DeepMutable widens unknown to `{}`") lands.

+ 53 - 0
packages/opencode/test/session/session-schema.test.ts

@@ -0,0 +1,53 @@
+import { describe, expect, test } from "bun:test"
+import { Schema } from "effect"
+import { ProjectID } from "../../src/project/schema"
+import { SessionID } from "../../src/session/schema"
+import { Session } from "../../src/session/session"
+
+const info = {
+  id: SessionID.descending(),
+  slug: "test-session",
+  projectID: ProjectID.global,
+  workspaceID: undefined,
+  directory: "/tmp/opencode",
+  parentID: undefined,
+  summary: undefined,
+  share: undefined,
+  title: "Test session",
+  version: "1.0.0",
+  time: {
+    created: 1,
+    updated: 2,
+    compacting: undefined,
+    archived: undefined,
+  },
+  permission: undefined,
+  revert: undefined,
+} satisfies Session.Info
+
+describe("Session schema", () => {
+  test("encodes undefined optional session fields as omitted keys", () => {
+    const encoded = Schema.encodeUnknownSync(Session.Info)(info) as Record<string, unknown>
+
+    for (const key of ["workspaceID", "parentID", "summary", "share", "permission", "revert"]) {
+      expect(Object.hasOwn(encoded, key)).toBe(false)
+    }
+    expect(Object.hasOwn(encoded.time as Record<string, unknown>, "compacting")).toBe(false)
+    expect(Object.hasOwn(encoded.time as Record<string, unknown>, "archived")).toBe(false)
+    expect(JSON.stringify(encoded)).not.toContain("parentID")
+  })
+
+  test("encodes undefined optional global session project fields as omitted keys", () => {
+    const encoded = Schema.encodeUnknownSync(Session.GlobalInfo)({
+      ...info,
+      project: {
+        id: ProjectID.global,
+        name: undefined,
+        worktree: "/tmp/opencode",
+      },
+    }) as Record<string, unknown>
+
+    expect(Object.hasOwn(encoded, "parentID")).toBe(false)
+    expect(Object.hasOwn(encoded.project as Record<string, unknown>, "name")).toBe(false)
+  })
+})