|
@@ -0,0 +1,97 @@
|
|
|
|
|
+// Regression: a stored step-finish part with a negative token count made the
|
|
|
|
|
+// messages endpoint 400. Some providers reported `outputTokens` excluding
|
|
|
|
|
+// reasoning while also reporting `reasoningTokens` separately, so the
|
|
|
|
|
+// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to
|
|
|
|
|
+// negative. The pre-fix `safe()` clamp only guarded against non-finite. The
|
|
|
|
|
+// strict `NonNegativeInt` schema then made every load of the message list
|
|
|
|
|
+// fail to encode, killing Desktop boot for every user with such a row.
|
|
|
|
|
+import { afterEach, describe, expect } from "bun:test"
|
|
|
|
|
+import { Effect } from "effect"
|
|
|
|
|
+import { eq } from "drizzle-orm"
|
|
|
|
|
+import { ModelID, ProviderID } from "../../src/provider/schema"
|
|
|
|
|
+import { WithInstance } from "../../src/project/with-instance"
|
|
|
|
|
+import { Server } from "../../src/server/server"
|
|
|
|
|
+import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
|
|
|
|
+import { Session } from "@/session/session"
|
|
|
|
|
+import { MessageID, PartID } from "../../src/session/schema"
|
|
|
|
|
+import * as Database from "@/storage/db"
|
|
|
|
|
+import { PartTable } from "@/session/session.sql"
|
|
|
|
|
+import { resetDatabase } from "../fixture/db"
|
|
|
|
|
+import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
|
|
|
|
+import { it } from "../lib/effect"
|
|
|
|
|
+
|
|
|
|
|
+afterEach(async () => {
|
|
|
|
|
+ await disposeAllInstances()
|
|
|
|
|
+ await resetDatabase()
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+function seedNegativeTokenSession(directory: string) {
|
|
|
|
|
+ return Effect.promise(async () =>
|
|
|
|
|
+ WithInstance.provide({
|
|
|
|
|
+ directory,
|
|
|
|
|
+ fn: () =>
|
|
|
|
|
+ Effect.runPromise(
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const session = yield* Session.Service
|
|
|
|
|
+ const info = yield* session.create({})
|
|
|
|
|
+ const message = yield* session.updateMessage({
|
|
|
|
|
+ id: MessageID.ascending(),
|
|
|
|
|
+ role: "user",
|
|
|
|
|
+ sessionID: info.id,
|
|
|
|
|
+ agent: "build",
|
|
|
|
|
+ model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
|
|
|
|
+ time: { created: Date.now() },
|
|
|
|
|
+ })
|
|
|
|
|
+ const partID = PartID.ascending()
|
|
|
|
|
+ yield* session.updatePart({
|
|
|
|
|
+ id: partID,
|
|
|
|
|
+ sessionID: info.id,
|
|
|
|
|
+ messageID: message.id,
|
|
|
|
|
+ type: "step-finish",
|
|
|
|
|
+ reason: "stop",
|
|
|
|
|
+ cost: 0,
|
|
|
|
|
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Bypass the schema with a direct SQL update to install the
|
|
|
|
|
+ // negative `output` value we want to test loading.
|
|
|
|
|
+ Database.use((db) =>
|
|
|
|
|
+ db
|
|
|
|
|
+ .update(PartTable)
|
|
|
|
|
+ .set({
|
|
|
|
|
+ data: {
|
|
|
|
|
+ type: "step-finish",
|
|
|
|
|
+ reason: "stop",
|
|
|
|
|
+ cost: 0,
|
|
|
|
|
+ tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
|
|
|
+ } as never,
|
|
|
|
|
+ })
|
|
|
|
|
+ .where(eq(PartTable.id, partID))
|
|
|
|
|
+ .run(),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return info.id
|
|
|
|
|
+ }).pipe(Effect.provide(Session.defaultLayer)),
|
|
|
|
|
+ ),
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+describe("messages endpoint tolerates legacy negative token counts", () => {
|
|
|
|
|
+ it.live(
|
|
|
|
|
+ "returns 200 even when a step-finish part has tokens.output < 0",
|
|
|
|
|
+ Effect.acquireRelease(
|
|
|
|
|
+ Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })),
|
|
|
|
|
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
|
|
|
+ ).pipe(
|
|
|
|
|
+ Effect.flatMap((tmp) =>
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const sessionID = yield* seedNegativeTokenSession(tmp.path)
|
|
|
|
|
+ const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
|
|
|
|
|
+ const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
|
|
|
|
+ expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
|
|
|
|
|
+ }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+})
|