Skip to content

Commit c6e6bdf

Browse files
authored
fix(session): tolerate negative token counts in stored parts (#26620)
1 parent d80e119 commit c6e6bdf

5 files changed

Lines changed: 120 additions & 23 deletions

File tree

packages/opencode/src/session/message-v2.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -270,13 +270,13 @@ export const StepFinishPart = Schema.Struct({
270270
snapshot: Schema.optional(Schema.String),
271271
cost: Schema.Finite,
272272
tokens: Schema.Struct({
273-
total: Schema.optional(NonNegativeInt),
274-
input: NonNegativeInt,
275-
output: NonNegativeInt,
276-
reasoning: NonNegativeInt,
273+
total: Schema.optional(Schema.Finite),
274+
input: Schema.Finite,
275+
output: Schema.Finite,
276+
reasoning: Schema.Finite,
277277
cache: Schema.Struct({
278-
read: NonNegativeInt,
279-
write: NonNegativeInt,
278+
read: Schema.Finite,
279+
write: Schema.Finite,
280280
}),
281281
}),
282282
})
@@ -554,13 +554,13 @@ export const Assistant = Schema.Struct({
554554
summary: Schema.optional(Schema.Boolean),
555555
cost: Schema.Finite,
556556
tokens: Schema.Struct({
557-
total: Schema.optional(NonNegativeInt),
558-
input: NonNegativeInt,
559-
output: NonNegativeInt,
560-
reasoning: NonNegativeInt,
557+
total: Schema.optional(Schema.Finite),
558+
input: Schema.Finite,
559+
output: Schema.Finite,
560+
reasoning: Schema.Finite,
561561
cache: Schema.Struct({
562-
read: NonNegativeInt,
563-
write: NonNegativeInt,
562+
read: Schema.Finite,
563+
write: Schema.Finite,
564564
}),
565565
}),
566566
structured: Schema.optional(Schema.Any),

packages/opencode/src/session/message.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,12 @@ export const Info = Schema.Struct({
172172
cost: Schema.Finite,
173173
summary: Schema.optional(Schema.Boolean),
174174
tokens: Schema.Struct({
175-
input: NonNegativeInt,
176-
output: NonNegativeInt,
177-
reasoning: NonNegativeInt,
175+
input: Schema.Finite,
176+
output: Schema.Finite,
177+
reasoning: Schema.Finite,
178178
cache: Schema.Struct({
179-
read: NonNegativeInt,
180-
write: NonNegativeInt,
179+
read: Schema.Finite,
180+
write: Schema.Finite,
181181
}),
182182
}),
183183
}),

packages/opencode/src/session/session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
353353
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => {
354354
const safe = (value: number) => {
355355
if (!Number.isFinite(value)) return 0
356-
return value
356+
return Math.max(0, value)
357357
}
358358
const inputTokens = safe(input.usage.inputTokens ?? 0)
359359
const outputTokens = safe(input.usage.outputTokens ?? 0)

packages/opencode/src/v2/session-event.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,12 @@ export namespace Step {
118118
finish: Schema.String,
119119
cost: Schema.Finite,
120120
tokens: Schema.Struct({
121-
input: NonNegativeInt,
122-
output: NonNegativeInt,
123-
reasoning: NonNegativeInt,
121+
input: Schema.Finite,
122+
output: Schema.Finite,
123+
reasoning: Schema.Finite,
124124
cache: Schema.Struct({
125-
read: NonNegativeInt,
126-
write: NonNegativeInt,
125+
read: Schema.Finite,
126+
write: Schema.Finite,
127127
}),
128128
}),
129129
snapshot: Schema.String.pipe(Schema.optional),
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 { afterEach, describe, expect } from "bun:test"
9+
import { Effect } from "effect"
10+
import { eq } from "drizzle-orm"
11+
import { ModelID, ProviderID } from "../../src/provider/schema"
12+
import { WithInstance } from "../../src/project/with-instance"
13+
import { Server } from "../../src/server/server"
14+
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
15+
import { Session } from "@/session/session"
16+
import { MessageID, PartID } from "../../src/session/schema"
17+
import * as Database from "@/storage/db"
18+
import { PartTable } from "@/session/session.sql"
19+
import { resetDatabase } from "../fixture/db"
20+
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
21+
import { it } from "../lib/effect"
22+
23+
afterEach(async () => {
24+
await disposeAllInstances()
25+
await resetDatabase()
26+
})
27+
28+
function seedNegativeTokenSession(directory: string) {
29+
return Effect.promise(async () =>
30+
WithInstance.provide({
31+
directory,
32+
fn: () =>
33+
Effect.runPromise(
34+
Effect.gen(function* () {
35+
const session = yield* Session.Service
36+
const info = yield* session.create({})
37+
const message = yield* session.updateMessage({
38+
id: MessageID.ascending(),
39+
role: "user",
40+
sessionID: info.id,
41+
agent: "build",
42+
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
43+
time: { created: Date.now() },
44+
})
45+
const partID = PartID.ascending()
46+
yield* session.updatePart({
47+
id: partID,
48+
sessionID: info.id,
49+
messageID: message.id,
50+
type: "step-finish",
51+
reason: "stop",
52+
cost: 0,
53+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
54+
})
55+
56+
// Bypass the schema with a direct SQL update to install the
57+
// negative `output` value we want to test loading.
58+
Database.use((db) =>
59+
db
60+
.update(PartTable)
61+
.set({
62+
data: {
63+
type: "step-finish",
64+
reason: "stop",
65+
cost: 0,
66+
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
67+
} as never,
68+
})
69+
.where(eq(PartTable.id, partID))
70+
.run(),
71+
)
72+
73+
return info.id
74+
}).pipe(Effect.provide(Session.defaultLayer)),
75+
),
76+
}),
77+
)
78+
}
79+
80+
describe("messages endpoint tolerates legacy negative token counts", () => {
81+
it.live(
82+
"returns 200 even when a step-finish part has tokens.output < 0",
83+
Effect.acquireRelease(
84+
Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })),
85+
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
86+
).pipe(
87+
Effect.flatMap((tmp) =>
88+
Effect.gen(function* () {
89+
const sessionID = yield* seedNegativeTokenSession(tmp.path)
90+
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
91+
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
92+
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
93+
}),
94+
),
95+
),
96+
)
97+
})

0 commit comments

Comments
 (0)