Skip to content

Commit a0a09f4

Browse files
committed
core: add session diff API to show file changes between snapshots
1 parent f3f2119 commit a0a09f4

File tree

6 files changed

+317
-46
lines changed

6 files changed

+317
-46
lines changed

packages/opencode/src/server/server.ts

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { Log } from "../util/log"
22
import { Bus } from "../bus"
3-
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
3+
import {
4+
describeRoute,
5+
generateSpecs,
6+
validator,
7+
resolver,
8+
openAPIRouteHandler,
9+
} from "hono-openapi"
410
import { Hono } from "hono"
511
import { cors } from "hono/cors"
612
import { streamSSE } from "hono/streaming"
@@ -35,6 +41,7 @@ import { InstanceBootstrap } from "../project/bootstrap"
3541
import { MCP } from "../mcp"
3642
import { Storage } from "../storage/storage"
3743
import type { ContentfulStatusCode } from "hono/utils/http-status"
44+
import { Snapshot } from "@/snapshot"
3845

3946
const ERRORS = {
4047
400: {
@@ -66,7 +73,9 @@ const ERRORS = {
6673
} as const
6774

6875
function errors(...codes: number[]) {
69-
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
76+
return Object.fromEntries(
77+
codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]),
78+
)
7079
}
7180

7281
export namespace Server {
@@ -90,7 +99,8 @@ export namespace Server {
9099
else status = 500
91100
return c.json(err.toObject(), { status })
92101
}
93-
const message = err instanceof Error && err.stack ? err.stack : err.toString()
102+
const message =
103+
err instanceof Error && err.stack ? err.stack : err.toString()
94104
return c.json(new NamedError.Unknown({ message }).toObject(), {
95105
status: 500,
96106
})
@@ -184,14 +194,17 @@ export namespace Server {
184194
.get(
185195
"/experimental/tool/ids",
186196
describeRoute({
187-
description: "List all tool IDs (including built-in and dynamically registered)",
197+
description:
198+
"List all tool IDs (including built-in and dynamically registered)",
188199
operationId: "tool.ids",
189200
responses: {
190201
200: {
191202
description: "Tool IDs",
192203
content: {
193204
"application/json": {
194-
schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
205+
schema: resolver(
206+
z.array(z.string()).meta({ ref: "ToolIDs" }),
207+
),
195208
},
196209
},
197210
},
@@ -205,7 +218,8 @@ export namespace Server {
205218
.get(
206219
"/experimental/tool",
207220
describeRoute({
208-
description: "List tools with JSON schema parameters for a provider/model",
221+
description:
222+
"List tools with JSON schema parameters for a provider/model",
209223
operationId: "tool.list",
210224
responses: {
211225
200: {
@@ -246,7 +260,9 @@ export namespace Server {
246260
id: t.id,
247261
description: t.description,
248262
// Handle both Zod schemas and plain JSON schemas
249-
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
263+
parameters: (t.parameters as any)?._def
264+
? zodToJsonSchema(t.parameters as any)
265+
: t.parameters,
250266
})),
251267
)
252268
},
@@ -608,6 +624,44 @@ export namespace Server {
608624
return c.json(session)
609625
},
610626
)
627+
.get(
628+
"/session/:id/diff",
629+
describeRoute({
630+
description: "Get the diff that resulted from this user message",
631+
operationId: "session.diff",
632+
responses: {
633+
200: {
634+
description: "Successfully retrieved diff",
635+
content: {
636+
"application/json": {
637+
schema: resolver(Snapshot.FileDiff.array()),
638+
},
639+
},
640+
},
641+
},
642+
}),
643+
validator(
644+
"param",
645+
z.object({
646+
id: Session.diff.schema.shape.sessionID,
647+
}),
648+
),
649+
validator(
650+
"query",
651+
z.object({
652+
messageID: Session.diff.schema.shape.messageID,
653+
}),
654+
),
655+
async (c) => {
656+
const query = c.req.valid("query")
657+
const params = c.req.valid("param")
658+
const result = await Session.diff({
659+
sessionID: params.id,
660+
messageID: query.messageID,
661+
})
662+
return c.json(result)
663+
},
664+
)
611665
.delete(
612666
"/session/:id/share",
613667
describeRoute({
@@ -734,7 +788,10 @@ export namespace Server {
734788
),
735789
async (c) => {
736790
const params = c.req.valid("param")
737-
const message = await Session.getMessage({ sessionID: params.id, messageID: params.messageID })
791+
const message = await Session.getMessage({
792+
sessionID: params.id,
793+
messageID: params.messageID,
794+
})
738795
return c.json(message)
739796
},
740797
)
@@ -868,7 +925,10 @@ export namespace Server {
868925
async (c) => {
869926
const id = c.req.valid("param").id
870927
log.info("revert", c.req.valid("json"))
871-
const session = await SessionRevert.revert({ sessionID: id, ...c.req.valid("json") })
928+
const session = await SessionRevert.revert({
929+
sessionID: id,
930+
...c.req.valid("json"),
931+
})
872932
return c.json(session)
873933
},
874934
)
@@ -929,7 +989,11 @@ export namespace Server {
929989
const params = c.req.valid("param")
930990
const id = params.id
931991
const permissionID = params.permissionID
932-
Permission.respond({ sessionID: id, permissionID, response: c.req.valid("json").response })
992+
Permission.respond({
993+
sessionID: id,
994+
permissionID,
995+
response: c.req.valid("json").response,
996+
})
933997
return c.json(true)
934998
},
935999
)
@@ -976,10 +1040,15 @@ export namespace Server {
9761040
},
9771041
}),
9781042
async (c) => {
979-
const providers = await Provider.list().then((x) => mapValues(x, (item) => item.info))
1043+
const providers = await Provider.list().then((x) =>
1044+
mapValues(x, (item) => item.info),
1045+
)
9801046
return c.json({
9811047
providers: Object.values(providers),
982-
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
1048+
default: mapValues(
1049+
providers,
1050+
(item) => Provider.sort(Object.values(item.models))[0].id,
1051+
),
9831052
})
9841053
},
9851054
)
@@ -1174,8 +1243,12 @@ export namespace Server {
11741243
validator(
11751244
"json",
11761245
z.object({
1177-
service: z.string().meta({ description: "Service name for the log entry" }),
1178-
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
1246+
service: z
1247+
.string()
1248+
.meta({ description: "Service name for the log entry" }),
1249+
level: z
1250+
.enum(["debug", "info", "error", "warn"])
1251+
.meta({ description: "Log level" }),
11791252
message: z.string().meta({ description: "Log message" }),
11801253
extra: z
11811254
.record(z.string(), z.any())

packages/opencode/src/session/index.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Project } from "../project/project"
1818
import { Instance } from "../project/instance"
1919
import { SessionPrompt } from "./prompt"
2020
import { fn } from "@/util/fn"
21+
import { Snapshot } from "@/snapshot"
2122

2223
export namespace Session {
2324
const log = Log.create({ service: "session" })
@@ -146,7 +147,12 @@ export namespace Session {
146147
})
147148
})
148149

149-
export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) {
150+
export async function createNext(input: {
151+
id?: string
152+
title?: string
153+
parentID?: string
154+
directory: string
155+
}) {
150156
const result: Info = {
151157
id: Identifier.descending("session", input.id),
152158
version: Installation.VERSION,
@@ -366,7 +372,9 @@ export namespace Session {
366372
.add(new Decimal(tokens.input).mul(input.model.cost?.input ?? 0).div(1_000_000))
367373
.add(new Decimal(tokens.output).mul(input.model.cost?.output ?? 0).div(1_000_000))
368374
.add(new Decimal(tokens.cache.read).mul(input.model.cost?.cache_read ?? 0).div(1_000_000))
369-
.add(new Decimal(tokens.cache.write).mul(input.model.cost?.cache_write ?? 0).div(1_000_000))
375+
.add(
376+
new Decimal(tokens.cache.write).mul(input.model.cost?.cache_write ?? 0).div(1_000_000),
377+
)
370378
.toNumber(),
371379
tokens,
372380
}
@@ -405,4 +413,47 @@ export namespace Session {
405413
await Project.setInitialized(Instance.project.id)
406414
},
407415
)
416+
417+
export const diff = fn(
418+
z.object({
419+
sessionID: Identifier.schema("session"),
420+
messageID: Identifier.schema("message").optional(),
421+
}),
422+
async (input) => {
423+
const all = await messages(input.sessionID)
424+
const index = !input.messageID ? 0 : all.findIndex((x) => x.info.id === input.messageID)
425+
if (index === -1) return []
426+
427+
let from: string | undefined
428+
let to: string | undefined
429+
430+
// scan assistant messages to find earliest from and latest to
431+
// snapshot
432+
for (let i = index + 1; i < all.length; i++) {
433+
const item = all[i]
434+
435+
// if messageID is provided, stop at the next user message
436+
if (input.messageID && item.info.role === "user") break
437+
438+
if (!from) {
439+
for (const part of item.parts) {
440+
if (part.type === "step-start" && part.snapshot) {
441+
from = part.snapshot
442+
break
443+
}
444+
}
445+
}
446+
447+
for (const part of item.parts) {
448+
if (part.type === "step-finish" && part.snapshot) {
449+
to = part.snapshot
450+
break
451+
}
452+
}
453+
}
454+
455+
if (from && to) return Snapshot.diffFull(from, to)
456+
return []
457+
},
458+
)
408459
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,15 @@ export namespace MessageV2 {
130130

131131
export const StepStartPart = PartBase.extend({
132132
type: z.literal("step-start"),
133+
snapshot: z.string().optional(),
133134
}).meta({
134135
ref: "StepStartPart",
135136
})
136137
export type StepStartPart = z.infer<typeof StepStartPart>
137138

138139
export const StepFinishPart = PartBase.extend({
139140
type: z.literal("step-finish"),
141+
snapshot: z.string().optional(),
140142
cost: z.number(),
141143
tokens: z.object({
142144
input: z.number(),

packages/opencode/src/session/prompt.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1195,13 +1195,14 @@ export namespace SessionPrompt {
11951195
throw value.error
11961196

11971197
case "start-step":
1198+
snapshot = await Snapshot.track()
11981199
await Session.updatePart({
11991200
id: Identifier.ascending("part"),
12001201
messageID: assistantMsg.id,
12011202
sessionID: assistantMsg.sessionID,
1203+
snapshot,
12021204
type: "step-start",
12031205
})
1204-
snapshot = await Snapshot.track()
12051206
break
12061207

12071208
case "finish-step":
@@ -1214,6 +1215,7 @@ export namespace SessionPrompt {
12141215
assistantMsg.tokens = usage.tokens
12151216
await Session.updatePart({
12161217
id: Identifier.ascending("part"),
1218+
snapshot: await Snapshot.track(),
12171219
messageID: assistantMsg.id,
12181220
sessionID: assistantMsg.sessionID,
12191221
type: "step-finish",

0 commit comments

Comments
 (0)