From 1a6810535cab5da9ca3c972d7bb5d608eb130b82 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 18:20:19 +0900 Subject: [PATCH] refactor: create normalizeSDKResponse helper and replace scattered patterns across 37 files --- src/cli/run/completion.ts | 7 +- src/features/background-agent/manager.ts | 12 ++-- .../background-agent/notify-parent-session.ts | 4 +- .../background-agent/poll-running-tasks.ts | 13 ++-- .../background-agent/session-validator.ts | 11 +-- .../hook-message-injector/injector.ts | 24 ++++++- src/features/tmux-subagent/manager.ts | 4 +- src/features/tmux-subagent/polling-manager.ts | 5 +- .../message-builder.ts | 3 +- .../message-storage-directory.ts | 3 +- .../pruning-deduplication.ts | 3 +- .../pruning-tool-output-truncation.ts | 3 +- .../target-token-truncation.ts | 3 +- .../tool-result-storage-sdk.ts | 5 +- src/hooks/atlas/recent-model-resolver.ts | 6 +- .../continuation-prompt-injector.ts | 3 +- .../recover-empty-content-message-sdk.ts | 3 +- .../recover-thinking-block-order.ts | 5 +- .../recover-thinking-disabled-violation.ts | 3 +- .../recover-tool-result-missing.ts | 3 +- .../session-recovery/storage/empty-text.ts | 5 +- .../storage/messages-reader.ts | 6 +- .../storage/thinking-prepend.ts | 3 +- .../storage/thinking-strip.ts | 3 +- src/hooks/session-todo-status.ts | 3 +- .../continuation-injection.ts | 3 +- .../todo-continuation-enforcer/idle-event.ts | 7 +- src/plugin/session-agent-resolver.ts | 3 +- src/shared/available-models-fetcher.ts | 5 +- src/shared/dynamic-truncator.ts | 3 +- src/shared/index.ts | 1 + src/shared/model-availability.ts | 5 +- src/shared/normalize-sdk-response.test.ts | 72 +++++++++++++++++++ src/shared/normalize-sdk-response.ts | 36 ++++++++++ src/tools/call-omo-agent/completion-poller.ts | 7 +- src/tools/delegate-task/subagent-resolver.ts | 5 +- src/tools/delegate-task/sync-continuation.ts | 3 +- .../delegate-task/sync-result-fetcher.ts | 5 +- .../delegate-task/sync-session-poller.ts | 3 +- .../delegate-task/unstable-agent-task.ts | 11 ++- src/tools/session-manager/storage.ts | 15 ++-- 41 files changed, 250 insertions(+), 77 deletions(-) create mode 100644 src/shared/normalize-sdk-response.test.ts create mode 100644 src/shared/normalize-sdk-response.ts 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/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/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/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.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/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/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.ts b/src/tools/session-manager/storage.ts index 64e4001b..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 [] @@ -122,7 +123,7 @@ export { getMessageDir } from "../../shared/opencode-message-dir" export async function sessionExists(sessionID: string): Promise { if (isSqliteBackend() && sdkClient) { const response = await sdkClient.session.list() - const sessions = (response.data || []) as Array<{ id?: string }> + const sessions = normalizeSDKResponse(response, [] as Array<{ id?: string }>) return sessions.some((s) => s.id === sessionID) } return getMessageDir(sessionID) !== null @@ -133,7 +134,7 @@ export async function readSessionMessages(sessionID: string): Promise - }> + }>) const messages: SessionMessage[] = rawMessages .filter((m) => m.info?.id) .map((m) => ({ @@ -254,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 || "",