#18429 · @RhoninSeiei · opened Mar 20, 2026 at 6:58 PM UTC · last updated Mar 21, 2026 at 3:49 PM UTC

fix: preserve subtask output and refresh TUI session after provider auth

tuifix
61
+190138 files

Score breakdown

Impact

9.0

Clarity

8.0

Urgency

9.0

Ease Of Review

8.0

Guidelines

9.0

Readiness

7.0

Size

3.0

Trust

5.0

Traction

2.0

Summary

This PR fixes two critical bugs: subagent output returning empty after tool calls, and TUI sessions failing to refresh after provider authentication, leading to blank message lists. It includes new regression tests for both issues.

Open in GitHub

Description

Issue for this PR

Closes #18423 Closes #18427

Type of change

  • [x] Bug fix
  • [ ] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

task was returning the last text part from a subagent session even when that last part was empty, so the orchestrator could receive an empty <task_result> after a tool call. This change now prefers the last non-empty text part and only falls back to the literal last text part when every text part is empty.

The TUI provider auth dialog was also only calling sync.bootstrap() after OAuth or API key setup. In an active session, that left the session cache untouched and the message list could stay blank until leaving and reopening the session. This change adds a force option to TUI session sync and refreshes the current session immediately after provider auth succeeds.

How did you verify your code works?

  • Added a regression test for subagent output selection in test/tool/task.test.ts
  • Added a regression test for provider auth session refresh in test/cli/tui/dialog-provider-refresh.test.ts
  • Ran /tmp/bun-install/bun-linux-x64/bun test test/tool/task.test.ts test/cli/tui/dialog-provider-refresh.test.ts --timeout 30000
  • Ran /tmp/bun-install/bun-linux-x64/bun run typecheck
  • Ran ./node_modules/.bin/prettier --check packages/opencode/src/tool/task.ts packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx packages/opencode/src/cli/cmd/tui/component/dialog-provider-refresh.ts packages/opencode/src/cli/cmd/tui/context/sync.tsx packages/opencode/test/tool/task.test.ts packages/opencode/test/cli/tui/dialog-provider-refresh.test.ts
  • Push hook also ran bun turbo typecheck

Screenshots / recordings

N/A. This TUI change updates session refresh behavior rather than terminal layout.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

#18423 Ollama subagent executes tool calls correctly but always returns empty text to orchestrator

View issue

#18427 Issue: Oauth mid-session causes no messages or responses to display

View issue

Comments

No comments.

Changed Files

packages/app/src/components/dialog-connect-provider.tsx

+11
@@ -382,7 +382,7 @@ export function DialogConnectProvider(props: { provider: string }) {
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
body: {
type: "api",
key: apiKey,
},

packages/app/src/components/dialog-custom-provider.tsx

+11
@@ -124,7 +124,7 @@ export function DialogCustomProvider(props: Props) {
if (result.key) {
await globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
body: {
type: "api",
key: result.key,
},

packages/opencode/src/cli/cmd/tui/component/dialog-provider-refresh.ts

+250
@@ -0,0 +1,25 @@
import type { Route } from "../context/route"
type RefreshProviderSessionDeps = {
route: { data: Route }
sdk: {
client: {
instance: {
dispose(): Promise<unknown>
}
}
}
sync: {
bootstrap(): Promise<unknown>
session: {
sync(sessionID: string, opts?: { force?: boolean }): Promise<unknown> | unknown
}
}
}
export async function refreshProviderSession(deps: RefreshProviderSessionDeps) {
await deps.sdk.client.instance.dispose()
await deps.sync.bootstrap()
if (deps.route.data.type !== "session") return
await deps.sync.session.sync(deps.route.data.sessionID, { force: true })
}

packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

+97
@@ -13,6 +13,8 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { useRoute } from "../context/route"
import { refreshProviderSession } from "./dialog-provider-refresh"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -130,6 +132,7 @@ function AutoMethod(props: AutoMethodProps) {
const sdk = useSDK()
const dialog = useDialog()
const sync = useSync()
const route = useRoute()
const toast = useToast()
useKeyboard((evt) => {
@@ -150,8 +153,7 @@ function AutoMethod(props: AutoMethodProps) {
dialog.clear()
return
}
await sdk.client.instance.dispose()
await sync.bootstrap()
await refreshProviderSession({ sdk, sync, route })
dialog.replace(() => <DialogModel providerID={props.providerID} />)
})
@@ -188,6 +190,7 @@ function CodeMethod(props: CodeMethodProps) {
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const route = useRoute()
const [error, setError] = createSignal(false)
return (
@@ -201,8 +204,7 @@ function CodeMet

packages/opencode/src/cli/cmd/tui/context/sync.tsx

+22
@@ -467,8 +467,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
async sync(sessionID: string, opts?: { force?: boolean }) {
if (fullSyncedSessions.has(sessionID) && !opts?.force) return
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),

packages/opencode/src/tool/task.ts

+21
@@ -144,7 +144,8 @@ export const TaskTool = Tool.define("task", async (ctx) => {
parts: promptParts,
})
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
const textParts = result.parts.filter((part): part is MessageV2.TextPart => part.type === "text")
const text = textParts.findLast((part) => part.text.trim().length > 0)?.text ?? textParts.at(-1)?.text ?? ""
const output = [
`task_id: ${session.id} (for resuming to continue this task if needed)`,

packages/opencode/test/cli/tui/dialog-provider-refresh.test.ts

+670
@@ -0,0 +1,67 @@
import { describe, expect, mock, test } from "bun:test"
import { refreshProviderSession } from "../../../src/cli/cmd/tui/component/dialog-provider-refresh"
describe("dialog provider refresh", () => {
test("forces a session refresh after provider auth inside a session route", async () => {
const calls: string[] = []
const sessionSync = mock(async (_sessionID: string, _opts?: { force?: boolean }) => {
calls.push("sync")
})
await refreshProviderSession({
route: {
data: {
type: "session",
sessionID: "ses_test",
},
},
sdk: {
client: {
instance: {
dispose: async () => {
calls.push("dispose")
},
},
},
},
sync: {
bootstrap: async () => {
calls.push("bootstrap")
},
session: {
sync: sessionSync,
},
},
})
expect(calls).toEqual(["dispose", "bootstrap", "sync"])
expect(sessionSync).toHaveBeenCalledWith("ses_test", { force: true })
})
test("skips session refresh outside a session route", async () => {

packages/opencode/test/tool/task.test.ts

+831
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageID, SessionID } from "../../src/session/schema"
import { TaskTool } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
@@ -9,6 +14,10 @@ afterEach(async () => {
})
describe("tool.task", () => {
afterEach(() => {
mock.restore()
})
test("description sorts subagents by name and is stable across calls", async () => {
await using tmp = await tmpdir({
config: {
@@ -46,4 +55,77 @@ describe("tool.task", () => {
},
})
})
test("returns the latest non-empty text from a subagent result", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: {
description: "Build agent",