Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton c3111fe15f fix(tui): undo latest pending prompt 2026-08-08 21:18:27 -04:00
Kit Langton 84fd347afa fix(codegen): write prettier-stable generated manifests (#41343) 2026-08-08 20:52:04 -04:00
opencode-agent[bot] e8f215bfbc chore: generate 2026-08-09 00:29:22 +00:00
opencode-agent[bot] 445af9ce70 docs: fix install command rendering (#41340)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-08-08 20:28:07 -04:00
5 changed files with 85 additions and 9 deletions
+9 -1
View File
@@ -1316,7 +1316,15 @@ export function write(
}).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))),
{ concurrency: 8, discard: true },
)
yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n")
// Format the manifest with the same prettier settings as the repo-wide
// format pass, so `check:generated` stays clean after the generate bot
// reformats the tree.
const manifestJson = JSON.stringify(output.files.map((file) => file.path).sort())
const manifestContent = yield* Effect.tryPromise({
try: () => format(manifestJson, { filepath: manifest, parser: "json", printWidth: 120 }),
catch: (error) => new GenerationError({ reason: `Failed to format ${manifest}: ${String(error)}` }),
})
yield* fs.writeFileString(manifest, manifestContent)
})
}
+1 -1
View File
@@ -16,7 +16,7 @@ describe("HttpApiCodegen.write", () => {
expect(writes).toEqual([
{ path: "/generated/session.ts", content: "export const session = {}\n" },
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
{ path: "/generated/.httpapi-codegen.json", content: '["session.ts"]\n' },
])
}).pipe(
Effect.provideService(
+13 -7
View File
@@ -84,6 +84,7 @@ import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { PluginSlot } from "../../plugin/render"
import { usePlugin } from "../../plugin/context"
import { undoMessage } from "./undo"
import {
cacheReuseDrop,
createSessionRows,
@@ -656,19 +657,24 @@ export function Session() {
group: "Session",
slash: { name: "undo" },
run: () => {
const admitted = pendingUsers().at(-1)
const boundary = session()?.revert?.messageID
const message = messages().findLast(
(message): message is SessionMessageUser =>
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
)
const message = admitted
? { id: admitted.id, ...admitted.data }
: messages().findLast(
(message): message is SessionMessageUser =>
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
)
if (!message) {
toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 })
dialog.clear()
return
}
void client.api.session.revert
.stage({ sessionID: route.sessionID, messageID: message.id })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
void undoMessage(client.api, {
sessionID: route.sessionID,
messageID: message.id,
pending: admitted !== undefined,
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
prompt()?.set({
...projectedPromptInput(message),
pasted: [],
+14
View File
@@ -0,0 +1,14 @@
import type { OpenCodeClient } from "@opencode-ai/client"
export async function undoMessage(
client: OpenCodeClient,
input: { readonly sessionID: string; readonly messageID: string; readonly pending: boolean },
) {
const revert = () => client.session.revert.stage(input).then(() => undefined)
if (!input.pending) return revert()
return client.session.pending.cancel({ sessionID: input.sessionID, inputID: input.messageID }).catch((error) => {
if (typeof error !== "object" || error === null || !("_tag" in error) || error._tag !== "ConflictError") throw error
return revert()
})
}
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client"
import { undoMessage } from "../../../src/routes/session/undo"
test.each([
{ name: "projected", pending: false, cancelStatus: 204, expected: ["revert"] },
{ name: "pending", pending: true, cancelStatus: 204, expected: ["cancel"] },
{ name: "promoted race", pending: true, cancelStatus: 409, expected: ["cancel", "revert"] },
])("undo routes $name messages", async ({ pending, cancelStatus, expected }) => {
const calls: string[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
const operation = request.method === "DELETE" ? "cancel" : "revert"
calls.push(operation)
if (operation === "cancel") {
if (cancelStatus === 409)
return Response.json({ _tag: "ConflictError", message: "Input was promoted" }, { status: 409 })
return new Response(null, { status: 204 })
}
return Response.json({ data: { messageID: "msg_user" } })
},
{ preconnect: fetch.preconnect },
),
})
await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending })
expect(calls).toEqual([...expected])
})
test("undo does not reinterpret transport failures as promotion races", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async () => {
throw new Error("offline")
},
{ preconnect: fetch.preconnect },
),
})
await expect(
undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending: true }),
).rejects.toMatchObject({ reason: "Transport" })
})