diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 734958dd589..cd957298ac7 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -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, }, diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 53b66fb451d..4286b13062b 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -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, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider-refresh.ts b/packages/opencode/src/cli/cmd/tui/component/dialog-provider-refresh.ts new file mode 100644 index 00000000000..475d8be9ce6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider-refresh.ts @@ -0,0 +1,25 @@ +import type { Route } from "../context/route" + +type RefreshProviderSessionDeps = { + route: { data: Route } + sdk: { + client: { + instance: { + dispose(): Promise + } + } + } + sync: { + bootstrap(): Promise + session: { + sync(sessionID: string, opts?: { force?: boolean }): Promise | 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 }) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 635ed71f5b3..979417a648f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -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 = { 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(() => ) }) @@ -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 CodeMethod(props: CodeMethodProps) { code: value, }) if (!error) { - await sdk.client.instance.dispose() - await sync.bootstrap() + await refreshProviderSession({ sdk, sync, route }) dialog.replace(() => ) return } @@ -229,6 +231,7 @@ function ApiMethod(props: ApiMethodProps) { const dialog = useDialog() const sdk = useSDK() const sync = useSync() + const route = useRoute() const { theme } = useTheme() return ( @@ -265,13 +268,12 @@ function ApiMethod(props: ApiMethodProps) { if (!value) return await sdk.client.auth.set({ providerID: props.providerID, - auth: { + body: { type: "api", key: value, }, }) - await sdk.client.instance.dispose() - await sync.bootstrap() + await refreshProviderSession({ sdk, sync, route }) dialog.replace(() => ) }} /> diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 3b296a927aa..aecdc30b587 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -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 }), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e3781126d0c..7430bd0434d 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -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)`, diff --git a/packages/opencode/test/cli/tui/dialog-provider-refresh.test.ts b/packages/opencode/test/cli/tui/dialog-provider-refresh.test.ts new file mode 100644 index 00000000000..a61dc174974 --- /dev/null +++ b/packages/opencode/test/cli/tui/dialog-provider-refresh.test.ts @@ -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 () => { + const sessionSync = mock(async () => {}) + + await refreshProviderSession({ + route: { + data: { + type: "home", + }, + }, + sdk: { + client: { + instance: { + dispose: async () => {}, + }, + }, + }, + sync: { + bootstrap: async () => {}, + session: { + sync: sessionSync, + }, + }, + }) + + expect(sessionSync).not.toHaveBeenCalled() + }) +}) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index aae48a30ab3..296d35711a0 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -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", + mode: "subagent", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + if (!build) throw new Error("expected build agent") + + const tool = await TaskTool.init({ agent: build }) + + const sessionID = SessionID.make("ses_parent") + const messageID = MessageID.make("msg_parent") + const subtaskID = SessionID.make("ses_subtask") + + const createSpy = spyOn(Session, "create").mockResolvedValue({ id: subtaskID } as any) + const messageSpy = spyOn(MessageV2, "get").mockResolvedValue({ + info: { + role: "assistant", + modelID: "gpt-5.2", + providerID: "openai", + }, + parts: [], + } as any) + const configSpy = spyOn(Config, "get").mockResolvedValue({ experimental: {} } as any) + const resolveSpy = spyOn(SessionPrompt, "resolvePromptParts").mockResolvedValue([ + { type: "text", text: "work" }, + ] as any) + const promptSpy = spyOn(SessionPrompt, "prompt").mockResolvedValue({ + parts: [ + { type: "text", text: "draft" }, + { type: "text", text: "final answer" }, + { type: "text", text: "" }, + ], + } as any) + + const result = await tool.execute( + { + description: "Inspect", + prompt: "Do work", + subagent_type: "build", + }, + { + sessionID, + messageID, + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + }, + ) + + expect(createSpy).toHaveBeenCalledTimes(1) + expect(messageSpy).toHaveBeenCalledWith({ sessionID, messageID }) + expect(configSpy).toHaveBeenCalledTimes(1) + expect(resolveSpy).toHaveBeenCalledWith("Do work") + expect(promptSpy).toHaveBeenCalledTimes(1) + expect(result.output).toContain("final answer") + expect(result.output).not.toContain("\n\ndraft\n") + }, + }) + }) })