negative-tokens-regression.test.ts 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. // Regression: a stored step-finish part with a negative token count made the
  2. // messages endpoint 400. Some providers reported `outputTokens` excluding
  3. // reasoning while also reporting `reasoningTokens` separately, so the
  4. // `outputTokens - reasoningTokens` math in Session.getUsage underflowed to
  5. // negative. The pre-fix `safe()` clamp only guarded against non-finite. The
  6. // strict `NonNegativeInt` schema then made every load of the message list
  7. // fail to encode, killing Desktop boot for every user with such a row.
  8. import { describe, expect } from "bun:test"
  9. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  10. import { Effect, Layer } from "effect"
  11. import { eq } from "drizzle-orm"
  12. import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
  13. import { Session } from "@/session/session"
  14. import { MessageID, PartID } from "../../src/session/schema"
  15. import { Database } from "@kirincode-ai/core/database/database"
  16. import { PartTable } from "@kirincode-ai/core/session/sql"
  17. import { resetDatabase } from "../fixture/db"
  18. import { TestInstance } from "../fixture/fixture"
  19. import { testEffect } from "../lib/effect"
  20. import { ProviderV2 } from "@kirincode-ai/core/provider"
  21. import { ModelV2 } from "@kirincode-ai/core/model"
  22. import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
  23. const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer))
  24. function seedNegativeTokenSession() {
  25. return Effect.gen(function* () {
  26. const session = yield* Session.Service
  27. const info = yield* session.create({})
  28. const message = yield* session.updateMessage({
  29. id: MessageID.ascending(),
  30. role: "user",
  31. sessionID: info.id,
  32. agent: "build",
  33. model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
  34. time: { created: Date.now() },
  35. })
  36. const partID = PartID.ascending()
  37. yield* session.updatePart({
  38. id: partID,
  39. sessionID: info.id,
  40. messageID: message.id,
  41. type: "step-finish",
  42. reason: "stop",
  43. cost: 0,
  44. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  45. })
  46. // Bypass the schema with a direct SQL update to install the
  47. // negative `output` value we want to test loading.
  48. const { db } = yield* Database.Service
  49. yield* db
  50. .update(PartTable)
  51. .set({
  52. data: {
  53. type: "step-finish",
  54. reason: "stop",
  55. cost: 0,
  56. tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
  57. } as never,
  58. })
  59. .where(eq(PartTable.id, partID))
  60. .run()
  61. .pipe(Effect.orDie)
  62. return info.id
  63. })
  64. }
  65. describe("messages endpoint tolerates legacy negative token counts", () => {
  66. it.instance(
  67. "returns 200 even when a step-finish part has tokens.output < 0",
  68. Effect.gen(function* () {
  69. yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
  70. const test = yield* TestInstance
  71. const sessionID = yield* seedNegativeTokenSession()
  72. const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
  73. const res = yield* requestInDirectory(url, test.directory)
  74. expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
  75. }),
  76. { git: true, config: { formatter: false, lsp: false } },
  77. )
  78. })