فهرست منبع

fix(tui): submit prompt when resuming session (#38260)

Simon Klee 2 هفته پیش
والد
کامیت
ced3d5e02a

+ 1 - 0
packages/cli/src/commands/commands.ts

@@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
       Flag.withDescription("Session ID to continue"),
       Flag.optional,
     ),
+    prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
   },
   commands: [
     Spec.make("acp", { description: "Start an Agent Client Protocol server" }),

+ 5 - 1
packages/cli/src/commands/handlers/default.ts

@@ -55,7 +55,11 @@ export default Runtime.handler(Commands, (input) =>
             }
           : 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: {
         path: config.path,
         get: () => runPromise(config.get()),

+ 5 - 3
packages/tui/src/app.tsx

@@ -525,6 +525,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
   })
 
   const args = useArgs()
+  const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined
   onMount(() => {
     batch(() => {
       if (args.agent) local.agent.set(args.agent)
@@ -542,6 +543,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
         route.navigate({
           type: "session",
           sessionID: args.sessionID,
+          prompt: startupPrompt,
         })
       }
     })
@@ -564,12 +566,12 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
         const match = response.data[0]?.id
         if (!match) return
         if (!args.fork) {
-          route.navigate({ type: "session", sessionID: match })
+          route.navigate({ type: "session", sessionID: match, prompt: startupPrompt })
           return
         }
         void client.api.session
           .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)
@@ -582,7 +584,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
     forked = true
     void client.api.session
       .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)
   })
 

+ 18 - 3
packages/tui/src/routes/session/index.tsx

@@ -84,6 +84,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type
 import { switchLabel } from "../../util/model"
 import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
 import { stringWidth } from "../../util/string-width"
+import { useArgs } from "../../context/args"
 
 addDefaultParsers(parsers.parsers)
 
@@ -120,6 +121,7 @@ export function Session() {
   const { navigate } = useRoute()
   const data = useData()
   const local = useLocal()
+  const args = useArgs()
   const paths = useTuiPaths()
   const configState = useConfig()
   const config = configState.data
@@ -216,6 +218,7 @@ export function Session() {
   const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
   const [navigationMessage, setNavigationMessage] = createSignal<string>()
   const [navigationSlack, setNavigationSlack] = createSignal(0)
+  const [synced, setSynced] = createSignal(false)
 
   const clearMessageNavigation = () => {
     setNavigationSlack(0)
@@ -242,6 +245,7 @@ export function Session() {
 
   createEffect(() => {
     if (client.connection.status() !== "connected") return
+    setSynced(false)
     const sessionID = route.sessionID
     void (async () => {
       await Promise.all([
@@ -261,6 +265,7 @@ export function Session() {
       }
       editor.reconnect(info.location.directory)
       if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
+      setSynced(true)
     })().catch((error) => {
       if (route.sessionID !== sessionID) return
       toast.show({
@@ -273,15 +278,25 @@ export function Session() {
   })
 
   let seeded = false
+  let sent = false
   let scroll: ScrollBoxRenderable
-  let prompt: PromptRef | undefined
+  const [prompt, setPrompt] = createSignal<PromptRef>()
   const bind = (r: PromptRef | undefined) => {
-    prompt = r
+    setPrompt(r)
     promptRef.set(r)
     if (seeded || !route.prompt || !r) return
     seeded = true
     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 renderer = useRenderer()
   const unavailable = (feature: string) => {
@@ -526,7 +541,7 @@ export function Session() {
         void client.api.session.revert
           .stage({ sessionID: route.sessionID, messageID: message.id })
           .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
-        prompt?.set({
+        prompt()?.set({
           ...projectedPromptInput(message),
           pasted: [],
         })

+ 83 - 0
packages/tui/test/app-lifecycle.test.tsx

@@ -69,6 +69,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
     setTitle(title)
   }
   const events = createEventStream()
+  let promptRequests = 0
   const calls = createFetch((url) => {
     const session = {
       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/pending") 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)
   const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
   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("opencode2 -s dummy")
+    expect(promptRequests).toBe(0)
   } finally {
     process.stdout.write = originalWrite
     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()
   }
 })
+
+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()
+  }
+})