diff --git a/src/cli/run/completion.ts b/src/cli/run/completion.ts index 11a24f4b..f339e9d2 100644 --- a/src/cli/run/completion.ts +++ b/src/cli/run/completion.ts @@ -1,5 +1,6 @@ import pc from "picocolors" import type { RunContext, Todo, ChildSession, SessionStatus } from "./types" +import { normalizeSDKResponse } from "../../shared" export async function checkCompletionConditions(ctx: RunContext): Promise { try { @@ -20,7 +21,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise { const todosRes = await ctx.client.session.todo({ path: { id: ctx.sessionID } }) - const todos = (todosRes.data ?? []) as Todo[] + const todos = normalizeSDKResponse(todosRes, [] as Todo[]) const incompleteTodos = todos.filter( (t) => t.status !== "completed" && t.status !== "cancelled" @@ -43,7 +44,7 @@ async function fetchAllStatuses( ctx: RunContext ): Promise> { const statusRes = await ctx.client.session.status() - return (statusRes.data ?? {}) as Record + return normalizeSDKResponse(statusRes, {} as Record) } async function areAllDescendantsIdle( @@ -54,7 +55,7 @@ async function areAllDescendantsIdle( const childrenRes = await ctx.client.session.children({ path: { id: sessionID }, }) - const children = (childrenRes.data ?? []) as ChildSession[] + const children = normalizeSDKResponse(childrenRes, [] as ChildSession[]) for (const child of children) { const status = allStatuses[child.id] diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index e3c83384..e20f8414 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -6,7 +6,7 @@ import type { ResumeInput, } from "./types" import { TaskHistory } from "./task-history" -import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" +import { log, getAgentToolRestrictions, normalizeSDKResponse, promptWithModelSuggestionRetry } from "../../shared" import { setSessionTools } from "../../shared/session-tools-store" import { ConcurrencyManager } from "./concurrency" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" @@ -651,7 +651,7 @@ export class BackgroundManager { const response = await this.client.session.todo({ path: { id: sessionID }, }) - const todos = (response.data ?? response) as Todo[] + const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true }) if (!todos || todos.length === 0) return false const incomplete = todos.filter( @@ -875,7 +875,7 @@ export class BackgroundManager { path: { id: sessionID }, }) - const messages = ((response.data ?? response) as unknown as Array<{ info?: { role?: string } }>) ?? [] + const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true }) // Check for at least one assistant or tool message const hasAssistantOrToolMessage = messages.some( @@ -1244,9 +1244,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea try { const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> + }>) for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { @@ -1535,7 +1535,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea this.pruneStaleTasksAndNotifications() const statusResult = await this.client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) await this.checkAndInterruptStaleTasks(allStatuses) diff --git a/src/features/background-agent/notify-parent-session.ts b/src/features/background-agent/notify-parent-session.ts index da6a531e..15d24eb1 100644 --- a/src/features/background-agent/notify-parent-session.ts +++ b/src/features/background-agent/notify-parent-session.ts @@ -1,4 +1,4 @@ -import { log } from "../../shared" +import { log, normalizeSDKResponse } from "../../shared" import { findNearestMessageWithFields } from "../hook-message-injector" import { getTaskToastManager } from "../task-toast-manager" @@ -106,7 +106,7 @@ export async function notifyParentSession(args: { const messagesResp = await client.session.messages({ path: { id: task.parentSessionID }, }) - const raw = (messagesResp as { data?: unknown }).data ?? [] + const raw = normalizeSDKResponse(messagesResp, [] as unknown[]) const messages = Array.isArray(raw) ? raw : [] for (let i = messages.length - 1; i >= 0; i--) { diff --git a/src/features/background-agent/poll-running-tasks.ts b/src/features/background-agent/poll-running-tasks.ts index 023fbf55..e90c73d1 100644 --- a/src/features/background-agent/poll-running-tasks.ts +++ b/src/features/background-agent/poll-running-tasks.ts @@ -1,4 +1,4 @@ -import { log } from "../../shared" +import { log, normalizeSDKResponse } from "../../shared" import { MIN_STABILITY_TIME_MS, @@ -56,7 +56,7 @@ export async function pollRunningTasks(args: { pruneStaleTasksAndNotifications() const statusResult = await client.session.status() - const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap + const allStatuses = normalizeSDKResponse(statusResult, {} as SessionStatusMap) await checkAndInterruptStaleTasks(allStatuses) @@ -95,10 +95,9 @@ export async function pollRunningTasks(args: { continue } - const messagesPayload = Array.isArray(messagesResult) - ? messagesResult - : (messagesResult as { data?: unknown }).data - const messages = asSessionMessages(messagesPayload) + const messages = asSessionMessages(normalizeSDKResponse(messagesResult, [] as SessionMessage[], { + preferResponseOnMissingData: true, + })) const assistantMsgs = messages.filter((m) => m.info?.role === "assistant") let toolCalls = 0 @@ -139,7 +138,7 @@ export async function pollRunningTasks(args: { task.stablePolls = (task.stablePolls ?? 0) + 1 if (task.stablePolls >= 3) { const recheckStatus = await client.session.status() - const recheckData = ((recheckStatus as { data?: unknown }).data ?? {}) as SessionStatusMap + const recheckData = normalizeSDKResponse(recheckStatus, {} as SessionStatusMap) const currentStatus = recheckData[sessionID] if (currentStatus?.type !== "idle") { diff --git a/src/features/background-agent/session-validator.ts b/src/features/background-agent/session-validator.ts index 6181dec9..fe8a7f8a 100644 --- a/src/features/background-agent/session-validator.ts +++ b/src/features/background-agent/session-validator.ts @@ -1,4 +1,4 @@ -import { log } from "../../shared" +import { log, normalizeSDKResponse } from "../../shared" import type { OpencodeClient } from "./opencode-client" @@ -51,7 +51,9 @@ export async function validateSessionHasOutput( path: { id: sessionID }, }) - const messages = asSessionMessages((response as { data?: unknown }).data ?? response) + const messages = asSessionMessages(normalizeSDKResponse(response, [] as SessionMessage[], { + preferResponseOnMissingData: true, + })) const hasAssistantOrToolMessage = messages.some( (m) => m.info?.role === "assistant" || m.info?.role === "tool" @@ -97,8 +99,9 @@ export async function checkSessionTodos( path: { id: sessionID }, }) - const raw = (response as { data?: unknown }).data ?? response - const todos = Array.isArray(raw) ? (raw as Todo[]) : [] + const todos = normalizeSDKResponse(response, [] as Todo[], { + preferResponseOnMissingData: true, + }) if (todos.length === 0) return false const incomplete = todos.filter( diff --git a/src/features/hook-message-injector/index.ts b/src/features/hook-message-injector/index.ts index 2c8a91e6..e8b4ede4 100644 --- a/src/features/hook-message-injector/index.ts +++ b/src/features/hook-message-injector/index.ts @@ -4,6 +4,7 @@ export { findFirstMessageWithAgent, findNearestMessageWithFieldsFromSDK, findFirstMessageWithAgentFromSDK, + resolveMessageContext, } from "./injector" export type { StoredMessage } from "./injector" export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index 1b77997d..1acc72d3 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -5,6 +5,8 @@ import { MESSAGE_STORAGE, PART_STORAGE } from "./constants" import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" import { log } from "../../shared/logger" import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { getMessageDir } from "../../shared/opencode-message-dir" +import { normalizeSDKResponse } from "../../shared" export interface StoredMessage { agent?: string @@ -64,7 +66,7 @@ export async function findNearestMessageWithFieldsFromSDK( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? [] + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) for (let i = messages.length - 1; i >= 0; i--) { const stored = convertSDKMessageToStoredMessage(messages[i]) @@ -97,7 +99,7 @@ export async function findFirstMessageWithAgentFromSDK( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? [] + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) for (const msg of messages) { const stored = convertSDKMessageToStoredMessage(msg) @@ -354,3 +356,21 @@ export function injectHookMessage( return false } } + +export async function resolveMessageContext( + sessionID: string, + client: OpencodeClient, + messageDir: string | null +): Promise<{ prevMessage: StoredMessage | null; firstMessageAgent: string | null }> { + const [prevMessage, firstMessageAgent] = isSqliteBackend() + ? await Promise.all([ + findNearestMessageWithFieldsFromSDK(client, sessionID), + findFirstMessageWithAgentFromSDK(client, sessionID), + ]) + : [ + messageDir ? findNearestMessageWithFields(messageDir) : null, + messageDir ? findFirstMessageWithAgent(messageDir) : null, + ] + + return { prevMessage, firstMessageAgent } +} diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 5bd8d6e8..e25223a3 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" import type { TrackedSession, CapacityConfig } from "./types" +import { log, normalizeSDKResponse } from "../../shared" import { isInsideTmux as defaultIsInsideTmux, getCurrentPaneId as defaultGetCurrentPaneId, @@ -9,7 +10,6 @@ import { SESSION_READY_POLL_INTERVAL_MS, SESSION_READY_TIMEOUT_MS, } from "../../shared/tmux" -import { log } from "../../shared" import { queryWindowState } from "./pane-state-querier" import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine" import { executeActions, executeAction } from "./action-executor" @@ -103,7 +103,7 @@ export class TmuxSessionManager { while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) { try { const statusResult = await this.client.session.status({ path: undefined }) - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) if (allStatuses[sessionId]) { log("[tmux-session-manager] session ready", { diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts index 0a73cdc7..3d8492da 100644 --- a/src/features/tmux-subagent/polling-manager.ts +++ b/src/features/tmux-subagent/polling-manager.ts @@ -3,6 +3,7 @@ import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux" import type { TrackedSession } from "./types" import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux" import { log } from "../../shared" +import { normalizeSDKResponse } from "../../shared" const SESSION_TIMEOUT_MS = 10 * 60 * 1000 const MIN_STABILITY_TIME_MS = 10 * 1000 @@ -43,7 +44,7 @@ export class TmuxPollingManager { try { const statusResult = await this.client.session.status({ path: undefined }) - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) log("[tmux-session-manager] pollSessions", { trackedSessions: Array.from(this.sessions.keys()), @@ -82,7 +83,7 @@ export class TmuxPollingManager { if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) { const recheckResult = await this.client.session.status({ path: undefined }) - const recheckStatuses = (recheckResult.data ?? {}) as Record + const recheckStatuses = normalizeSDKResponse(recheckResult, {} as Record) const recheckStatus = recheckStatuses[sessionId] if (recheckStatus?.type === "idle") { diff --git a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts index 2c159486..29a8d394 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts @@ -61,7 +61,7 @@ export async function runAggressiveTruncationStrategy(params: { clearSessionState(params.autoCompactState, params.sessionID) setTimeout(async () => { try { - await params.client.session.prompt_async({ + await params.client.session.promptAsync({ path: { id: params.sessionID }, body: { auto: true } as never, query: { directory: params.directory }, diff --git a/src/hooks/anthropic-context-window-limit-recovery/client.ts b/src/hooks/anthropic-context-window-limit-recovery/client.ts index c323dafe..0ecaa263 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/client.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/client.ts @@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" export type Client = PluginInput["client"] & { session: { - prompt_async: (opts: { + promptAsync: (opts: { path: { id: string } body: { parts: Array<{ type: string; text: string }> } query: { directory: string } diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts new file mode 100644 index 00000000..e7d0e8ee --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test" +import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk" + +const mockReplaceEmptyTextParts = mock(() => Promise.resolve(false)) +const mockInjectTextPart = mock(() => Promise.resolve(false)) + +mock.module("../session-recovery/storage/empty-text", () => ({ + replaceEmptyTextPartsAsync: mockReplaceEmptyTextParts, +})) +mock.module("../session-recovery/storage/text-part-injector", () => ({ + injectTextPartAsync: mockInjectTextPart, +})) + +function createMockClient(messages: Array<{ info?: { id?: string }; parts?: Array<{ type?: string; text?: string }> }>) { + return { + session: { + messages: mock(() => Promise.resolve({ data: messages })), + }, + } as never +} + +describe("fixEmptyMessagesWithSDK", () => { + beforeEach(() => { + mockReplaceEmptyTextParts.mockReset() + mockInjectTextPart.mockReset() + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false)) + mockInjectTextPart.mockReturnValue(Promise.resolve(false)) + }) + + it("returns fixed=false when no empty messages exist", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "text", text: "Hello" }] }, + ]) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(false) + expect(result.fixedMessageIds).toEqual([]) + expect(result.scannedEmptyCount).toBe(0) + }) + + it("fixes empty message via replace when scanning all", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "text", text: "" }] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + expect(result.scannedEmptyCount).toBe(1) + }) + + it("falls back to inject when replace fails", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false)) + mockInjectTextPart.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + }) + + it("fixes target message by index when provided", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_0" }, parts: [{ type: "text", text: "ok" }] }, + { info: { id: "msg_1" }, parts: [] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + messageIndex: 1, + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + expect(result.scannedEmptyCount).toBe(0) + }) + + it("skips messages without info.id", async () => { + //#given + const client = createMockClient([ + { parts: [] }, + { info: {}, parts: [] }, + ]) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(false) + expect(result.scannedEmptyCount).toBe(0) + }) + + it("treats thinking-only messages as empty", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "thinking", text: "hmm" }] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + }) + + it("treats tool_use messages as non-empty", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "tool_use" }] }, + ]) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(false) + expect(result.scannedEmptyCount).toBe(0) + }) +}) diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts index 4c2f2d2d..8efb76de 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts @@ -99,7 +99,7 @@ describe("executeCompact lock management", () => { messages: mock(() => Promise.resolve({ data: [] })), summarize: mock(() => Promise.resolve()), revert: mock(() => Promise.resolve()), - prompt_async: mock(() => Promise.resolve()), + promptAsync: mock(() => Promise.resolve()), }, tui: { showToast: mock(() => Promise.resolve()), @@ -283,9 +283,9 @@ describe("executeCompact lock management", () => { expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) }) - test("clears lock when prompt_async in continuation throws", async () => { - // given: prompt_async will fail during continuation - mockClient.session.prompt_async = mock(() => + test("clears lock when promptAsync in continuation throws", async () => { + // given: promptAsync will fail during continuation + mockClient.session.promptAsync = mock(() => Promise.reject(new Error("Prompt failed")), ) autoCompactState.errorDataBySession.set(sessionID, { @@ -378,8 +378,8 @@ describe("executeCompact lock management", () => { // then: Summarize should NOT be called (early return from sufficient truncation) expect(mockClient.session.summarize).not.toHaveBeenCalled() - // then: prompt_async should be called (Continue after successful truncation) - expect(mockClient.session.prompt_async).toHaveBeenCalled() + // then: promptAsync should be called (Continue after successful truncation) + expect(mockClient.session.promptAsync).toHaveBeenCalled() // then: Lock should be cleared expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false) diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts index bcfe9434..17f24220 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts @@ -1,5 +1,6 @@ import { log } from "../../shared/logger" import type { PluginInput } from "@opencode-ai/plugin" +import { normalizeSDKResponse } from "../../shared" import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { findEmptyMessages, @@ -64,7 +65,7 @@ async function findEmptyMessageIdsFromSDK( const response = (await client.session.messages({ path: { id: sessionID }, })) as { data?: SDKMessage[] } - const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? [] + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) const emptyIds: string[] = [] for (const message of messages) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts index e8c5587b..f4a7e576 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync } from "node:fs" import type { PluginInput } from "@opencode-ai/plugin" import { getMessageDir } from "../../shared/opencode-message-dir" +import { normalizeSDKResponse } from "../../shared" export { getMessageDir } @@ -17,7 +18,7 @@ export async function getMessageIdsFromSDK( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? [] + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) return messages.map(msg => msg.info.id) } catch { return [] diff --git a/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts b/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts index b44db121..ef1a761c 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts @@ -6,6 +6,7 @@ import { estimateTokens } from "./pruning-types" import { log } from "../../shared/logger" import { getMessageDir } from "../../shared/opencode-message-dir" import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { normalizeSDKResponse } from "../../shared" type OpencodeClient = PluginInput["client"] @@ -72,7 +73,7 @@ function readMessages(sessionID: string): MessagePart[] { async function readMessagesFromSDK(client: OpencodeClient, sessionID: string): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const rawMessages = ((response.data ?? response) as unknown as Array<{ parts?: ToolPart[] }>) ?? [] + const rawMessages = normalizeSDKResponse(response, [] as Array<{ parts?: ToolPart[] }>, { preferResponseOnMissingData: true }) return rawMessages.filter((m) => m.parts) as MessagePart[] } catch { return [] diff --git a/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts b/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts index 27dcc7f6..4c3741aa 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts @@ -7,6 +7,7 @@ import { truncateToolResultAsync } from "./tool-result-storage-sdk" import { log } from "../../shared/logger" import { getMessageDir } from "../../shared/opencode-message-dir" import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { normalizeSDKResponse } from "../../shared" type OpencodeClient = PluginInput["client"] @@ -108,7 +109,7 @@ async function truncateToolOutputsByCallIdFromSDK( ): Promise<{ truncatedCount: number }> { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? [] + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) let truncatedCount = 0 for (const msg of messages) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts index 2e877277..65db7298 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts @@ -53,7 +53,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => { messages: mock(() => Promise.resolve({ data: [] })), summarize: mock(() => summarizePromise), revert: mock(() => Promise.resolve()), - prompt_async: mock(() => Promise.resolve()), + promptAsync: mock(() => Promise.resolve()), }, tui: { showToast: mock(() => Promise.resolve()), @@ -97,7 +97,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => { messages: mock(() => Promise.resolve({ data: [] })), summarize: mock(() => Promise.resolve()), revert: mock(() => Promise.resolve()), - prompt_async: mock(() => Promise.resolve()), + promptAsync: mock(() => Promise.resolve()), }, tui: { showToast: mock(() => Promise.resolve()), diff --git a/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts index 9da17f3a..f7d8dff9 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts @@ -3,6 +3,7 @@ import type { AggressiveTruncateResult } from "./tool-part-types" import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage" import { truncateToolResultAsync } from "./tool-result-storage-sdk" import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { normalizeSDKResponse } from "../../shared" type OpencodeClient = PluginInput["client"] @@ -66,7 +67,7 @@ export async function truncateUntilTargetTokens( const response = (await client.session.messages({ path: { id: sessionID }, })) as { data?: SDKMessage[] } - const messages = (response.data ?? response) as SDKMessage[] + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) toolPartsByKey = new Map() for (const message of messages) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts index 24df37d0..c163a636 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts @@ -4,6 +4,7 @@ import { TRUNCATION_MESSAGE } from "./storage-paths" import type { ToolResultInfo } from "./tool-part-types" import { patchPart } from "../../shared/opencode-http-api" import { log } from "../../shared/logger" +import { normalizeSDKResponse } from "../../shared" type OpencodeClient = PluginInput["client"] @@ -32,7 +33,7 @@ export async function findToolResultsBySizeFromSDK( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? [] + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) const results: ToolResultInfo[] = [] for (const msg of messages) { @@ -98,7 +99,7 @@ export async function countTruncatedResultsFromSDK( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? [] + const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) let count = 0 for (const msg of messages) { diff --git a/src/hooks/atlas/recent-model-resolver.ts b/src/hooks/atlas/recent-model-resolver.ts index a8509c32..ba6018b2 100644 --- a/src/hooks/atlas/recent-model-resolver.ts +++ b/src/hooks/atlas/recent-model-resolver.ts @@ -3,7 +3,7 @@ import { findNearestMessageWithFields, findNearestMessageWithFieldsFromSDK, } from "../../features/hook-message-injector" -import { getMessageDir, isSqliteBackend } from "../../shared" +import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared" import type { ModelInfo } from "./types" export async function resolveRecentModelForSession( @@ -12,9 +12,9 @@ export async function resolveRecentModelForSession( ): Promise { try { const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: { model?: ModelInfo; modelID?: string; providerID?: string } - }> + }>) for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts index 84af442f..d476fb26 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -3,6 +3,7 @@ import { log } from "../../shared/logger" import { findNearestMessageWithFields } from "../../features/hook-message-injector" import { getMessageDir } from "./message-storage-directory" import { withTimeout } from "./with-timeout" +import { normalizeSDKResponse } from "../../shared" type MessageInfo = { agent?: string @@ -25,7 +26,7 @@ export async function injectContinuationPrompt( }), options.apiTimeoutMs, ) - const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }> + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i]?.info if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { diff --git a/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts b/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts new file mode 100644 index 00000000..acf178ec --- /dev/null +++ b/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test" +import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk" +import type { MessageData } from "./types" + +function createMockClient(messages: MessageData[]) { + return { + session: { + messages: mock(() => Promise.resolve({ data: messages })), + }, + } as never +} + +function createDeps(overrides?: Partial[4]>) { + return { + placeholderText: "[recovered]", + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)), + injectTextPartAsync: mock(() => Promise.resolve(false)), + findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve([] as string[])), + ...overrides, + } +} + +const emptyMsg: MessageData = { info: { id: "msg_1", role: "assistant" }, parts: [] } +const contentMsg: MessageData = { info: { id: "msg_2", role: "assistant" }, parts: [{ type: "text", text: "Hello" }] } +const thinkingOnlyMsg: MessageData = { info: { id: "msg_3", role: "assistant" }, parts: [{ type: "thinking", text: "hmm" }] } + +describe("recoverEmptyContentMessageFromSDK", () => { + it("returns false when no empty messages exist", async () => { + //#given + const client = createMockClient([contentMsg]) + const deps = createDeps() + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", contentMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(false) + }) + + it("fixes messages with empty text parts via replace", async () => { + //#given + const client = createMockClient([emptyMsg]) + const deps = createDeps({ + findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve(["msg_1"])), + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", emptyMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + }) + + it("injects text part into thinking-only messages", async () => { + //#given + const client = createMockClient([thinkingOnlyMsg]) + const deps = createDeps({ + injectTextPartAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", thinkingOnlyMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + expect(deps.injectTextPartAsync).toHaveBeenCalledWith( + client, "ses_1", "msg_3", "[recovered]", + ) + }) + + it("targets message by index from error", async () => { + //#given + const client = createMockClient([contentMsg, emptyMsg]) + const error = new Error("messages: index 1 has empty content") + const deps = createDeps({ + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", emptyMsg, error, deps, + ) + + //#then + expect(result).toBe(true) + }) + + it("falls back to failedID when targetIndex fix fails", async () => { + //#given + const failedMsg: MessageData = { info: { id: "msg_fail" }, parts: [] } + const client = createMockClient([contentMsg]) + const deps = createDeps({ + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)), + injectTextPartAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", failedMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + expect(deps.injectTextPartAsync).toHaveBeenCalledWith( + client, "ses_1", "msg_fail", "[recovered]", + ) + }) + + it("returns false when SDK throws during message read", async () => { + //#given + const client = { session: { messages: mock(() => Promise.reject(new Error("SDK error"))) } } as never + const deps = createDeps() + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", emptyMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(false) + }) + + it("scans all empty messages when no target index available", async () => { + //#given + const empty1: MessageData = { info: { id: "e1" }, parts: [] } + const empty2: MessageData = { info: { id: "e2" }, parts: [] } + const client = createMockClient([empty1, empty2]) + const replaceMock = mock(() => Promise.resolve(true)) + const deps = createDeps({ replaceEmptyTextPartsAsync: replaceMock }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", empty1, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + }) +}) diff --git a/src/hooks/session-recovery/recover-empty-content-message-sdk.ts b/src/hooks/session-recovery/recover-empty-content-message-sdk.ts index 8766f0c7..ee6ab54e 100644 --- a/src/hooks/session-recovery/recover-empty-content-message-sdk.ts +++ b/src/hooks/session-recovery/recover-empty-content-message-sdk.ts @@ -2,6 +2,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk" import type { MessageData } from "./types" import { extractMessageIndex } from "./detect-error-type" import { META_TYPES, THINKING_TYPES } from "./constants" +import { normalizeSDKResponse } from "../../shared" type Client = ReturnType @@ -136,7 +137,7 @@ function sdkMessageHasContent(message: MessageData): boolean { async function readMessagesFromSDK(client: Client, sessionID: string): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - return ((response.data ?? response) as unknown as MessageData[]) ?? [] + return normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) } catch { return [] } diff --git a/src/hooks/session-recovery/recover-thinking-block-order.ts b/src/hooks/session-recovery/recover-thinking-block-order.ts index b8bbe04d..cd62b97c 100644 --- a/src/hooks/session-recovery/recover-thinking-block-order.ts +++ b/src/hooks/session-recovery/recover-thinking-block-order.ts @@ -5,6 +5,7 @@ import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prep import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { prependThinkingPartAsync } from "./storage/thinking-prepend" import { THINKING_TYPES } from "./constants" +import { normalizeSDKResponse } from "../../shared" type Client = ReturnType @@ -77,7 +78,7 @@ async function findMessagesWithOrphanThinkingFromSDK( let messages: MessageData[] try { const response = await client.session.messages({ path: { id: sessionID } }) - messages = ((response.data ?? response) as unknown as MessageData[]) ?? [] + messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) } catch { return [] } @@ -111,7 +112,7 @@ async function findMessageByIndexNeedingThinkingFromSDK( let messages: MessageData[] try { const response = await client.session.messages({ path: { id: sessionID } }) - messages = ((response.data ?? response) as unknown as MessageData[]) ?? [] + messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) } catch { return null } diff --git a/src/hooks/session-recovery/recover-thinking-disabled-violation.ts b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts index d569d37f..751d9535 100644 --- a/src/hooks/session-recovery/recover-thinking-disabled-violation.ts +++ b/src/hooks/session-recovery/recover-thinking-disabled-violation.ts @@ -5,6 +5,7 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { stripThinkingPartsAsync } from "./storage/thinking-strip" import { THINKING_TYPES } from "./constants" import { log } from "../../shared/logger" +import { normalizeSDKResponse } from "../../shared" type Client = ReturnType @@ -38,7 +39,7 @@ async function recoverThinkingDisabledViolationFromSDK( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as MessageData[]) ?? [] + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) const messageIDsWithThinking: string[] = [] for (const msg of messages) { diff --git a/src/hooks/session-recovery/recover-tool-result-missing.ts b/src/hooks/session-recovery/recover-tool-result-missing.ts index 26e6724a..a1121fc6 100644 --- a/src/hooks/session-recovery/recover-tool-result-missing.ts +++ b/src/hooks/session-recovery/recover-tool-result-missing.ts @@ -2,6 +2,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk" import type { MessageData } from "./types" import { readParts } from "./storage" import { isSqliteBackend } from "../../shared/opencode-storage-detection" +import { normalizeSDKResponse } from "../../shared" type Client = ReturnType @@ -28,7 +29,7 @@ async function readPartsFromSDKFallback( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as MessageData[]) ?? [] + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) const target = messages.find((m) => m.info?.id === messageID) if (!target?.parts) return [] diff --git a/src/hooks/session-recovery/storage/empty-text.ts b/src/hooks/session-recovery/storage/empty-text.ts index 6ddd1fac..c9aa3493 100644 --- a/src/hooks/session-recovery/storage/empty-text.ts +++ b/src/hooks/session-recovery/storage/empty-text.ts @@ -6,6 +6,7 @@ import type { StoredPart, StoredTextPart, MessageData } from "../types" import { readMessages } from "./messages-reader" import { readParts } from "./parts-reader" import { log, isSqliteBackend, patchPart } from "../../../shared" +import { normalizeSDKResponse } from "../../../shared" type OpencodeClient = PluginInput["client"] @@ -51,7 +52,7 @@ export async function replaceEmptyTextPartsAsync( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as MessageData[]) ?? [] + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) const targetMsg = messages.find((m) => m.info?.id === messageID) if (!targetMsg?.parts) return false @@ -101,7 +102,7 @@ export async function findMessagesWithEmptyTextPartsFromSDK( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as MessageData[]) ?? [] + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) const result: string[] = [] for (const msg of messages) { diff --git a/src/hooks/session-recovery/storage/messages-reader.ts b/src/hooks/session-recovery/storage/messages-reader.ts index 7e21ad7f..ecedf240 100644 --- a/src/hooks/session-recovery/storage/messages-reader.ts +++ b/src/hooks/session-recovery/storage/messages-reader.ts @@ -3,7 +3,7 @@ import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import type { StoredMessageMeta } from "../types" import { getMessageDir } from "./message-dir" -import { isSqliteBackend } from "../../../shared" +import { isSqliteBackend, normalizeSDKResponse } from "../../../shared" import { isRecord } from "../../../shared/record-type-guard" type OpencodeClient = PluginInput["client"] @@ -62,7 +62,9 @@ export async function readMessagesFromSDK( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const data: unknown = response.data ?? response + const data = normalizeSDKResponse(response, [] as unknown[], { + preferResponseOnMissingData: true, + }) if (!Array.isArray(data)) return [] const messages = data diff --git a/src/hooks/session-recovery/storage/thinking-prepend.ts b/src/hooks/session-recovery/storage/thinking-prepend.ts index 13feabf7..464898c9 100644 --- a/src/hooks/session-recovery/storage/thinking-prepend.ts +++ b/src/hooks/session-recovery/storage/thinking-prepend.ts @@ -6,6 +6,7 @@ import type { MessageData } from "../types" import { readMessages } from "./messages-reader" import { readParts } from "./parts-reader" import { log, isSqliteBackend, patchPart } from "../../../shared" +import { normalizeSDKResponse } from "../../../shared" type OpencodeClient = PluginInput["client"] @@ -74,7 +75,7 @@ async function findLastThinkingContentFromSDK( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as MessageData[]) ?? [] + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) const currentIndex = messages.findIndex((m) => m.info?.id === beforeMessageID) if (currentIndex === -1) return "" diff --git a/src/hooks/session-recovery/storage/thinking-strip.ts b/src/hooks/session-recovery/storage/thinking-strip.ts index 67c58da6..518ef1b0 100644 --- a/src/hooks/session-recovery/storage/thinking-strip.ts +++ b/src/hooks/session-recovery/storage/thinking-strip.ts @@ -4,6 +4,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { PART_STORAGE, THINKING_TYPES } from "../constants" import type { StoredPart } from "../types" import { log, isSqliteBackend, deletePart } from "../../../shared" +import { normalizeSDKResponse } from "../../../shared" type OpencodeClient = PluginInput["client"] @@ -42,7 +43,7 @@ export async function stripThinkingPartsAsync( ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = ((response.data ?? response) as unknown as Array<{ parts?: Array<{ type: string; id: string }> }>) ?? [] + const messages = normalizeSDKResponse(response, [] as Array<{ parts?: Array<{ type: string; id: string }> }>, { preferResponseOnMissingData: true }) const targetMsg = messages.find((m) => { const info = (m as Record)["info"] as Record | undefined diff --git a/src/hooks/session-todo-status.ts b/src/hooks/session-todo-status.ts index cb2a28f2..c86752fe 100644 --- a/src/hooks/session-todo-status.ts +++ b/src/hooks/session-todo-status.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { normalizeSDKResponse } from "../shared" interface Todo { content: string @@ -10,7 +11,7 @@ interface Todo { export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise { try { const response = await ctx.client.session.todo({ path: { id: sessionID } }) - const todos = (response.data ?? response) as Todo[] + const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true }) if (!todos || todos.length === 0) return false return todos.some((todo) => todo.status !== "completed" && todo.status !== "cancelled") } catch { diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index ded4ad3d..e9c36b47 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" +import { normalizeSDKResponse } from "../../shared" import { findNearestMessageWithFields, findNearestMessageWithFieldsFromSDK, @@ -63,7 +64,7 @@ export async function injectContinuation(args: { let todos: Todo[] = [] try { const response = await ctx.client.session.todo({ path: { id: sessionID } }) - todos = (response.data ?? response) as Todo[] + todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true }) } catch (error) { log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) }) return diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index cb039b69..d97a9b6b 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" import type { ToolPermission } from "../../features/hook-message-injector" +import { normalizeSDKResponse } from "../../shared" import { log } from "../../shared/logger" import { @@ -67,7 +68,7 @@ export async function handleSessionIdle(args: { path: { id: sessionID }, query: { directory: ctx.directory }, }) - const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? [] + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) if (isLastAssistantMessageAborted(messages)) { log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) return @@ -79,7 +80,7 @@ export async function handleSessionIdle(args: { let todos: Todo[] = [] try { const response = await ctx.client.session.todo({ path: { id: sessionID } }) - todos = (response.data ?? response) as Todo[] + todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true }) } catch (error) { log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(error) }) return @@ -139,7 +140,7 @@ export async function handleSessionIdle(args: { const messagesResp = await ctx.client.session.messages({ path: { id: sessionID }, }) - const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }> + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>) for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info if (info?.agent === "compaction") { diff --git a/src/plugin/session-agent-resolver.ts b/src/plugin/session-agent-resolver.ts index 8d9837ff..6cc12b8c 100644 --- a/src/plugin/session-agent-resolver.ts +++ b/src/plugin/session-agent-resolver.ts @@ -1,4 +1,5 @@ import { log } from "../shared" +import { normalizeSDKResponse } from "../shared" interface SessionMessage { info?: { @@ -19,7 +20,7 @@ export async function resolveSessionAgent( ): Promise { try { const messagesResp = await client.session.messages({ path: { id: sessionId } }) - const messages = (messagesResp.data ?? []) as SessionMessage[] + const messages = normalizeSDKResponse(messagesResp, [] as SessionMessage[]) for (const msg of messages) { if (msg.info?.agent) { diff --git a/src/shared/available-models-fetcher.ts b/src/shared/available-models-fetcher.ts index b19defce..790ad77e 100644 --- a/src/shared/available-models-fetcher.ts +++ b/src/shared/available-models-fetcher.ts @@ -2,6 +2,7 @@ import { addModelsFromModelsJsonCache } from "./models-json-cache-reader" import { getModelListFunction, getProviderListFunction } from "./open-code-client-accessors" import { addModelsFromProviderModelsCache } from "./provider-models-cache-model-reader" import { log } from "./logger" +import { normalizeSDKResponse } from "./normalize-sdk-response" export async function getConnectedProviders(client: unknown): Promise { const providerList = getProviderListFunction(client) @@ -53,7 +54,7 @@ export async function fetchAvailableModels( const modelSet = new Set() try { const modelsResult = await modelList() - const models = modelsResult.data ?? [] + const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>) for (const model of models) { if (model.provider && model.id) { modelSet.add(`${model.provider}/${model.id}`) @@ -92,7 +93,7 @@ export async function fetchAvailableModels( if (modelList) { try { const modelsResult = await modelList() - const models = modelsResult.data ?? [] + const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>) for (const model of models) { if (!model.provider || !model.id) continue diff --git a/src/shared/dynamic-truncator.ts b/src/shared/dynamic-truncator.ts index 017bca16..dbd90466 100644 --- a/src/shared/dynamic-truncator.ts +++ b/src/shared/dynamic-truncator.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin"; +import { normalizeSDKResponse } from "./normalize-sdk-response" const ANTHROPIC_ACTUAL_LIMIT = process.env.ANTHROPIC_1M_CONTEXT === "true" || @@ -119,7 +120,7 @@ export async function getContextWindowUsage( path: { id: sessionID }, }); - const messages = (response.data ?? response) as MessageWrapper[]; + const messages = normalizeSDKResponse(response, [] as MessageWrapper[], { preferResponseOnMissingData: true }) const assistantMessages = messages .filter((m) => m.info.role === "assistant") diff --git a/src/shared/index.ts b/src/shared/index.ts index cbee9bf4..85a62b83 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -53,3 +53,4 @@ export * from "./safe-create-hook" export * from "./truncate-description" export * from "./opencode-storage-paths" export * from "./opencode-message-dir" +export * from "./normalize-sdk-response" diff --git a/src/shared/model-availability.ts b/src/shared/model-availability.ts index 1ff696ee..0943ce85 100644 --- a/src/shared/model-availability.ts +++ b/src/shared/model-availability.ts @@ -3,6 +3,7 @@ import { join } from "path" import { log } from "./logger" import { getOpenCodeCacheDir } from "./data-path" import * as connectedProvidersCache from "./connected-providers-cache" +import { normalizeSDKResponse } from "./normalize-sdk-response" /** * Fuzzy match a target model name against available models @@ -159,7 +160,7 @@ export async function fetchAvailableModels( const modelSet = new Set() try { const modelsResult = await client.model.list() - const models = modelsResult.data ?? [] + const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>) for (const model of models) { if (model?.provider && model?.id) { modelSet.add(`${model.provider}/${model.id}`) @@ -261,7 +262,7 @@ export async function fetchAvailableModels( if (client?.model?.list) { try { const modelsResult = await client.model.list() - const models = modelsResult.data ?? [] + const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>) for (const model of models) { if (!model?.provider || !model?.id) continue diff --git a/src/shared/normalize-sdk-response.test.ts b/src/shared/normalize-sdk-response.test.ts new file mode 100644 index 00000000..870519d7 --- /dev/null +++ b/src/shared/normalize-sdk-response.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test" +import { normalizeSDKResponse } from "./normalize-sdk-response" + +describe("normalizeSDKResponse", () => { + it("returns data array when response includes data", () => { + //#given + const response = { data: [{ id: "1" }] } + + //#when + const result = normalizeSDKResponse(response, [] as Array<{ id: string }>) + + //#then + expect(result).toEqual([{ id: "1" }]) + }) + + it("returns fallback array when data is missing", () => { + //#given + const response = {} + const fallback = [{ id: "fallback" }] + + //#when + const result = normalizeSDKResponse(response, fallback) + + //#then + expect(result).toEqual(fallback) + }) + + it("returns response array directly when SDK returns plain array", () => { + //#given + const response = [{ id: "2" }] + + //#when + const result = normalizeSDKResponse(response, [] as Array<{ id: string }>) + + //#then + expect(result).toEqual([{ id: "2" }]) + }) + + it("returns response when data missing and preferResponseOnMissingData is true", () => { + //#given + const response = { value: "legacy" } + + //#when + const result = normalizeSDKResponse(response, { value: "fallback" }, { preferResponseOnMissingData: true }) + + //#then + expect(result).toEqual({ value: "legacy" }) + }) + + it("returns fallback for null response", () => { + //#given + const response = null + + //#when + const result = normalizeSDKResponse(response, [] as string[]) + + //#then + expect(result).toEqual([]) + }) + + it("returns object fallback for direct data nullish pattern", () => { + //#given + const response = { data: undefined as { connected: string[] } | undefined } + const fallback = { connected: [] } + + //#when + const result = normalizeSDKResponse(response, fallback) + + //#then + expect(result).toEqual(fallback) + }) +}) diff --git a/src/shared/normalize-sdk-response.ts b/src/shared/normalize-sdk-response.ts new file mode 100644 index 00000000..080cc992 --- /dev/null +++ b/src/shared/normalize-sdk-response.ts @@ -0,0 +1,36 @@ +export interface NormalizeSDKResponseOptions { + preferResponseOnMissingData?: boolean +} + +export function normalizeSDKResponse( + response: unknown, + fallback: TData, + options?: NormalizeSDKResponseOptions, +): TData { + if (response === null || response === undefined) { + return fallback + } + + if (Array.isArray(response)) { + return response as TData + } + + if (typeof response === "object" && "data" in response) { + const data = (response as { data?: unknown }).data + if (data !== null && data !== undefined) { + return data as TData + } + + if (options?.preferResponseOnMissingData === true) { + return response as TData + } + + return fallback + } + + if (options?.preferResponseOnMissingData === true) { + return response as TData + } + + return fallback +} diff --git a/src/shared/opencode-http-api.ts b/src/shared/opencode-http-api.ts index 618224a7..451d98e6 100644 --- a/src/shared/opencode-http-api.ts +++ b/src/shared/opencode-http-api.ts @@ -81,7 +81,7 @@ export async function patchPart( "Authorization": auth, }, body: JSON.stringify(body), - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(10_000), }) if (!response.ok) { @@ -123,7 +123,7 @@ export async function deletePart( headers: { "Authorization": auth, }, - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(10_000), }) if (!response.ok) { diff --git a/src/shared/opencode-storage-detection.test.ts b/src/shared/opencode-storage-detection.test.ts index 98792010..12238e50 100644 --- a/src/shared/opencode-storage-detection.test.ts +++ b/src/shared/opencode-storage-detection.test.ts @@ -15,15 +15,27 @@ const SQLITE_VERSION = "1.1.53" // Other files (e.g., opencode-message-dir.test.ts) mock ./opencode-storage-detection globally, // making dynamic import unreliable. By inlining, we test the actual logic with controlled deps. const NOT_CACHED = Symbol("NOT_CACHED") -let cachedResult: boolean | typeof NOT_CACHED = NOT_CACHED +const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY") +let cachedResult: true | false | typeof NOT_CACHED | typeof FALSE_PENDING_RETRY = NOT_CACHED function isSqliteBackend(): boolean { - if (cachedResult !== NOT_CACHED) return cachedResult as boolean + if (cachedResult === true) return true + if (cachedResult === false) return false + if (cachedResult === FALSE_PENDING_RETRY) { + const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })() + const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db") + const dbExists = existsSync(dbPath) + const result = versionOk && dbExists + cachedResult = result + return result + } const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })() const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db") const dbExists = existsSync(dbPath) - cachedResult = versionOk && dbExists - return cachedResult + const result = versionOk && dbExists + if (result) { cachedResult = true } + else { cachedResult = FALSE_PENDING_RETRY } + return result } function resetSqliteBackendCache(): void { @@ -77,7 +89,7 @@ describe("isSqliteBackend", () => { expect(versionCheckCalls).toContain("1.1.53") }) - it("caches the result and does not re-check on subsequent calls", () => { + it("caches true permanently and does not re-check", () => { //#given versionReturnValue = true mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true }) @@ -91,4 +103,59 @@ describe("isSqliteBackend", () => { //#then expect(versionCheckCalls.length).toBe(1) }) + + it("retries once when first result is false, then caches permanently", () => { + //#given + versionReturnValue = true + + //#when: first call — DB does not exist + const first = isSqliteBackend() + + //#then + expect(first).toBe(false) + expect(versionCheckCalls.length).toBe(1) + + //#when: second call — DB still does not exist (retry) + const second = isSqliteBackend() + + //#then: retried once + expect(second).toBe(false) + expect(versionCheckCalls.length).toBe(2) + + //#when: third call — no more retries + const third = isSqliteBackend() + + //#then: no further checks + expect(third).toBe(false) + expect(versionCheckCalls.length).toBe(2) + }) + + it("recovers on retry when DB appears after first false", () => { + //#given + versionReturnValue = true + + //#when: first call — DB does not exist + const first = isSqliteBackend() + + //#then + expect(first).toBe(false) + + //#given: DB appears before retry + mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true }) + writeFileSync(DB_PATH, "") + + //#when: second call — retry finds DB + const second = isSqliteBackend() + + //#then: recovers to true and caches permanently + expect(second).toBe(true) + expect(versionCheckCalls.length).toBe(2) + + //#when: third call — cached true + const third = isSqliteBackend() + + //#then: no further checks + expect(third).toBe(true) + expect(versionCheckCalls.length).toBe(2) + }) }) \ No newline at end of file diff --git a/src/shared/opencode-storage-detection.ts b/src/shared/opencode-storage-detection.ts index 3e0aa474..930f9e1f 100644 --- a/src/shared/opencode-storage-detection.ts +++ b/src/shared/opencode-storage-detection.ts @@ -4,19 +4,29 @@ import { getDataDir } from "./data-path" import { isOpenCodeVersionAtLeast, OPENCODE_SQLITE_VERSION } from "./opencode-version" const NOT_CACHED = Symbol("NOT_CACHED") -let cachedResult: boolean | typeof NOT_CACHED = NOT_CACHED +const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY") +let cachedResult: true | false | typeof NOT_CACHED | typeof FALSE_PENDING_RETRY = NOT_CACHED export function isSqliteBackend(): boolean { - if (cachedResult !== NOT_CACHED) { - return cachedResult + if (cachedResult === true) return true + if (cachedResult === false) return false + + const check = (): boolean => { + const versionOk = isOpenCodeVersionAtLeast(OPENCODE_SQLITE_VERSION) + const dbPath = join(getDataDir(), "opencode", "opencode.db") + return versionOk && existsSync(dbPath) } - - const versionOk = isOpenCodeVersionAtLeast(OPENCODE_SQLITE_VERSION) - const dbPath = join(getDataDir(), "opencode", "opencode.db") - const dbExists = existsSync(dbPath) - - cachedResult = versionOk && dbExists - return cachedResult + + if (cachedResult === FALSE_PENDING_RETRY) { + const result = check() + cachedResult = result + return result + } + + const result = check() + if (result) { cachedResult = true } + else { cachedResult = FALSE_PENDING_RETRY } + return result } export function resetSqliteBackendCache(): void { diff --git a/src/tools/background-task/create-background-task.ts b/src/tools/background-task/create-background-task.ts index 22adff8c..9da0d5c5 100644 --- a/src/tools/background-task/create-background-task.ts +++ b/src/tools/background-task/create-background-task.ts @@ -2,18 +2,12 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundTaskArgs } from "./types" import { BACKGROUND_TASK_DESCRIPTION } from "./constants" -import { - findFirstMessageWithAgent, - findFirstMessageWithAgentFromSDK, - findNearestMessageWithFields, - findNearestMessageWithFieldsFromSDK, -} from "../../features/hook-message-injector" +import { resolveMessageContext } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { storeToolMetadata } from "../../features/tool-metadata-store" import { log } from "../../shared/logger" import { delay } from "./delay" import { getMessageDir } from "./message-dir" -import { isSqliteBackend } from "../../shared/opencode-storage-detection" type ToolContextWithMetadata = { sessionID: string @@ -44,16 +38,11 @@ export function createBackgroundTask( try { const messageDir = getMessageDir(ctx.sessionID) - - const [prevMessage, firstMessageAgent] = isSqliteBackend() - ? await Promise.all([ - findNearestMessageWithFieldsFromSDK(client, ctx.sessionID), - findFirstMessageWithAgentFromSDK(client, ctx.sessionID), - ]) - : [ - messageDir ? findNearestMessageWithFields(messageDir) : null, - messageDir ? findFirstMessageWithAgent(messageDir) : null, - ] + const { prevMessage, firstMessageAgent } = await resolveMessageContext( + ctx.sessionID, + client, + messageDir + ) const sessionAgent = getSessionAgent(ctx.sessionID) const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent diff --git a/src/tools/call-omo-agent/background-agent-executor.ts b/src/tools/call-omo-agent/background-agent-executor.ts index 9041831f..c09f78df 100644 --- a/src/tools/call-omo-agent/background-agent-executor.ts +++ b/src/tools/call-omo-agent/background-agent-executor.ts @@ -1,18 +1,12 @@ import type { BackgroundManager } from "../../features/background-agent" import type { PluginInput } from "@opencode-ai/plugin" -import { - findFirstMessageWithAgent, - findFirstMessageWithAgentFromSDK, - findNearestMessageWithFields, - findNearestMessageWithFieldsFromSDK, -} from "../../features/hook-message-injector" +import { resolveMessageContext } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared" import type { CallOmoAgentArgs } from "./types" import type { ToolContextWithMetadata } from "./tool-context-with-metadata" import { getMessageDir } from "./message-storage-directory" import { getSessionTools } from "../../shared/session-tools-store" -import { isSqliteBackend } from "../../shared/opencode-storage-detection" export async function executeBackgroundAgent( args: CallOmoAgentArgs, @@ -22,16 +16,11 @@ export async function executeBackgroundAgent( ): Promise { try { const messageDir = getMessageDir(toolContext.sessionID) - - const [prevMessage, firstMessageAgent] = isSqliteBackend() - ? await Promise.all([ - findNearestMessageWithFieldsFromSDK(client, toolContext.sessionID), - findFirstMessageWithAgentFromSDK(client, toolContext.sessionID), - ]) - : [ - messageDir ? findNearestMessageWithFields(messageDir) : null, - messageDir ? findFirstMessageWithAgent(messageDir) : null, - ] + const { prevMessage, firstMessageAgent } = await resolveMessageContext( + toolContext.sessionID, + client, + messageDir + ) const sessionAgent = getSessionAgent(toolContext.sessionID) const parentAgent = diff --git a/src/tools/call-omo-agent/background-executor.ts b/src/tools/call-omo-agent/background-executor.ts index e302bab7..c9eb9ef4 100644 --- a/src/tools/call-omo-agent/background-executor.ts +++ b/src/tools/call-omo-agent/background-executor.ts @@ -3,16 +3,10 @@ import type { BackgroundManager } from "../../features/background-agent" import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared" import { consumeNewMessages } from "../../shared/session-cursor" -import { - findFirstMessageWithAgent, - findFirstMessageWithAgentFromSDK, - findNearestMessageWithFields, - findNearestMessageWithFieldsFromSDK, -} from "../../features/hook-message-injector" +import { resolveMessageContext } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { getMessageDir } from "./message-dir" import { getSessionTools } from "../../shared/session-tools-store" -import { isSqliteBackend } from "../../shared/opencode-storage-detection" export async function executeBackground( args: CallOmoAgentArgs, @@ -28,16 +22,11 @@ export async function executeBackground( ): Promise { try { const messageDir = getMessageDir(toolContext.sessionID) - - const [prevMessage, firstMessageAgent] = isSqliteBackend() - ? await Promise.all([ - findNearestMessageWithFieldsFromSDK(client, toolContext.sessionID), - findFirstMessageWithAgentFromSDK(client, toolContext.sessionID), - ]) - : [ - messageDir ? findNearestMessageWithFields(messageDir) : null, - messageDir ? findFirstMessageWithAgent(messageDir) : null, - ] + const { prevMessage, firstMessageAgent } = await resolveMessageContext( + toolContext.sessionID, + client, + messageDir + ) const sessionAgent = getSessionAgent(toolContext.sessionID) const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent diff --git a/src/tools/call-omo-agent/completion-poller.ts b/src/tools/call-omo-agent/completion-poller.ts index 0ca73e73..61f2829b 100644 --- a/src/tools/call-omo-agent/completion-poller.ts +++ b/src/tools/call-omo-agent/completion-poller.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared" +import { normalizeSDKResponse } from "../../shared" export async function waitForCompletion( sessionID: string, @@ -33,7 +34,7 @@ export async function waitForCompletion( // Check session status const statusResult = await ctx.client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) const sessionStatus = allStatuses[sessionID] // If session is actively running, reset stability counter @@ -45,7 +46,9 @@ export async function waitForCompletion( // Session is idle - check message stability const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } }) - const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const msgs = normalizeSDKResponse(messagesCheck, [] as Array, { + preferResponseOnMissingData: true, + }) const currentMsgCount = msgs.length if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { diff --git a/src/tools/delegate-task/parent-context-resolver.ts b/src/tools/delegate-task/parent-context-resolver.ts index 4a1eda9c..2d831cda 100644 --- a/src/tools/delegate-task/parent-context-resolver.ts +++ b/src/tools/delegate-task/parent-context-resolver.ts @@ -1,32 +1,21 @@ import type { ToolContextWithMetadata } from "./types" import type { OpencodeClient } from "./types" import type { ParentContext } from "./executor-types" -import { - findFirstMessageWithAgent, - findFirstMessageWithAgentFromSDK, - findNearestMessageWithFields, - findNearestMessageWithFieldsFromSDK, -} from "../../features/hook-message-injector" +import { resolveMessageContext } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" import { getMessageDir } from "../../shared/opencode-message-dir" -import { isSqliteBackend } from "../../shared/opencode-storage-detection" export async function resolveParentContext( ctx: ToolContextWithMetadata, client: OpencodeClient ): Promise { const messageDir = getMessageDir(ctx.sessionID) - - const [prevMessage, firstMessageAgent] = isSqliteBackend() - ? await Promise.all([ - findNearestMessageWithFieldsFromSDK(client, ctx.sessionID), - findFirstMessageWithAgentFromSDK(client, ctx.sessionID), - ]) - : [ - messageDir ? findNearestMessageWithFields(messageDir) : null, - messageDir ? findFirstMessageWithAgent(messageDir) : null, - ] + const { prevMessage, firstMessageAgent } = await resolveMessageContext( + ctx.sessionID, + client, + messageDir + ) const sessionAgent = getSessionAgent(ctx.sessionID) const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 0447416d..79226a00 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -4,6 +4,7 @@ import { isPlanFamily } from "./constants" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { parseModelString } from "./model-string-parser" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" +import { normalizeSDKResponse } from "../../shared" import { getAvailableModelsForDelegateTask } from "./available-models" import { resolveModelForDelegateTask } from "./model-selection" @@ -47,7 +48,9 @@ Create the work plan directly - that's your job as the planning agent.`, try { const agentsResult = await client.app.agents() type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all"; model?: { providerID: string; modelID: string } } - const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[] + const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], { + preferResponseOnMissingData: true, + }) const callableAgents = agents.filter((a) => a.mode !== "primary") diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index 0a72a454..b31e1950 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -10,6 +10,7 @@ import { findNearestMessageWithFields } from "../../features/hook-message-inject import { formatDuration } from "./time-formatter" import { syncContinuationDeps, type SyncContinuationDeps } from "./sync-continuation-deps" import { setSessionTools } from "../../shared/session-tools-store" +import { normalizeSDKResponse } from "../../shared" export async function executeSyncContinuation( args: DelegateTaskArgs, @@ -56,7 +57,7 @@ export async function executeSyncContinuation( try { try { const messagesResp = await client.session.messages({ path: { id: args.session_id! } }) - const messages = (messagesResp.data ?? []) as SessionMessage[] + const messages = normalizeSDKResponse(messagesResp, [] as SessionMessage[]) anchorMessageCount = messages.length for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts index 64d1a278..3eb454e5 100644 --- a/src/tools/delegate-task/sync-result-fetcher.ts +++ b/src/tools/delegate-task/sync-result-fetcher.ts @@ -1,5 +1,6 @@ import type { OpencodeClient } from "./types" import type { SessionMessage } from "./executor-types" +import { normalizeSDKResponse } from "../../shared" export async function fetchSyncResult( client: OpencodeClient, @@ -14,7 +15,9 @@ export async function fetchSyncResult( return { ok: false, error: `Error fetching result: ${(messagesResult as { error: unknown }).error}\n\nSession ID: ${sessionID}` } } - const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] + const messages = normalizeSDKResponse(messagesResult, [] as SessionMessage[], { + preferResponseOnMissingData: true, + }) const messagesAfterAnchor = anchorMessageCount !== undefined ? messages.slice(anchorMessageCount) : messages diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 3f7b2fd9..9c8cb256 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -2,6 +2,7 @@ import type { ToolContextWithMetadata, OpencodeClient } from "./types" import type { SessionMessage } from "./executor-types" import { getTimingConfig } from "./timing" import { log } from "../../shared/logger" +import { normalizeSDKResponse } from "../../shared" const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"]) @@ -58,7 +59,7 @@ export async function pollSyncSession( log("[task] Poll status fetch failed, retrying", { sessionID: input.sessionID, error: String(error) }) continue } - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) const sessionStatus = allStatuses[input.sessionID] if (pollCount % 10 === 0) { diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index cc6e7cd8..d806fd93 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -5,6 +5,7 @@ import { storeToolMetadata } from "../../features/tool-metadata-store" import { formatDuration } from "./time-formatter" import { formatDetailedError } from "./error-formatting" import { getSessionTools } from "../../shared/session-tools-store" +import { normalizeSDKResponse } from "../../shared" export async function executeUnstableAgentTask( args: DelegateTaskArgs, @@ -93,7 +94,7 @@ export async function executeUnstableAgentTask( } const statusResult = await client.session.status() - const allStatuses = (statusResult.data ?? {}) as Record + const allStatuses = normalizeSDKResponse(statusResult, {} as Record) const sessionStatus = allStatuses[sessionID] if (sessionStatus && sessionStatus.type !== "idle") { @@ -105,7 +106,9 @@ export async function executeUnstableAgentTask( if (Date.now() - pollStart < timingCfg.MIN_STABILITY_TIME_MS) continue const messagesCheck = await client.session.messages({ path: { id: sessionID } }) - const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array + const msgs = normalizeSDKResponse(messagesCheck, [] as Array, { + preferResponseOnMissingData: true, + }) const currentMsgCount = msgs.length if (currentMsgCount === lastMsgCount) { @@ -136,7 +139,9 @@ session_id: ${sessionID} } const messagesResult = await client.session.messages({ path: { id: sessionID } }) - const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[] + const messages = normalizeSDKResponse(messagesResult, [] as SessionMessage[], { + preferResponseOnMissingData: true, + }) const assistantMessages = messages .filter((m) => m.info?.role === "assistant") diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts index d4fe7b50..63d3eca2 100644 --- a/src/tools/session-manager/storage.test.ts +++ b/src/tools/session-manager/storage.test.ts @@ -31,6 +31,13 @@ mock.module("../../shared/opencode-storage-detection", () => ({ resetSqliteBackendCache: () => {}, })) +mock.module("../../shared/opencode-storage-paths", () => ({ + OPENCODE_STORAGE: TEST_DIR, + MESSAGE_STORAGE: TEST_MESSAGE_STORAGE, + PART_STORAGE: TEST_PART_STORAGE, + SESSION_STORAGE: TEST_SESSION_STORAGE, +})) + const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } = await import("./storage") diff --git a/src/tools/session-manager/storage.ts b/src/tools/session-manager/storage.ts index fff12393..59fda3ff 100644 --- a/src/tools/session-manager/storage.ts +++ b/src/tools/session-manager/storage.ts @@ -6,6 +6,7 @@ import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DI import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { getMessageDir } from "../../shared/opencode-message-dir" import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types" +import { normalizeSDKResponse } from "../../shared" export interface GetMainSessionsOptions { directory?: string @@ -27,7 +28,7 @@ export async function getMainSessions(options: GetMainSessionsOptions): Promise< if (isSqliteBackend() && sdkClient) { try { const response = await sdkClient.session.list() - const sessions = (response.data || []) as SessionMetadata[] + const sessions = normalizeSDKResponse(response, [] as SessionMetadata[]) const mainSessions = sessions.filter((s) => !s.parentID) if (options.directory) { return mainSessions @@ -82,7 +83,7 @@ export async function getAllSessions(): Promise { if (isSqliteBackend() && sdkClient) { try { const response = await sdkClient.session.list() - const sessions = (response.data || []) as SessionMetadata[] + const sessions = normalizeSDKResponse(response, [] as SessionMetadata[]) return sessions.map((s) => s.id) } catch { return [] @@ -121,13 +122,9 @@ export { getMessageDir } from "../../shared/opencode-message-dir" export async function sessionExists(sessionID: string): Promise { if (isSqliteBackend() && sdkClient) { - try { - const response = await sdkClient.session.list() - const sessions = (response.data || []) as Array<{ id?: string }> - return sessions.some((s) => s.id === sessionID) - } catch { - return false - } + const response = await sdkClient.session.list() + const sessions = normalizeSDKResponse(response, [] as Array<{ id?: string }>) + return sessions.some((s) => s.id === sessionID) } return getMessageDir(sessionID) !== null } @@ -137,7 +134,7 @@ export async function readSessionMessages(sessionID: string): Promise - }> + }>) const messages: SessionMessage[] = rawMessages .filter((m) => m.info?.id) .map((m) => ({ @@ -258,12 +255,12 @@ export async function readSessionTodos(sessionID: string): Promise { if (isSqliteBackend() && sdkClient) { try { const response = await sdkClient.session.todo({ path: { id: sessionID } }) - const data = (response.data || []) as Array<{ + const data = normalizeSDKResponse(response, [] as Array<{ id?: string content?: string status?: string priority?: string - }> + }>) return data.map((item) => ({ id: item.id || "", content: item.content || "",