mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 01:43:27 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a841e828ce |
@@ -53,7 +53,6 @@ import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { createPromptSubmission } from "../../prompt/submission"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -253,7 +252,6 @@ export function Prompt(props: PromptProps) {
|
||||
const [cursorVersion, setCursorVersion] = createSignal(0)
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const connected = useConnected()
|
||||
const promptSubmission = createPromptSubmission()
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
function promptModelWarning() {
|
||||
@@ -329,10 +327,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (!session) return
|
||||
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
||||
if (agent && !args.agent) local.agent.set(agent.id)
|
||||
if (session.model) {
|
||||
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
|
||||
local.model.variant.set(session.model.variant)
|
||||
}
|
||||
if (!local.model.hydrate(session.model)) return
|
||||
syncedSessionID = sessionID
|
||||
})
|
||||
|
||||
@@ -954,72 +949,27 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const variant = local.model.variant.current()
|
||||
const currentMode = store.mode
|
||||
const prompt = {
|
||||
text: store.prompt.text,
|
||||
files: store.prompt.files?.map((file) => ({
|
||||
...file,
|
||||
mention: file.mention && { ...file.mention },
|
||||
})),
|
||||
agents: store.prompt.agents?.map((agent) => ({
|
||||
...agent,
|
||||
mention: agent.mention && { ...agent.mention },
|
||||
})),
|
||||
pasted: store.prompt.pasted.map((part) => ({
|
||||
...part,
|
||||
source: { ...part.source },
|
||||
})),
|
||||
} satisfies PromptInfo
|
||||
const inputText = expandTrackedPastedText(
|
||||
prompt.text,
|
||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||
const ref = store.extmarkToPart.get(extmark.id)
|
||||
if (ref?.type !== "pasted") return []
|
||||
const part = prompt.pasted[ref.index]
|
||||
if (!part) return []
|
||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||
}),
|
||||
)
|
||||
const directory = props.sessionID == null ? await move.getDirectory() : undefined
|
||||
if (props.sessionID == null && move.pending() && !directory) return false
|
||||
const sessionInput = {
|
||||
location: directory ? { directory } : (currentLocation.ref ?? data.location.default()),
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
id: selectedModel.modelID,
|
||||
variant,
|
||||
},
|
||||
}
|
||||
const promptInput = {
|
||||
text: inputText,
|
||||
files: prompt.files,
|
||||
agents: prompt.agents,
|
||||
}
|
||||
// Keep both IDs stable until the whole create/admit sequence succeeds. If the
|
||||
// transport drops after either durable write, retry reconciles that write.
|
||||
const sessionID = await promptSubmission.begin(
|
||||
Bun.hash(
|
||||
JSON.stringify({
|
||||
sessionID: props.sessionID,
|
||||
session: sessionInput,
|
||||
prompt: promptInput,
|
||||
mode: currentMode,
|
||||
}),
|
||||
),
|
||||
props.sessionID,
|
||||
)
|
||||
let sessionID = props.sessionID
|
||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||
let finishMoveProgress = false
|
||||
if (props.sessionID == null) {
|
||||
if (sessionID == null) {
|
||||
const directory = await move.getDirectory()
|
||||
if (move.pending() && !directory) return false
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
// The location context is where the next session is created: seeded by the home
|
||||
// route (launch cwd, inherited session location, or picked project) and updated
|
||||
// by /cd before a session exists.
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
|
||||
const created = await client.api.session
|
||||
.create({
|
||||
id: sessionID,
|
||||
...sessionInput,
|
||||
location: directory ? { directory } : location,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
id: selectedModel.modelID,
|
||||
variant,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
@@ -1033,13 +983,27 @@ export function Prompt(props: PromptProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
sessionID = created.id
|
||||
session = created
|
||||
}
|
||||
|
||||
const inputText = expandTrackedPastedText(
|
||||
store.prompt.text,
|
||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||
const ref = store.extmarkToPart.get(extmark.id)
|
||||
if (ref?.type !== "pasted") return []
|
||||
const part = store.prompt.pasted[ref.index]
|
||||
if (!part) return []
|
||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||
}),
|
||||
)
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
const editorSelection = editorContext()
|
||||
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
|
||||
|
||||
if (currentMode === "shell") {
|
||||
if (store.mode === "shell") {
|
||||
move.startSubmit()
|
||||
void client.api.session.shell({
|
||||
sessionID,
|
||||
@@ -1066,9 +1030,9 @@ export function Prompt(props: PromptProps) {
|
||||
command: command.slice(1),
|
||||
arguments: args,
|
||||
agent: agent.id,
|
||||
model: sessionInput.model,
|
||||
files: promptInput.files,
|
||||
agents: promptInput.agents,
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
@@ -1100,7 +1064,7 @@ export function Prompt(props: PromptProps) {
|
||||
) {
|
||||
await client.api.session.switchModel({
|
||||
sessionID,
|
||||
model: sessionInput.model,
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
})
|
||||
}
|
||||
if (session?.revert) {
|
||||
@@ -1133,8 +1097,9 @@ export function Prompt(props: PromptProps) {
|
||||
const error = await client.api.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
id: await promptSubmission.message(),
|
||||
...promptInput,
|
||||
text: inputText,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
})
|
||||
.then(
|
||||
() => undefined,
|
||||
@@ -1146,9 +1111,8 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
if (pendingEditorSelection) editor.markSelectionSent()
|
||||
}
|
||||
promptSubmission.complete()
|
||||
history.append({
|
||||
...prompt,
|
||||
...store.prompt,
|
||||
mode: currentMode,
|
||||
})
|
||||
input.extmarks.clear()
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useEvent } from "./event"
|
||||
import path from "path"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import { useArgs } from "./args"
|
||||
import { useClient } from "./client"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import {
|
||||
@@ -48,7 +47,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
@@ -210,6 +208,47 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
)
|
||||
})
|
||||
|
||||
function select(model: ModelPreferenceModel, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
if (!isModelValid(model)) return
|
||||
const a = agent.current()
|
||||
if (!a) return
|
||||
setModelStore("model", a.id, model)
|
||||
if (!options?.recent) return
|
||||
setModelStore("recent", recentModels(model, modelStore.recent))
|
||||
save()
|
||||
})
|
||||
}
|
||||
|
||||
function selectVariant(value: string | undefined) {
|
||||
const model = currentModel()
|
||||
if (!model) return
|
||||
const key = modelPreferenceKey(model)
|
||||
const variant = normalizeModelVariant(value)
|
||||
if (modelStore.variant[key] === variant) return
|
||||
setModelStore("variant", key, variant)
|
||||
save()
|
||||
}
|
||||
|
||||
function matches(model?: { providerID: string; id: string }) {
|
||||
if (!modelStore.ready) return false
|
||||
const current = currentModel()
|
||||
if (!current) return false
|
||||
if (!model) return true
|
||||
return current.providerID === model.providerID && current.modelID === model.id
|
||||
}
|
||||
|
||||
function hydrate(model?: { providerID: string; id: string; variant?: string }) {
|
||||
if (!modelStore.ready) return false
|
||||
if (!model) return true
|
||||
if (data.location.model.list() === undefined) return false
|
||||
const selected = { providerID: model.providerID, modelID: model.id }
|
||||
if (!isModelValid(selected)) return false
|
||||
select(selected)
|
||||
selectVariant(model.variant)
|
||||
return matches(model)
|
||||
}
|
||||
|
||||
return {
|
||||
current: currentModel,
|
||||
get ready() {
|
||||
@@ -221,6 +260,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
favorite() {
|
||||
return modelStore.favorite
|
||||
},
|
||||
hydrate,
|
||||
matches,
|
||||
parsed: createMemo(() => {
|
||||
const value = currentModel()
|
||||
if (!value) {
|
||||
@@ -285,18 +326,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setModelStore("recent", recentModels(next, modelStore.recent))
|
||||
save()
|
||||
},
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
if (!isModelValid(model)) return
|
||||
const a = agent.current()
|
||||
if (!a) return
|
||||
setModelStore("model", a.id, model)
|
||||
if (options?.recent) {
|
||||
setModelStore("recent", recentModels(model, modelStore.recent))
|
||||
save()
|
||||
}
|
||||
})
|
||||
},
|
||||
set: select,
|
||||
toggleFavorite(model: { providerID: string; modelID: string }) {
|
||||
batch(() => {
|
||||
if (!isModelValid(model)) return
|
||||
@@ -333,10 +363,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return info?.variants?.map((variant) => variant.id) ?? []
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = currentModel()
|
||||
if (!m) return
|
||||
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||
save()
|
||||
selectVariant(value)
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
type PromptSubmission = {
|
||||
key: number | bigint
|
||||
sessionID: string
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
export function createPromptSubmission() {
|
||||
let pending: PromptSubmission | undefined
|
||||
|
||||
return {
|
||||
async begin(key: number | bigint, sessionID?: string) {
|
||||
if (pending?.key === key && (sessionID === undefined || pending.sessionID === sessionID)) return pending.sessionID
|
||||
if (sessionID !== undefined) {
|
||||
pending = { key, sessionID }
|
||||
return pending.sessionID
|
||||
}
|
||||
const { SessionID } = await import("@opencode-ai/schema/session-id")
|
||||
pending = {
|
||||
key,
|
||||
sessionID: SessionID.create(),
|
||||
}
|
||||
return pending.sessionID
|
||||
},
|
||||
async message() {
|
||||
if (!pending) throw new Error("Prompt submission has not started")
|
||||
if (pending.messageID) return pending.messageID
|
||||
const { SessionMessage } = await import("@opencode-ai/schema/session-message")
|
||||
pending.messageID = SessionMessage.ID.create()
|
||||
return pending.messageID
|
||||
},
|
||||
complete() {
|
||||
pending = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -360,7 +360,7 @@ export function Session() {
|
||||
createEffect(() => {
|
||||
const current = prompt()
|
||||
if (sent || !current || !synced() || !local.model.ready) return
|
||||
if (!local.agent.current() || !local.model.current()) return
|
||||
if (!local.agent.current() || !local.model.matches(session()?.model)) return
|
||||
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
||||
sent = true
|
||||
current.submit()
|
||||
|
||||
@@ -226,199 +226,110 @@ test("session title generated while an untitled session is loading remains visib
|
||||
}
|
||||
})
|
||||
|
||||
test("session startup prompt is submitted exactly once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const cwd = process.cwd()
|
||||
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
||||
const session = {
|
||||
id: "dummy",
|
||||
title: "Demo session",
|
||||
projectID: "project",
|
||||
location: { directory: cwd },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const bodies: unknown[] = []
|
||||
const promptSubmitted = Promise.withResolvers<void>()
|
||||
const calls = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({
|
||||
location,
|
||||
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
|
||||
})
|
||||
if (url.pathname === "/api/model")
|
||||
return json({
|
||||
location,
|
||||
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
|
||||
})
|
||||
if (url.pathname === "/api/session/dummy/prompt") {
|
||||
bodies.push(await request.json())
|
||||
promptSubmitted.resolve()
|
||||
return json({ data: {} })
|
||||
for (const scenario of [
|
||||
{ name: "session startup prompt is submitted exactly once", delayed: false },
|
||||
{ name: "session model hydration retries after the model catalog loads", delayed: true },
|
||||
]) {
|
||||
test(scenario.name, async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const cwd = process.cwd()
|
||||
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
||||
const selected = scenario.delayed ? "selected" : "model"
|
||||
const session = {
|
||||
id: "dummy",
|
||||
title: "Demo session",
|
||||
projectID: "project",
|
||||
location: { directory: cwd },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: selected },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const sessionRequested = Promise.withResolvers<void>()
|
||||
const modelRequested = Promise.withResolvers<void>()
|
||||
const releaseModels = Promise.withResolvers<void>()
|
||||
const promptSubmitted = Promise.withResolvers<void>()
|
||||
const bodies: unknown[] = []
|
||||
const selections: unknown[] = []
|
||||
const calls = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy") {
|
||||
sessionRequested.resolve()
|
||||
return json({ data: session })
|
||||
}
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({
|
||||
location,
|
||||
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
|
||||
})
|
||||
if (url.pathname === "/api/model") {
|
||||
modelRequested.resolve()
|
||||
if (scenario.delayed) await releaseModels.promise
|
||||
return json({
|
||||
location,
|
||||
data: [
|
||||
...(scenario.delayed ? [{ id: "fallback", providerID: "provider", name: "Fallback", variants: [] }] : []),
|
||||
{ id: selected, providerID: "provider", name: "Selected", variants: [] },
|
||||
],
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/session/dummy/model") {
|
||||
selections.push(await request.json())
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/session/dummy/prompt") {
|
||||
bodies.push(await request.json())
|
||||
promptSubmitted.resolve()
|
||||
return json({ data: {} })
|
||||
}
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await Promise.race([
|
||||
promptSubmitted.promise,
|
||||
Bun.sleep(2000).then(() => {
|
||||
throw new Error("startup prompt was not submitted")
|
||||
}),
|
||||
])
|
||||
await Bun.sleep(20)
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
if (scenario.delayed) {
|
||||
await Promise.all([sessionRequested.promise, modelRequested.promise])
|
||||
await Bun.sleep(20)
|
||||
expect(bodies).toEqual([])
|
||||
releaseModels.resolve()
|
||||
}
|
||||
await Promise.race([
|
||||
promptSubmitted.promise,
|
||||
Bun.sleep(2000).then(() => {
|
||||
throw new Error("startup prompt was not submitted")
|
||||
}),
|
||||
])
|
||||
await Bun.sleep(20)
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("home prompt retry reuses the accepted session and message IDs", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const sessionReady = Promise.withResolvers<void>()
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
setup.renderer.setTerminalTitle = (title) => {
|
||||
if (title === "OpenCode") ready.resolve()
|
||||
if (title === "OC | New session") sessionReady.resolve()
|
||||
setTitle(title)
|
||||
}
|
||||
const events = createEventStream()
|
||||
const cwd = process.cwd()
|
||||
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
||||
const creates: unknown[] = []
|
||||
const prompts: unknown[] = []
|
||||
const modelLoaded = Promise.withResolvers<void>()
|
||||
const firstPrompt = Promise.withResolvers<void>()
|
||||
const secondPrompt = Promise.withResolvers<void>()
|
||||
let createdID: string | undefined
|
||||
const session = (id: string) => ({
|
||||
id,
|
||||
title: "New session",
|
||||
projectID: "project",
|
||||
location: { directory: cwd },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
|
||||
expect(selections).toEqual([])
|
||||
} finally {
|
||||
releaseModels.resolve()
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
const calls = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({
|
||||
location,
|
||||
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
|
||||
})
|
||||
if (url.pathname === "/api/model") {
|
||||
modelLoaded.resolve()
|
||||
return json({
|
||||
location,
|
||||
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/session" && request.method === "GET")
|
||||
return json({ data: createdID ? [session(createdID)] : [], cursor: {} })
|
||||
if (url.pathname === "/api/session" && request.method === "POST") {
|
||||
const body = await request.json()
|
||||
if (!body || typeof body !== "object" || !("id" in body) || typeof body.id !== "string") {
|
||||
throw new Error("session create did not supply an ID")
|
||||
}
|
||||
creates.push(body)
|
||||
createdID = body.id
|
||||
return json({ data: session(body.id) })
|
||||
}
|
||||
if (createdID && url.pathname === `/api/session/${createdID}`) return json({ data: session(createdID) })
|
||||
if (createdID && url.pathname === `/api/session/${createdID}/message`) return json({ data: [], cursor: {} })
|
||||
if (createdID && url.pathname === `/api/session/${createdID}/pending`) return json({ data: [] })
|
||||
if (createdID && url.pathname === `/api/session/${createdID}/permission`) return json({ data: [] })
|
||||
if (createdID && url.pathname === `/api/session/${createdID}/prompt`) {
|
||||
prompts.push(await request.json())
|
||||
if (prompts.length === 1) {
|
||||
firstPrompt.resolve()
|
||||
return json({ error: "response lost after admission" }, { status: 500 })
|
||||
}
|
||||
secondPrompt.resolve()
|
||||
return json({ data: {} })
|
||||
}
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await Promise.all([ready.promise, modelLoaded.promise])
|
||||
await setup.mockInput.typeText("RETRY_READY")
|
||||
setup.mockInput.pressEnter()
|
||||
await Promise.race([
|
||||
firstPrompt.promise,
|
||||
Bun.sleep(2_000).then(() => {
|
||||
throw new Error("first home prompt was not submitted")
|
||||
}),
|
||||
])
|
||||
await setup.waitForFrame((frame) => frame.includes("Failed to send prompt"))
|
||||
setup.mockInput.pressEnter()
|
||||
await Promise.race([
|
||||
secondPrompt.promise,
|
||||
Bun.sleep(2_000).then(() => {
|
||||
throw new Error("home prompt was not retried")
|
||||
}),
|
||||
])
|
||||
await sessionReady.promise
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
|
||||
expect(creates).toHaveLength(2)
|
||||
expect(prompts).toHaveLength(2)
|
||||
expect(creates[1]).toEqual(creates[0])
|
||||
expect(prompts[1]).toEqual(prompts[0])
|
||||
expect(creates[0]).toMatchObject({ id: expect.stringMatching(/^ses_/) })
|
||||
expect(prompts[0]).toMatchObject({ id: expect.stringMatching(/^msg_/), text: "RETRY_READY" })
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { createPromptSubmission } from "../../src/prompt/submission"
|
||||
|
||||
describe("prompt submission identity", () => {
|
||||
test("reuses identities while retrying the same submission", async () => {
|
||||
const submission = createPromptSubmission()
|
||||
const firstSession = await submission.begin(1n)
|
||||
const firstMessage = await submission.message()
|
||||
|
||||
expect(await submission.begin(1n)).toBe(firstSession)
|
||||
expect(await submission.message()).toBe(firstMessage)
|
||||
expect(await submission.begin(2n)).not.toBe(firstSession)
|
||||
expect(await submission.message()).not.toBe(firstMessage)
|
||||
})
|
||||
|
||||
test("preserves an existing session while retrying its prompt", async () => {
|
||||
const submission = createPromptSubmission()
|
||||
const sessionID = Session.ID.create()
|
||||
|
||||
expect(await submission.begin(1n, sessionID)).toBe(sessionID)
|
||||
expect(await submission.begin(1n, sessionID)).toBe(sessionID)
|
||||
})
|
||||
|
||||
test("starts a new identity after completion", async () => {
|
||||
const submission = createPromptSubmission()
|
||||
const first = await submission.begin(1n)
|
||||
submission.complete()
|
||||
|
||||
expect(await submission.begin(1n)).not.toBe(first)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user