diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 19edb6eec4b..9a5d89e1c78 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -7,10 +7,33 @@ import { NamedError } from "@opencode-ai/util/error" import { readableStreamToText } from "bun" import { Lock } from "../util/lock" import { PackageRegistry } from "./registry" +import { mkdir, rm } from "fs/promises" export namespace BunProc { const log = Log.create({ service: "bun" }) + async function getPendingUpdatePath(pkg: string): Promise { + return path.join(Global.Path.cache, ".update-pending", pkg) + } + + async function hasPendingUpdate(pkg: string): Promise { + const updatePath = await getPendingUpdatePath(pkg) + return await Filesystem.exists(updatePath) + } + + async function markUpdatePending(pkg: string): Promise { + const updatePath = await getPendingUpdatePath(pkg) + await mkdir(path.dirname(updatePath), { recursive: true }) + await Bun.write(updatePath, Date.now().toString()) + } + + async function clearPendingUpdate(pkg: string): Promise { + const updatePath = await getPendingUpdatePath(pkg) + if (await Filesystem.exists(updatePath)) { + await rm(updatePath) + } + } + export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject) { log.info("running", { cmd: [which(), ...cmd], @@ -76,14 +99,37 @@ export namespace BunProc { const modExists = await Filesystem.exists(mod) const cachedVersion = dependencies[pkg] + const pendingUpdate = await hasPendingUpdate(pkg) + if (!modExists || !cachedVersion) { // continue to install } else if (version !== "latest" && cachedVersion === version) { return mod - } else if (version === "latest") { - const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache) - if (!isOutdated) return mod - log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion }) + } else if (version === "latest" && pendingUpdate) { + // Update was detected in previous session - install now + log.info("Installing pending plugin update", { pkg, current: cachedVersion }) + await clearPendingUpdate(pkg) + // Fall through to install latest version + } else if (version === "latest" && cachedVersion) { + // Return cached version immediately, check for updates in background + queueMicrotask(() => { + void (async () => { + try { + using _lock = await Lock.read("bun-install") + const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache) + if (isOutdated) { + await markUpdatePending(pkg) + log.info("Plugin update available - will be installed on next startup", { + pkg, + current: cachedVersion, + }) + } + } catch (error) { + log.debug("Background update check failed", { pkg, error }) + } + })() + }) + return mod } const proxied = !!( diff --git a/packages/opencode/src/cli/ink/App.tsx b/packages/opencode/src/cli/ink/App.tsx index 4b82f6a09ba..f71d06eceb9 100644 --- a/packages/opencode/src/cli/ink/App.tsx +++ b/packages/opencode/src/cli/ink/App.tsx @@ -2,6 +2,7 @@ import { useReducer, useCallback, useState, useRef, useEffect } from "react" import type { ReactElement } from "react" import { Box, Text } from "ink" + import { appReducer, initialState } from "./state/reducer" import { theme } from "./theme" import { InputLine } from "./components/InputLine" @@ -36,6 +37,7 @@ export const App = (): ReactElement => { }) } catch (error) { console.error("Session init failed:", error) + dispatch({ type: "CLEAR_STREAMING" }) dispatch({ type: "STREAM_TEXT", payload: `Error: Failed to initialize session - ${error}\n`, @@ -66,8 +68,15 @@ export const App = (): ReactElement => { }) return } - // Send message via SDK hook - await sendMessage(value.trim()) + try { + await sendMessage(value.trim()) + } catch (err) { + dispatch({ type: "CLEAR_STREAMING" }) + dispatch({ + type: "STREAM_TEXT", + payload: `Failed to send message: ${err}\n`, + }) + } } }, [dispatch, setUIMode, state.session.id, sendMessage], diff --git a/packages/opencode/src/cli/ink/components/InputLine.tsx b/packages/opencode/src/cli/ink/components/InputLine.tsx index 7e1babf2b79..7f846dd7f39 100644 --- a/packages/opencode/src/cli/ink/components/InputLine.tsx +++ b/packages/opencode/src/cli/ink/components/InputLine.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from "react" import type { ReactElement } from "react" import { Box, Text, useInput } from "ink" + import { theme } from "../theme" interface InputLineProps { @@ -29,7 +30,9 @@ export const InputLine = ({ useInput( (input, key) => { - if (disabled || !focus) return + if (disabled || !focus) { + return + } if (key.return) { onSubmit(state.value) diff --git a/packages/opencode/src/cli/ink/entry.ts b/packages/opencode/src/cli/ink/entry.ts index ec9cbf5c00c..c79c45f07a5 100644 --- a/packages/opencode/src/cli/ink/entry.ts +++ b/packages/opencode/src/cli/ink/entry.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun + import { Global } from "@/global" import { Log } from "@/util/log" import { Instance } from "@/project/instance" @@ -16,6 +17,7 @@ async function main() { } await Global.init() + // Disable Log printing to stdout - Ink owns stdout for TUI rendering await Log.init({ print: false, diff --git a/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts b/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts index f053fb29fcc..f84b7a0d7c3 100644 --- a/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts +++ b/packages/opencode/src/cli/ink/hooks/useSDKEvents.ts @@ -1,4 +1,5 @@ import { useEffect, useCallback, useRef, useState } from "react" + import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import type { Action } from "@/cli/ink/state/reducer" import type { Dispatch } from "react" @@ -24,9 +25,13 @@ function createInProcessFetch() { export function useSDKEvents(sessionId: string | null, dispatch: Dispatch) { const sdkRef = useRef | null>(null) const [isStreaming, setIsStreaming] = useState(false) + const currentAssistantMessageIdRef = useRef(null) + const streamingLockRef = useRef(false) useEffect(() => { - if (!sessionId) return + if (!sessionId) { + return + } const abortController = new AbortController() const sdk = createOpencodeClient({ @@ -47,7 +52,7 @@ export function useSDKEvents(sessionId: string | null, dispatch: Dispatch { - if (!sessionId || !sdkRef.current) return + if (!sessionId || !sdkRef.current) { + throw new Error("Session not ready - SDK client not initialized") + } + + // Use ref-based locking to prevent TOCTOU race + if (streamingLockRef.current || isStreaming) { + throw new Error("Message already in progress - please wait") + } + + // Acquire lock BEFORE any async operations + streamingLockRef.current = true - setIsStreaming(true) dispatch({ type: "CLEAR_STREAMING" }) + setIsStreaming(true) + try { - await sdkRef.current.session.prompt({ + const result = await sdkRef.current.session.prompt({ sessionID: sessionId, parts: [{ type: "text", text: content }], }) + + if (result.error) { + throw new Error("Failed to send message") + } + + currentAssistantMessageIdRef.current = result.data.info.id } catch (err) { + currentAssistantMessageIdRef.current = null setIsStreaming(false) + streamingLockRef.current = false console.error("Failed to send message:", err) throw err } }, - [sessionId, dispatch], + [sessionId, dispatch, isStreaming], ) return { @@ -97,19 +121,27 @@ function handleEvent( dispatch: Dispatch, sessionId: string, setIsStreaming: (value: boolean) => void, + currentAssistantMessageIdRef: { current: string | null }, + streamingLockRef: { current: boolean }, ) { switch (event.type) { case "message.part.updated": { const part = event.properties.part if (part.sessionID !== sessionId) return - // Handle text streaming + // Handle text streaming - only for assistant messages if (part.type === "text") { - const delta = event.properties.delta - if (delta) { + // Skip if this is not the expected assistant message + if (currentAssistantMessageIdRef.current && part.messageID !== currentAssistantMessageIdRef.current) { + return + } + + // Use delta for incremental updates, fallback to full text + const content = event.properties.delta ?? part.text + if (typeof content === "string" && content.length > 0) { dispatch({ type: "STREAM_TEXT", - payload: delta, + payload: content, }) } } @@ -178,6 +210,13 @@ function handleEvent( case "message.updated": { if (event.properties.info.sessionID !== sessionId) return setIsStreaming(false) + + // Clear the tracked message ID if this is the expected assistant message + if (currentAssistantMessageIdRef.current === event.properties.info.id) { + currentAssistantMessageIdRef.current = null + streamingLockRef.current = false + } + dispatch({ type: "MESSAGE_COMPLETE", payload: { id: event.properties.info.id }, @@ -189,7 +228,15 @@ function handleEvent( if (event.properties.sessionID !== sessionId) return if (event.properties.status.type === "idle") { setIsStreaming(false) - dispatch({ type: "CLEAR_STREAMING" }) + streamingLockRef.current = false + + // Fallback: If MESSAGE_COMPLETE never arrived (SDK crash, network error), + // clear streaming state to prevent memory leak + if (currentAssistantMessageIdRef.current !== null) { + currentAssistantMessageIdRef.current = null + dispatch({ type: "CLEAR_STREAMING" }) + } + // Otherwise, MESSAGE_COMPLETE already moved content to messages array } break } diff --git a/packages/opencode/src/cli/ink/index.tsx b/packages/opencode/src/cli/ink/index.tsx index 68e1155150a..69baf68f10a 100644 --- a/packages/opencode/src/cli/ink/index.tsx +++ b/packages/opencode/src/cli/ink/index.tsx @@ -1,8 +1,15 @@ /** @jsxImportSource react */ + import { render } from "ink" import App from "./App" export function startInkTUI() { - const instance = render() + // Configure stdin/stdout for Ink to receive keyboard events + const instance = render(, { + stdin: process.stdin, + stdout: process.stdout, + exitOnCtrlC: true, // Let Ink handle Ctrl+C exit + }) + return instance.waitUntilExit() } diff --git a/packages/opencode/test/cli/ink/hooks/event-handler.test.ts b/packages/opencode/test/cli/ink/hooks/event-handler.test.ts index 85790025ca9..1266ee332bb 100644 --- a/packages/opencode/test/cli/ink/hooks/event-handler.test.ts +++ b/packages/opencode/test/cli/ink/hooks/event-handler.test.ts @@ -253,10 +253,10 @@ describe("SDK Event Handling Logic", () => { if (sessionStatusEvent.type === "session.status") { if (sessionStatusEvent.properties.status.type === "idle") { - dispatch({ type: "CLEAR_STREAMING" }) + // Don't dispatch CLEAR_STREAMING - MESSAGE_COMPLETE handles it } } - expect(dispatch).toHaveBeenCalledWith({ type: "CLEAR_STREAMING" }) + expect(dispatch).not.toHaveBeenCalled() }) }) diff --git a/packages/opencode/test/cli/ink/hooks/streaming-fix.test.ts b/packages/opencode/test/cli/ink/hooks/streaming-fix.test.ts new file mode 100644 index 00000000000..f2524715c34 --- /dev/null +++ b/packages/opencode/test/cli/ink/hooks/streaming-fix.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "bun:test" +import { appReducer, initialState } from "@/cli/ink/state/reducer" +import type { Event } from "@opencode-ai/sdk/v2" + +describe("TUI Response Rendering Fix - Issue #142", () => { + it("preserves response text through complete event sequence", () => { + // Simulate the exact event sequence from the debug log where response was lost + const sessionId = "sess-1" + let state = initialState + let isStreaming = false + + // 1. User sends message - clear previous state + state = appReducer(state, { type: "CLEAR_STREAMING" }) + isStreaming = true + expect(state.streaming.text).toBe("") + expect(state.messages).toHaveLength(0) + + // 2. message.part.updated - LLM streams text response + const textEvent: Event = { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + sessionID: sessionId, + messageID: "msg-1", + type: "text", + text: "Hello! How can I help you today?", + }, + delta: "Hello! How can I help you today?", + }, + } + + if (textEvent.type === "message.part.updated" && textEvent.properties.part.type === "text") { + const content = textEvent.properties.delta ?? textEvent.properties.part.text + state = appReducer(state, { type: "STREAM_TEXT", payload: content as string }) + } + + // Response is now in streaming.text + expect(state.streaming.text).toBe("Hello! How can I help you today?") + expect(state.messages).toHaveLength(0) + + // 3. message.updated - message completes + const messageUpdatedEvent: Event = { + type: "message.updated", + properties: { + info: { + id: "msg-1", + sessionID: sessionId, + role: "assistant", + parts: [], + time: { created: Date.now() }, + }, + }, + } + + if (messageUpdatedEvent.type === "message.updated") { + isStreaming = false + state = appReducer(state, { + type: "MESSAGE_COMPLETE", + payload: { id: messageUpdatedEvent.properties.info.id }, + }) + } + + // MESSAGE_COMPLETE moves text to messages array and clears streaming + expect(state.messages).toHaveLength(1) + expect(state.messages[0].parts).toHaveLength(1) + expect(state.messages[0].parts[0].type).toBe("text") + if (state.messages[0].parts[0].type === "text") { + expect(state.messages[0].parts[0].content).toBe("Hello! How can I help you today?") + } + expect(state.streaming.text).toBe("") + + // 4. session.status: idle - this was clearing everything (THE BUG!) + // After the fix, this should NOT dispatch CLEAR_STREAMING + const sessionStatusEvent: Event = { + type: "session.status", + properties: { + sessionID: sessionId, + status: { type: "idle" }, + }, + } + + if (sessionStatusEvent.type === "session.status") { + if (sessionStatusEvent.properties.status.type === "idle") { + isStreaming = false + // FIX: Don't dispatch CLEAR_STREAMING here + // The old buggy code did: dispatch({ type: "CLEAR_STREAMING" }) + } + } + + // Message should still be preserved in messages array + expect(state.messages).toHaveLength(1) + if (state.messages[0].parts[0].type === "text") { + expect(state.messages[0].parts[0].content).toBe("Hello! How can I help you today?") + } + expect(isStreaming).toBe(false) + }) + + it("handles multiple text chunks correctly", () => { + const sessionId = "sess-1" + let state = initialState + + // Clear previous state + state = appReducer(state, { type: "CLEAR_STREAMING" }) + + // Stream text in chunks + state = appReducer(state, { type: "STREAM_TEXT", payload: "Hello! " }) + state = appReducer(state, { type: "STREAM_TEXT", payload: "How can " }) + state = appReducer(state, { type: "STREAM_TEXT", payload: "I help you?" }) + + expect(state.streaming.text).toBe("Hello! How can I help you?") + + // Complete the message + state = appReducer(state, { + type: "MESSAGE_COMPLETE", + payload: { id: "msg-1" }, + }) + + expect(state.messages).toHaveLength(1) + if (state.messages[0].parts[0].type === "text") { + expect(state.messages[0].parts[0].content).toBe("Hello! How can I help you?") + } + expect(state.streaming.text).toBe("") + }) +}) diff --git a/packages/opencode/test/cli/ink/hooks/useSDKEvents.test.ts b/packages/opencode/test/cli/ink/hooks/useSDKEvents.test.ts index dd81a8e2fd4..77d121fa39d 100644 --- a/packages/opencode/test/cli/ink/hooks/useSDKEvents.test.ts +++ b/packages/opencode/test/cli/ink/hooks/useSDKEvents.test.ts @@ -41,6 +41,76 @@ describe("useSDKEvents - sendMessage logic", () => { }) }) +describe("useSDKEvents - message filtering", () => { + it("filters out user message text parts", () => { + const dispatch = mock<(action: Action) => void>(() => {}) + const currentAssistantMessageId = "msg-assistant-123" + const userMessageId = "msg-user-456" + + // Simulate user message text part (should be filtered) + const userTextEvent: Event = { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + sessionID: "sess-1", + messageID: userMessageId, + type: "text", + text: "Hello?", + }, + }, + } + + // Check if this is a text part from a non-assistant message + if (userTextEvent.type === "message.part.updated" && userTextEvent.properties.part.type === "text") { + const part = userTextEvent.properties.part + if (currentAssistantMessageId && part.messageID !== currentAssistantMessageId) { + // This should be skipped - don't dispatch + return + } + } + + expect(dispatch).not.toHaveBeenCalled() + }) + + it("allows assistant message text parts", () => { + const dispatch = mock<(action: Action) => void>(() => {}) + const currentAssistantMessageId = "msg-assistant-123" + + // Simulate assistant message text part (should be allowed) + const assistantTextEvent: Event = { + type: "message.part.updated", + properties: { + part: { + id: "part-2", + sessionID: "sess-1", + messageID: currentAssistantMessageId, + type: "text", + text: "I can help with that!", + }, + }, + } + + // Check if this is a text part from the assistant message + if (assistantTextEvent.type === "message.part.updated" && assistantTextEvent.properties.part.type === "text") { + const part = assistantTextEvent.properties.part + if (currentAssistantMessageId && part.messageID !== currentAssistantMessageId) { + return + } + + dispatch({ + type: "STREAM_TEXT", + payload: part.text, + }) + } + + expect(dispatch).toHaveBeenCalledWith({ + type: "STREAM_TEXT", + payload: "I can help with that!", + }) + }) +}) + describe("useSDKEvents - streaming state tracking", () => { it("sets isStreaming to false on message.updated event", () => { const dispatch = mock<(action: Action) => void>(() => {}) @@ -89,11 +159,11 @@ describe("useSDKEvents - streaming state tracking", () => { if (sessionStatusEvent.type === "session.status") { if (sessionStatusEvent.properties.status.type === "idle") { isStreaming = false - dispatch({ type: "CLEAR_STREAMING" }) + // Don't dispatch CLEAR_STREAMING - MESSAGE_COMPLETE handles it } } expect(isStreaming).toBe(false) - expect(dispatch).toHaveBeenCalledWith({ type: "CLEAR_STREAMING" }) + expect(dispatch).not.toHaveBeenCalled() }) })