refactor: create normalizeSDKResponse helper and replace scattered patterns across 37 files

This commit is contained in:
YeonGyu-Kim 2026-02-16 18:20:19 +09:00
parent 6d732fd1f6
commit 1a6810535c
41 changed files with 250 additions and 77 deletions

View File

@ -1,5 +1,6 @@
import pc from "picocolors" import pc from "picocolors"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types" import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
import { normalizeSDKResponse } from "../../shared"
export async function checkCompletionConditions(ctx: RunContext): Promise<boolean> { export async function checkCompletionConditions(ctx: RunContext): Promise<boolean> {
try { try {
@ -20,7 +21,7 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
async function areAllTodosComplete(ctx: RunContext): Promise<boolean> { async function areAllTodosComplete(ctx: RunContext): Promise<boolean> {
const todosRes = await ctx.client.session.todo({ path: { id: ctx.sessionID } }) 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( const incompleteTodos = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled" (t) => t.status !== "completed" && t.status !== "cancelled"
@ -43,7 +44,7 @@ async function fetchAllStatuses(
ctx: RunContext ctx: RunContext
): Promise<Record<string, SessionStatus>> { ): Promise<Record<string, SessionStatus>> {
const statusRes = await ctx.client.session.status() const statusRes = await ctx.client.session.status()
return (statusRes.data ?? {}) as Record<string, SessionStatus> return normalizeSDKResponse(statusRes, {} as Record<string, SessionStatus>)
} }
async function areAllDescendantsIdle( async function areAllDescendantsIdle(
@ -54,7 +55,7 @@ async function areAllDescendantsIdle(
const childrenRes = await ctx.client.session.children({ const childrenRes = await ctx.client.session.children({
path: { id: sessionID }, path: { id: sessionID },
}) })
const children = (childrenRes.data ?? []) as ChildSession[] const children = normalizeSDKResponse(childrenRes, [] as ChildSession[])
for (const child of children) { for (const child of children) {
const status = allStatuses[child.id] const status = allStatuses[child.id]

View File

@ -6,7 +6,7 @@ import type {
ResumeInput, ResumeInput,
} from "./types" } from "./types"
import { TaskHistory } from "./task-history" 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 { setSessionTools } from "../../shared/session-tools-store"
import { ConcurrencyManager } from "./concurrency" import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
@ -651,7 +651,7 @@ export class BackgroundManager {
const response = await this.client.session.todo({ const response = await this.client.session.todo({
path: { id: sessionID }, 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 if (!todos || todos.length === 0) return false
const incomplete = todos.filter( const incomplete = todos.filter(
@ -875,7 +875,7 @@ export class BackgroundManager {
path: { id: sessionID }, 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 // Check for at least one assistant or tool message
const hasAssistantOrToolMessage = messages.some( const hasAssistantOrToolMessage = messages.some(
@ -1244,9 +1244,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try { try {
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } }) 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 } info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
}> }>)
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info const info = messages[i].info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { 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() this.pruneStaleTasksAndNotifications()
const statusResult = await this.client.session.status() const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
await this.checkAndInterruptStaleTasks(allStatuses) await this.checkAndInterruptStaleTasks(allStatuses)

View File

@ -1,4 +1,4 @@
import { log } from "../../shared" import { log, normalizeSDKResponse } from "../../shared"
import { findNearestMessageWithFields } from "../hook-message-injector" import { findNearestMessageWithFields } from "../hook-message-injector"
import { getTaskToastManager } from "../task-toast-manager" import { getTaskToastManager } from "../task-toast-manager"
@ -106,7 +106,7 @@ export async function notifyParentSession(args: {
const messagesResp = await client.session.messages({ const messagesResp = await client.session.messages({
path: { id: task.parentSessionID }, path: { id: task.parentSessionID },
}) })
const raw = (messagesResp as { data?: unknown }).data ?? [] const raw = normalizeSDKResponse(messagesResp, [] as unknown[])
const messages = Array.isArray(raw) ? raw : [] const messages = Array.isArray(raw) ? raw : []
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {

View File

@ -1,4 +1,4 @@
import { log } from "../../shared" import { log, normalizeSDKResponse } from "../../shared"
import { import {
MIN_STABILITY_TIME_MS, MIN_STABILITY_TIME_MS,
@ -56,7 +56,7 @@ export async function pollRunningTasks(args: {
pruneStaleTasksAndNotifications() pruneStaleTasksAndNotifications()
const statusResult = await client.session.status() const statusResult = await client.session.status()
const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap const allStatuses = normalizeSDKResponse(statusResult, {} as SessionStatusMap)
await checkAndInterruptStaleTasks(allStatuses) await checkAndInterruptStaleTasks(allStatuses)
@ -95,10 +95,9 @@ export async function pollRunningTasks(args: {
continue continue
} }
const messagesPayload = Array.isArray(messagesResult) const messages = asSessionMessages(normalizeSDKResponse(messagesResult, [] as SessionMessage[], {
? messagesResult preferResponseOnMissingData: true,
: (messagesResult as { data?: unknown }).data }))
const messages = asSessionMessages(messagesPayload)
const assistantMsgs = messages.filter((m) => m.info?.role === "assistant") const assistantMsgs = messages.filter((m) => m.info?.role === "assistant")
let toolCalls = 0 let toolCalls = 0
@ -139,7 +138,7 @@ export async function pollRunningTasks(args: {
task.stablePolls = (task.stablePolls ?? 0) + 1 task.stablePolls = (task.stablePolls ?? 0) + 1
if (task.stablePolls >= 3) { if (task.stablePolls >= 3) {
const recheckStatus = await client.session.status() 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] const currentStatus = recheckData[sessionID]
if (currentStatus?.type !== "idle") { if (currentStatus?.type !== "idle") {

View File

@ -1,4 +1,4 @@
import { log } from "../../shared" import { log, normalizeSDKResponse } from "../../shared"
import type { OpencodeClient } from "./opencode-client" import type { OpencodeClient } from "./opencode-client"
@ -51,7 +51,9 @@ export async function validateSessionHasOutput(
path: { id: sessionID }, 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( const hasAssistantOrToolMessage = messages.some(
(m) => m.info?.role === "assistant" || m.info?.role === "tool" (m) => m.info?.role === "assistant" || m.info?.role === "tool"
@ -97,8 +99,9 @@ export async function checkSessionTodos(
path: { id: sessionID }, path: { id: sessionID },
}) })
const raw = (response as { data?: unknown }).data ?? response const todos = normalizeSDKResponse(response, [] as Todo[], {
const todos = Array.isArray(raw) ? (raw as Todo[]) : [] preferResponseOnMissingData: true,
})
if (todos.length === 0) return false if (todos.length === 0) return false
const incomplete = todos.filter( const incomplete = todos.filter(

View File

@ -5,6 +5,8 @@ import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { getMessageDir } from "../../shared/opencode-message-dir"
import { normalizeSDKResponse } from "../../shared"
export interface StoredMessage { export interface StoredMessage {
agent?: string agent?: string
@ -64,7 +66,7 @@ export async function findNearestMessageWithFieldsFromSDK(
): Promise<StoredMessage | null> { ): Promise<StoredMessage | null> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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--) { for (let i = messages.length - 1; i >= 0; i--) {
const stored = convertSDKMessageToStoredMessage(messages[i]) const stored = convertSDKMessageToStoredMessage(messages[i])
@ -97,7 +99,7 @@ export async function findFirstMessageWithAgentFromSDK(
): Promise<string | null> { ): Promise<string | null> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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) { for (const msg of messages) {
const stored = convertSDKMessageToStoredMessage(msg) const stored = convertSDKMessageToStoredMessage(msg)
@ -354,3 +356,21 @@ export function injectHookMessage(
return false 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 }
}

View File

@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema" import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig } from "./types" import type { TrackedSession, CapacityConfig } from "./types"
import { log, normalizeSDKResponse } from "../../shared"
import { import {
isInsideTmux as defaultIsInsideTmux, isInsideTmux as defaultIsInsideTmux,
getCurrentPaneId as defaultGetCurrentPaneId, getCurrentPaneId as defaultGetCurrentPaneId,
@ -9,7 +10,6 @@ import {
SESSION_READY_POLL_INTERVAL_MS, SESSION_READY_POLL_INTERVAL_MS,
SESSION_READY_TIMEOUT_MS, SESSION_READY_TIMEOUT_MS,
} from "../../shared/tmux" } from "../../shared/tmux"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier" import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine" import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
import { executeActions, executeAction } from "./action-executor" import { executeActions, executeAction } from "./action-executor"
@ -103,7 +103,7 @@ export class TmuxSessionManager {
while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) { while (Date.now() - startTime < SESSION_READY_TIMEOUT_MS) {
try { try {
const statusResult = await this.client.session.status({ path: undefined }) const statusResult = await this.client.session.status({ path: undefined })
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
if (allStatuses[sessionId]) { if (allStatuses[sessionId]) {
log("[tmux-session-manager] session ready", { log("[tmux-session-manager] session ready", {

View File

@ -3,6 +3,7 @@ import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux"
import type { TrackedSession } from "./types" import type { TrackedSession } from "./types"
import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux" import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux"
import { log } from "../../shared" import { log } from "../../shared"
import { normalizeSDKResponse } from "../../shared"
const SESSION_TIMEOUT_MS = 10 * 60 * 1000 const SESSION_TIMEOUT_MS = 10 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000 const MIN_STABILITY_TIME_MS = 10 * 1000
@ -43,7 +44,7 @@ export class TmuxPollingManager {
try { try {
const statusResult = await this.client.session.status({ path: undefined }) const statusResult = await this.client.session.status({ path: undefined })
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
log("[tmux-session-manager] pollSessions", { log("[tmux-session-manager] pollSessions", {
trackedSessions: Array.from(this.sessions.keys()), trackedSessions: Array.from(this.sessions.keys()),
@ -82,7 +83,7 @@ export class TmuxPollingManager {
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) { if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
const recheckResult = await this.client.session.status({ path: undefined }) const recheckResult = await this.client.session.status({ path: undefined })
const recheckStatuses = (recheckResult.data ?? {}) as Record<string, { type: string }> const recheckStatuses = normalizeSDKResponse(recheckResult, {} as Record<string, { type: string }>)
const recheckStatus = recheckStatuses[sessionId] const recheckStatus = recheckStatuses[sessionId]
if (recheckStatus?.type === "idle") { if (recheckStatus?.type === "idle") {

View File

@ -1,5 +1,6 @@
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSDKResponse } from "../../shared"
import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { import {
findEmptyMessages, findEmptyMessages,
@ -64,7 +65,7 @@ async function findEmptyMessageIdsFromSDK(
const response = (await client.session.messages({ const response = (await client.session.messages({
path: { id: sessionID }, path: { id: sessionID },
})) as { data?: SDKMessage[] } })) as { data?: SDKMessage[] }
const messages = ((response.data ?? response) as unknown as SDKMessage[]) ?? [] const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
const emptyIds: string[] = [] const emptyIds: string[] = []
for (const message of messages) { for (const message of messages) {

View File

@ -1,6 +1,7 @@
import { existsSync, readdirSync } from "node:fs" import { existsSync, readdirSync } from "node:fs"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { getMessageDir } from "../../shared/opencode-message-dir" import { getMessageDir } from "../../shared/opencode-message-dir"
import { normalizeSDKResponse } from "../../shared"
export { getMessageDir } export { getMessageDir }
@ -17,7 +18,7 @@ export async function getMessageIdsFromSDK(
): Promise<string[]> { ): Promise<string[]> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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) return messages.map(msg => msg.info.id)
} catch { } catch {
return [] return []

View File

@ -6,6 +6,7 @@ import { estimateTokens } from "./pruning-types"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { getMessageDir } from "../../shared/opencode-message-dir" import { getMessageDir } from "../../shared/opencode-message-dir"
import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -72,7 +73,7 @@ function readMessages(sessionID: string): MessagePart[] {
async function readMessagesFromSDK(client: OpencodeClient, sessionID: string): Promise<MessagePart[]> { async function readMessagesFromSDK(client: OpencodeClient, sessionID: string): Promise<MessagePart[]> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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[] return rawMessages.filter((m) => m.parts) as MessagePart[]
} catch { } catch {
return [] return []

View File

@ -7,6 +7,7 @@ import { truncateToolResultAsync } from "./tool-result-storage-sdk"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { getMessageDir } from "../../shared/opencode-message-dir" import { getMessageDir } from "../../shared/opencode-message-dir"
import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -108,7 +109,7 @@ async function truncateToolOutputsByCallIdFromSDK(
): Promise<{ truncatedCount: number }> { ): Promise<{ truncatedCount: number }> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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 let truncatedCount = 0
for (const msg of messages) { for (const msg of messages) {

View File

@ -3,6 +3,7 @@ import type { AggressiveTruncateResult } from "./tool-part-types"
import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage" import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage"
import { truncateToolResultAsync } from "./tool-result-storage-sdk" import { truncateToolResultAsync } from "./tool-result-storage-sdk"
import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -66,7 +67,7 @@ export async function truncateUntilTargetTokens(
const response = (await client.session.messages({ const response = (await client.session.messages({
path: { id: sessionID }, path: { id: sessionID },
})) as { data?: SDKMessage[] } })) as { data?: SDKMessage[] }
const messages = (response.data ?? response) as SDKMessage[] const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true })
toolPartsByKey = new Map<string, SDKToolPart>() toolPartsByKey = new Map<string, SDKToolPart>()
for (const message of messages) { for (const message of messages) {

View File

@ -4,6 +4,7 @@ import { TRUNCATION_MESSAGE } from "./storage-paths"
import type { ToolResultInfo } from "./tool-part-types" import type { ToolResultInfo } from "./tool-part-types"
import { patchPart } from "../../shared/opencode-http-api" import { patchPart } from "../../shared/opencode-http-api"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { normalizeSDKResponse } from "../../shared"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -32,7 +33,7 @@ export async function findToolResultsBySizeFromSDK(
): Promise<ToolResultInfo[]> { ): Promise<ToolResultInfo[]> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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[] = [] const results: ToolResultInfo[] = []
for (const msg of messages) { for (const msg of messages) {
@ -98,7 +99,7 @@ export async function countTruncatedResultsFromSDK(
): Promise<number> { ): Promise<number> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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 let count = 0
for (const msg of messages) { for (const msg of messages) {

View File

@ -3,7 +3,7 @@ import {
findNearestMessageWithFields, findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK, findNearestMessageWithFieldsFromSDK,
} from "../../features/hook-message-injector" } from "../../features/hook-message-injector"
import { getMessageDir, isSqliteBackend } from "../../shared" import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
import type { ModelInfo } from "./types" import type { ModelInfo } from "./types"
export async function resolveRecentModelForSession( export async function resolveRecentModelForSession(
@ -12,9 +12,9 @@ export async function resolveRecentModelForSession(
): Promise<ModelInfo | undefined> { ): Promise<ModelInfo | undefined> {
try { try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } }) 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 } info?: { model?: ModelInfo; modelID?: string; providerID?: string }
}> }>)
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info const info = messages[i].info

View File

@ -3,6 +3,7 @@ import { log } from "../../shared/logger"
import { findNearestMessageWithFields } from "../../features/hook-message-injector" import { findNearestMessageWithFields } from "../../features/hook-message-injector"
import { getMessageDir } from "./message-storage-directory" import { getMessageDir } from "./message-storage-directory"
import { withTimeout } from "./with-timeout" import { withTimeout } from "./with-timeout"
import { normalizeSDKResponse } from "../../shared"
type MessageInfo = { type MessageInfo = {
agent?: string agent?: string
@ -25,7 +26,7 @@ export async function injectContinuationPrompt(
}), }),
options.apiTimeoutMs, 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--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i]?.info const info = messages[i]?.info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {

View File

@ -2,6 +2,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types" import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type" import { extractMessageIndex } from "./detect-error-type"
import { META_TYPES, THINKING_TYPES } from "./constants" import { META_TYPES, THINKING_TYPES } from "./constants"
import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
@ -136,7 +137,7 @@ function sdkMessageHasContent(message: MessageData): boolean {
async function readMessagesFromSDK(client: Client, sessionID: string): Promise<MessageData[]> { async function readMessagesFromSDK(client: Client, sessionID: string): Promise<MessageData[]> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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 { } catch {
return [] return []
} }

View File

@ -5,6 +5,7 @@ import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prep
import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { prependThinkingPartAsync } from "./storage/thinking-prepend" import { prependThinkingPartAsync } from "./storage/thinking-prepend"
import { THINKING_TYPES } from "./constants" import { THINKING_TYPES } from "./constants"
import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
@ -77,7 +78,7 @@ async function findMessagesWithOrphanThinkingFromSDK(
let messages: MessageData[] let messages: MessageData[]
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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 { } catch {
return [] return []
} }
@ -111,7 +112,7 @@ async function findMessageByIndexNeedingThinkingFromSDK(
let messages: MessageData[] let messages: MessageData[]
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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 { } catch {
return null return null
} }

View File

@ -5,6 +5,7 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { stripThinkingPartsAsync } from "./storage/thinking-strip" import { stripThinkingPartsAsync } from "./storage/thinking-strip"
import { THINKING_TYPES } from "./constants" import { THINKING_TYPES } from "./constants"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
@ -38,7 +39,7 @@ async function recoverThinkingDisabledViolationFromSDK(
): Promise<boolean> { ): Promise<boolean> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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[] = [] const messageIDsWithThinking: string[] = []
for (const msg of messages) { for (const msg of messages) {

View File

@ -2,6 +2,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types" import type { MessageData } from "./types"
import { readParts } from "./storage" import { readParts } from "./storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
@ -28,7 +29,7 @@ async function readPartsFromSDKFallback(
): Promise<MessagePart[]> { ): Promise<MessagePart[]> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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) const target = messages.find((m) => m.info?.id === messageID)
if (!target?.parts) return [] if (!target?.parts) return []

View File

@ -6,6 +6,7 @@ import type { StoredPart, StoredTextPart, MessageData } from "../types"
import { readMessages } from "./messages-reader" import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader" import { readParts } from "./parts-reader"
import { log, isSqliteBackend, patchPart } from "../../../shared" import { log, isSqliteBackend, patchPart } from "../../../shared"
import { normalizeSDKResponse } from "../../../shared"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -51,7 +52,7 @@ export async function replaceEmptyTextPartsAsync(
): Promise<boolean> { ): Promise<boolean> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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) const targetMsg = messages.find((m) => m.info?.id === messageID)
if (!targetMsg?.parts) return false if (!targetMsg?.parts) return false
@ -101,7 +102,7 @@ export async function findMessagesWithEmptyTextPartsFromSDK(
): Promise<string[]> { ): Promise<string[]> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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[] = [] const result: string[] = []
for (const msg of messages) { for (const msg of messages) {

View File

@ -3,7 +3,7 @@ import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { StoredMessageMeta } from "../types" import type { StoredMessageMeta } from "../types"
import { getMessageDir } from "./message-dir" import { getMessageDir } from "./message-dir"
import { isSqliteBackend } from "../../../shared" import { isSqliteBackend, normalizeSDKResponse } from "../../../shared"
import { isRecord } from "../../../shared/record-type-guard" import { isRecord } from "../../../shared/record-type-guard"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -62,7 +62,9 @@ export async function readMessagesFromSDK(
): Promise<StoredMessageMeta[]> { ): Promise<StoredMessageMeta[]> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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 [] if (!Array.isArray(data)) return []
const messages = data const messages = data

View File

@ -6,6 +6,7 @@ import type { MessageData } from "../types"
import { readMessages } from "./messages-reader" import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader" import { readParts } from "./parts-reader"
import { log, isSqliteBackend, patchPart } from "../../../shared" import { log, isSqliteBackend, patchPart } from "../../../shared"
import { normalizeSDKResponse } from "../../../shared"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -74,7 +75,7 @@ async function findLastThinkingContentFromSDK(
): Promise<string> { ): Promise<string> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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) const currentIndex = messages.findIndex((m) => m.info?.id === beforeMessageID)
if (currentIndex === -1) return "" if (currentIndex === -1) return ""

View File

@ -4,6 +4,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE, THINKING_TYPES } from "../constants" import { PART_STORAGE, THINKING_TYPES } from "../constants"
import type { StoredPart } from "../types" import type { StoredPart } from "../types"
import { log, isSqliteBackend, deletePart } from "../../../shared" import { log, isSqliteBackend, deletePart } from "../../../shared"
import { normalizeSDKResponse } from "../../../shared"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -42,7 +43,7 @@ export async function stripThinkingPartsAsync(
): Promise<boolean> { ): Promise<boolean> {
try { try {
const response = await client.session.messages({ path: { id: sessionID } }) 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 targetMsg = messages.find((m) => {
const info = (m as Record<string, unknown>)["info"] as Record<string, unknown> | undefined const info = (m as Record<string, unknown>)["info"] as Record<string, unknown> | undefined

View File

@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSDKResponse } from "../shared"
interface Todo { interface Todo {
content: string content: string
@ -10,7 +11,7 @@ interface Todo {
export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise<boolean> { export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise<boolean> {
try { try {
const response = await ctx.client.session.todo({ path: { id: sessionID } }) 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 if (!todos || todos.length === 0) return false
return todos.some((todo) => todo.status !== "completed" && todo.status !== "cancelled") return todos.some((todo) => todo.status !== "completed" && todo.status !== "cancelled")
} catch { } catch {

View File

@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundManager } from "../../features/background-agent"
import { normalizeSDKResponse } from "../../shared"
import { import {
findNearestMessageWithFields, findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK, findNearestMessageWithFieldsFromSDK,
@ -63,7 +64,7 @@ export async function injectContinuation(args: {
let todos: Todo[] = [] let todos: Todo[] = []
try { try {
const response = await ctx.client.session.todo({ path: { id: sessionID } }) 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) { } catch (error) {
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) }) log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) })
return return

View File

@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundManager } from "../../features/background-agent"
import type { ToolPermission } from "../../features/hook-message-injector" import type { ToolPermission } from "../../features/hook-message-injector"
import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { import {
@ -67,7 +68,7 @@ export async function handleSessionIdle(args: {
path: { id: sessionID }, path: { id: sessionID },
query: { directory: ctx.directory }, query: { directory: ctx.directory },
}) })
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? [] const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>)
if (isLastAssistantMessageAborted(messages)) { if (isLastAssistantMessageAborted(messages)) {
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID }) log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
return return
@ -79,7 +80,7 @@ export async function handleSessionIdle(args: {
let todos: Todo[] = [] let todos: Todo[] = []
try { try {
const response = await ctx.client.session.todo({ path: { id: sessionID } }) 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) { } catch (error) {
log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(error) }) log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(error) })
return return
@ -139,7 +140,7 @@ export async function handleSessionIdle(args: {
const messagesResp = await ctx.client.session.messages({ const messagesResp = await ctx.client.session.messages({
path: { id: sessionID }, 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--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info const info = messages[i].info
if (info?.agent === "compaction") { if (info?.agent === "compaction") {

View File

@ -1,4 +1,5 @@
import { log } from "../shared" import { log } from "../shared"
import { normalizeSDKResponse } from "../shared"
interface SessionMessage { interface SessionMessage {
info?: { info?: {
@ -19,7 +20,7 @@ export async function resolveSessionAgent(
): Promise<string | undefined> { ): Promise<string | undefined> {
try { try {
const messagesResp = await client.session.messages({ path: { id: sessionId } }) 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) { for (const msg of messages) {
if (msg.info?.agent) { if (msg.info?.agent) {

View File

@ -2,6 +2,7 @@ import { addModelsFromModelsJsonCache } from "./models-json-cache-reader"
import { getModelListFunction, getProviderListFunction } from "./open-code-client-accessors" import { getModelListFunction, getProviderListFunction } from "./open-code-client-accessors"
import { addModelsFromProviderModelsCache } from "./provider-models-cache-model-reader" import { addModelsFromProviderModelsCache } from "./provider-models-cache-model-reader"
import { log } from "./logger" import { log } from "./logger"
import { normalizeSDKResponse } from "./normalize-sdk-response"
export async function getConnectedProviders(client: unknown): Promise<string[]> { export async function getConnectedProviders(client: unknown): Promise<string[]> {
const providerList = getProviderListFunction(client) const providerList = getProviderListFunction(client)
@ -53,7 +54,7 @@ export async function fetchAvailableModels(
const modelSet = new Set<string>() const modelSet = new Set<string>()
try { try {
const modelsResult = await modelList() const modelsResult = await modelList()
const models = modelsResult.data ?? [] const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
for (const model of models) { for (const model of models) {
if (model.provider && model.id) { if (model.provider && model.id) {
modelSet.add(`${model.provider}/${model.id}`) modelSet.add(`${model.provider}/${model.id}`)
@ -92,7 +93,7 @@ export async function fetchAvailableModels(
if (modelList) { if (modelList) {
try { try {
const modelsResult = await modelList() const modelsResult = await modelList()
const models = modelsResult.data ?? [] const models = normalizeSDKResponse(modelsResult, [] as Array<{ provider?: string; id?: string }>)
for (const model of models) { for (const model of models) {
if (!model.provider || !model.id) continue if (!model.provider || !model.id) continue

View File

@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"; import type { PluginInput } from "@opencode-ai/plugin";
import { normalizeSDKResponse } from "./normalize-sdk-response"
const ANTHROPIC_ACTUAL_LIMIT = const ANTHROPIC_ACTUAL_LIMIT =
process.env.ANTHROPIC_1M_CONTEXT === "true" || process.env.ANTHROPIC_1M_CONTEXT === "true" ||
@ -119,7 +120,7 @@ export async function getContextWindowUsage(
path: { id: sessionID }, path: { id: sessionID },
}); });
const messages = (response.data ?? response) as MessageWrapper[]; const messages = normalizeSDKResponse(response, [] as MessageWrapper[], { preferResponseOnMissingData: true })
const assistantMessages = messages const assistantMessages = messages
.filter((m) => m.info.role === "assistant") .filter((m) => m.info.role === "assistant")

View File

@ -53,3 +53,4 @@ export * from "./safe-create-hook"
export * from "./truncate-description" export * from "./truncate-description"
export * from "./opencode-storage-paths" export * from "./opencode-storage-paths"
export * from "./opencode-message-dir" export * from "./opencode-message-dir"
export * from "./normalize-sdk-response"

View File

@ -3,6 +3,7 @@ import { join } from "path"
import { log } from "./logger" import { log } from "./logger"
import { getOpenCodeCacheDir } from "./data-path" import { getOpenCodeCacheDir } from "./data-path"
import * as connectedProvidersCache from "./connected-providers-cache" import * as connectedProvidersCache from "./connected-providers-cache"
import { normalizeSDKResponse } from "./normalize-sdk-response"
/** /**
* Fuzzy match a target model name against available models * Fuzzy match a target model name against available models
@ -159,7 +160,7 @@ export async function fetchAvailableModels(
const modelSet = new Set<string>() const modelSet = new Set<string>()
try { try {
const modelsResult = await client.model.list() 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) { for (const model of models) {
if (model?.provider && model?.id) { if (model?.provider && model?.id) {
modelSet.add(`${model.provider}/${model.id}`) modelSet.add(`${model.provider}/${model.id}`)
@ -261,7 +262,7 @@ export async function fetchAvailableModels(
if (client?.model?.list) { if (client?.model?.list) {
try { try {
const modelsResult = await client.model.list() 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) { for (const model of models) {
if (!model?.provider || !model?.id) continue if (!model?.provider || !model?.id) continue

View File

@ -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)
})
})

View File

@ -0,0 +1,36 @@
export interface NormalizeSDKResponseOptions {
preferResponseOnMissingData?: boolean
}
export function normalizeSDKResponse<TData>(
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
}

View File

@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared" import { log } from "../../shared"
import { normalizeSDKResponse } from "../../shared"
export async function waitForCompletion( export async function waitForCompletion(
sessionID: string, sessionID: string,
@ -33,7 +34,7 @@ export async function waitForCompletion(
// Check session status // Check session status
const statusResult = await ctx.client.session.status() const statusResult = await ctx.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
const sessionStatus = allStatuses[sessionID] const sessionStatus = allStatuses[sessionID]
// If session is actively running, reset stability counter // If session is actively running, reset stability counter
@ -45,7 +46,9 @@ export async function waitForCompletion(
// Session is idle - check message stability // Session is idle - check message stability
const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } }) const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown> const msgs = normalizeSDKResponse(messagesCheck, [] as Array<unknown>, {
preferResponseOnMissingData: true,
})
const currentMsgCount = msgs.length const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) { if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {

View File

@ -4,6 +4,7 @@ import { isPlanFamily } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { parseModelString } from "./model-string-parser" import { parseModelString } from "./model-string-parser"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeSDKResponse } from "../../shared"
import { getAvailableModelsForDelegateTask } from "./available-models" import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection" import { resolveModelForDelegateTask } from "./model-selection"
@ -47,7 +48,9 @@ Create the work plan directly - that's your job as the planning agent.`,
try { try {
const agentsResult = await client.app.agents() const agentsResult = await client.app.agents()
type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all"; model?: { providerID: string; modelID: string } } 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") const callableAgents = agents.filter((a) => a.mode !== "primary")

View File

@ -10,6 +10,7 @@ import { findNearestMessageWithFields } from "../../features/hook-message-inject
import { formatDuration } from "./time-formatter" import { formatDuration } from "./time-formatter"
import { syncContinuationDeps, type SyncContinuationDeps } from "./sync-continuation-deps" import { syncContinuationDeps, type SyncContinuationDeps } from "./sync-continuation-deps"
import { setSessionTools } from "../../shared/session-tools-store" import { setSessionTools } from "../../shared/session-tools-store"
import { normalizeSDKResponse } from "../../shared"
export async function executeSyncContinuation( export async function executeSyncContinuation(
args: DelegateTaskArgs, args: DelegateTaskArgs,
@ -56,7 +57,7 @@ export async function executeSyncContinuation(
try { try {
try { try {
const messagesResp = await client.session.messages({ path: { id: args.session_id! } }) 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 anchorMessageCount = messages.length
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info const info = messages[i].info

View File

@ -1,5 +1,6 @@
import type { OpencodeClient } from "./types" import type { OpencodeClient } from "./types"
import type { SessionMessage } from "./executor-types" import type { SessionMessage } from "./executor-types"
import { normalizeSDKResponse } from "../../shared"
export async function fetchSyncResult( export async function fetchSyncResult(
client: OpencodeClient, 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}` } 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 const messagesAfterAnchor = anchorMessageCount !== undefined ? messages.slice(anchorMessageCount) : messages

View File

@ -2,6 +2,7 @@ import type { ToolContextWithMetadata, OpencodeClient } from "./types"
import type { SessionMessage } from "./executor-types" import type { SessionMessage } from "./executor-types"
import { getTimingConfig } from "./timing" import { getTimingConfig } from "./timing"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { normalizeSDKResponse } from "../../shared"
const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"]) 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) }) log("[task] Poll status fetch failed, retrying", { sessionID: input.sessionID, error: String(error) })
continue continue
} }
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
const sessionStatus = allStatuses[input.sessionID] const sessionStatus = allStatuses[input.sessionID]
if (pollCount % 10 === 0) { if (pollCount % 10 === 0) {

View File

@ -5,6 +5,7 @@ import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDuration } from "./time-formatter" import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting" import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store" import { getSessionTools } from "../../shared/session-tools-store"
import { normalizeSDKResponse } from "../../shared"
export async function executeUnstableAgentTask( export async function executeUnstableAgentTask(
args: DelegateTaskArgs, args: DelegateTaskArgs,
@ -93,7 +94,7 @@ export async function executeUnstableAgentTask(
} }
const statusResult = await client.session.status() const statusResult = await client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }> const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
const sessionStatus = allStatuses[sessionID] const sessionStatus = allStatuses[sessionID]
if (sessionStatus && sessionStatus.type !== "idle") { if (sessionStatus && sessionStatus.type !== "idle") {
@ -105,7 +106,9 @@ export async function executeUnstableAgentTask(
if (Date.now() - pollStart < timingCfg.MIN_STABILITY_TIME_MS) continue if (Date.now() - pollStart < timingCfg.MIN_STABILITY_TIME_MS) continue
const messagesCheck = await client.session.messages({ path: { id: sessionID } }) const messagesCheck = await client.session.messages({ path: { id: sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown> const msgs = normalizeSDKResponse(messagesCheck, [] as Array<unknown>, {
preferResponseOnMissingData: true,
})
const currentMsgCount = msgs.length const currentMsgCount = msgs.length
if (currentMsgCount === lastMsgCount) { if (currentMsgCount === lastMsgCount) {
@ -136,7 +139,9 @@ session_id: ${sessionID}
} }
const messagesResult = await client.session.messages({ path: { 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 const assistantMessages = messages
.filter((m) => m.info?.role === "assistant") .filter((m) => m.info?.role === "assistant")

View File

@ -6,6 +6,7 @@ import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DI
import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { getMessageDir } from "../../shared/opencode-message-dir" import { getMessageDir } from "../../shared/opencode-message-dir"
import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types" import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types"
import { normalizeSDKResponse } from "../../shared"
export interface GetMainSessionsOptions { export interface GetMainSessionsOptions {
directory?: string directory?: string
@ -27,7 +28,7 @@ export async function getMainSessions(options: GetMainSessionsOptions): Promise<
if (isSqliteBackend() && sdkClient) { if (isSqliteBackend() && sdkClient) {
try { try {
const response = await sdkClient.session.list() 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) const mainSessions = sessions.filter((s) => !s.parentID)
if (options.directory) { if (options.directory) {
return mainSessions return mainSessions
@ -82,7 +83,7 @@ export async function getAllSessions(): Promise<string[]> {
if (isSqliteBackend() && sdkClient) { if (isSqliteBackend() && sdkClient) {
try { try {
const response = await sdkClient.session.list() const response = await sdkClient.session.list()
const sessions = (response.data || []) as SessionMetadata[] const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
return sessions.map((s) => s.id) return sessions.map((s) => s.id)
} catch { } catch {
return [] return []
@ -122,7 +123,7 @@ export { getMessageDir } from "../../shared/opencode-message-dir"
export async function sessionExists(sessionID: string): Promise<boolean> { export async function sessionExists(sessionID: string): Promise<boolean> {
if (isSqliteBackend() && sdkClient) { if (isSqliteBackend() && sdkClient) {
const response = await sdkClient.session.list() 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 sessions.some((s) => s.id === sessionID)
} }
return getMessageDir(sessionID) !== null return getMessageDir(sessionID) !== null
@ -133,7 +134,7 @@ export async function readSessionMessages(sessionID: string): Promise<SessionMes
if (isSqliteBackend() && sdkClient) { if (isSqliteBackend() && sdkClient) {
try { try {
const response = await sdkClient.session.messages({ path: { id: sessionID } }) const response = await sdkClient.session.messages({ path: { id: sessionID } })
const rawMessages = (response.data || []) as Array<{ const rawMessages = normalizeSDKResponse(response, [] as Array<{
info?: { info?: {
id?: string id?: string
role?: string role?: string
@ -151,7 +152,7 @@ export async function readSessionMessages(sessionID: string): Promise<SessionMes
output?: string output?: string
error?: string error?: string
}> }>
}> }>)
const messages: SessionMessage[] = rawMessages const messages: SessionMessage[] = rawMessages
.filter((m) => m.info?.id) .filter((m) => m.info?.id)
.map((m) => ({ .map((m) => ({
@ -254,12 +255,12 @@ export async function readSessionTodos(sessionID: string): Promise<TodoItem[]> {
if (isSqliteBackend() && sdkClient) { if (isSqliteBackend() && sdkClient) {
try { try {
const response = await sdkClient.session.todo({ path: { id: sessionID } }) const response = await sdkClient.session.todo({ path: { id: sessionID } })
const data = (response.data || []) as Array<{ const data = normalizeSDKResponse(response, [] as Array<{
id?: string id?: string
content?: string content?: string
status?: string status?: string
priority?: string priority?: string
}> }>)
return data.map((item) => ({ return data.map((item) => ({
id: item.id || "", id: item.id || "",
content: item.content || "", content: item.content || "",