fix(tui): submit prompt when resuming session (#38260)
This commit is contained in:
parent
6e826f3e22
commit
ced3d5e02a
5 changed files with 112 additions and 7 deletions
|
|
@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||||
Flag.withDescription("Session ID to continue"),
|
Flag.withDescription("Session ID to continue"),
|
||||||
Flag.optional,
|
Flag.optional,
|
||||||
),
|
),
|
||||||
|
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||||
},
|
},
|
||||||
commands: [
|
commands: [
|
||||||
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
args: {
|
||||||
|
continue: input.continue,
|
||||||
|
sessionID: Option.getOrUndefined(input.session),
|
||||||
|
prompt: Option.getOrUndefined(input.prompt),
|
||||||
|
},
|
||||||
config: {
|
config: {
|
||||||
path: config.path,
|
path: config.path,
|
||||||
get: () => runPromise(config.get()),
|
get: () => runPromise(config.get()),
|
||||||
|
|
|
||||||
|
|
@ -525,6 +525,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const args = useArgs()
|
const args = useArgs()
|
||||||
|
const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (args.agent) local.agent.set(args.agent)
|
if (args.agent) local.agent.set(args.agent)
|
||||||
|
|
@ -542,6 +543,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||||
route.navigate({
|
route.navigate({
|
||||||
type: "session",
|
type: "session",
|
||||||
sessionID: args.sessionID,
|
sessionID: args.sessionID,
|
||||||
|
prompt: startupPrompt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -564,12 +566,12 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||||
const match = response.data[0]?.id
|
const match = response.data[0]?.id
|
||||||
if (!match) return
|
if (!match) return
|
||||||
if (!args.fork) {
|
if (!args.fork) {
|
||||||
route.navigate({ type: "session", sessionID: match })
|
route.navigate({ type: "session", sessionID: match, prompt: startupPrompt })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void client.api.session
|
void client.api.session
|
||||||
.fork({ sessionID: match })
|
.fork({ sessionID: match })
|
||||||
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
|
.then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
})
|
})
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
|
|
@ -582,7 +584,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||||
forked = true
|
forked = true
|
||||||
void client.api.session
|
void client.api.session
|
||||||
.fork({ sessionID: args.sessionID })
|
.fork({ sessionID: args.sessionID })
|
||||||
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
|
.then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type
|
||||||
import { switchLabel } from "../../util/model"
|
import { switchLabel } from "../../util/model"
|
||||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||||
import { stringWidth } from "../../util/string-width"
|
import { stringWidth } from "../../util/string-width"
|
||||||
|
import { useArgs } from "../../context/args"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
|
|
@ -120,6 +121,7 @@ export function Session() {
|
||||||
const { navigate } = useRoute()
|
const { navigate } = useRoute()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const args = useArgs()
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const configState = useConfig()
|
const configState = useConfig()
|
||||||
const config = configState.data
|
const config = configState.data
|
||||||
|
|
@ -216,6 +218,7 @@ export function Session() {
|
||||||
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
||||||
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
||||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||||
|
const [synced, setSynced] = createSignal(false)
|
||||||
|
|
||||||
const clearMessageNavigation = () => {
|
const clearMessageNavigation = () => {
|
||||||
setNavigationSlack(0)
|
setNavigationSlack(0)
|
||||||
|
|
@ -242,6 +245,7 @@ export function Session() {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (client.connection.status() !== "connected") return
|
if (client.connection.status() !== "connected") return
|
||||||
|
setSynced(false)
|
||||||
const sessionID = route.sessionID
|
const sessionID = route.sessionID
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -261,6 +265,7 @@ export function Session() {
|
||||||
}
|
}
|
||||||
editor.reconnect(info.location.directory)
|
editor.reconnect(info.location.directory)
|
||||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||||
|
setSynced(true)
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
if (route.sessionID !== sessionID) return
|
if (route.sessionID !== sessionID) return
|
||||||
toast.show({
|
toast.show({
|
||||||
|
|
@ -273,15 +278,25 @@ export function Session() {
|
||||||
})
|
})
|
||||||
|
|
||||||
let seeded = false
|
let seeded = false
|
||||||
|
let sent = false
|
||||||
let scroll: ScrollBoxRenderable
|
let scroll: ScrollBoxRenderable
|
||||||
let prompt: PromptRef | undefined
|
const [prompt, setPrompt] = createSignal<PromptRef>()
|
||||||
const bind = (r: PromptRef | undefined) => {
|
const bind = (r: PromptRef | undefined) => {
|
||||||
prompt = r
|
setPrompt(r)
|
||||||
promptRef.set(r)
|
promptRef.set(r)
|
||||||
if (seeded || !route.prompt || !r) return
|
if (seeded || !route.prompt || !r) return
|
||||||
seeded = true
|
seeded = true
|
||||||
r.set(route.prompt)
|
r.set(route.prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const current = prompt()
|
||||||
|
if (sent || !current || !synced() || !local.model.ready) return
|
||||||
|
if (!local.agent.current() || !local.model.current()) return
|
||||||
|
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
||||||
|
sent = true
|
||||||
|
current.submit()
|
||||||
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const unavailable = (feature: string) => {
|
const unavailable = (feature: string) => {
|
||||||
|
|
@ -526,7 +541,7 @@ export function Session() {
|
||||||
void client.api.session.revert
|
void client.api.session.revert
|
||||||
.stage({ sessionID: route.sessionID, messageID: message.id })
|
.stage({ sessionID: route.sessionID, messageID: message.id })
|
||||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||||
prompt?.set({
|
prompt()?.set({
|
||||||
...projectedPromptInput(message),
|
...projectedPromptInput(message),
|
||||||
pasted: [],
|
pasted: [],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||||
setTitle(title)
|
setTitle(title)
|
||||||
}
|
}
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
|
let promptRequests = 0
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
const session = {
|
const session = {
|
||||||
id: "dummy",
|
id: "dummy",
|
||||||
|
|
@ -88,6 +89,10 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
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/pending") return json({ data: [] })
|
||||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||||
|
if (url.pathname === "/api/session/dummy/prompt") {
|
||||||
|
promptRequests++
|
||||||
|
return json({ data: {} })
|
||||||
|
}
|
||||||
}, events)
|
}, events)
|
||||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||||
const originalWrite = process.stdout.write.bind(process.stdout)
|
const originalWrite = process.stdout.write.bind(process.stdout)
|
||||||
|
|
@ -124,6 +129,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||||
|
|
||||||
expect(stdout).toContain("Renamed session")
|
expect(stdout).toContain("Renamed session")
|
||||||
expect(stdout).toContain("opencode2 -s dummy")
|
expect(stdout).toContain("opencode2 -s dummy")
|
||||||
|
expect(promptRequests).toBe(0)
|
||||||
} finally {
|
} finally {
|
||||||
process.stdout.write = originalWrite
|
process.stdout.write = originalWrite
|
||||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||||
|
|
@ -131,3 +137,80 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||||
mock.restore()
|
mock.restore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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: {} })
|
||||||
|
}
|
||||||
|
}, 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({}))),
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue