Merge pull request #1886 from code-yeongyu/fix/oracle-review-findings
fix: address Oracle safety review findings for v3.6.0 minor publish
This commit is contained in:
commit
9a07227bea
@ -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]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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--) {
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export {
|
|||||||
findFirstMessageWithAgent,
|
findFirstMessageWithAgent,
|
||||||
findNearestMessageWithFieldsFromSDK,
|
findNearestMessageWithFieldsFromSDK,
|
||||||
findFirstMessageWithAgentFromSDK,
|
findFirstMessageWithAgentFromSDK,
|
||||||
|
resolveMessageContext,
|
||||||
} from "./injector"
|
} from "./injector"
|
||||||
export type { StoredMessage } from "./injector"
|
export type { StoredMessage } from "./injector"
|
||||||
export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
|
export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
|
||||||
|
|||||||
@ -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 }
|
||||||
|
}
|
||||||
|
|||||||
@ -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", {
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export async function runAggressiveTruncationStrategy(params: {
|
|||||||
clearSessionState(params.autoCompactState, params.sessionID)
|
clearSessionState(params.autoCompactState, params.sessionID)
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await params.client.session.prompt_async({
|
await params.client.session.promptAsync({
|
||||||
path: { id: params.sessionID },
|
path: { id: params.sessionID },
|
||||||
body: { auto: true } as never,
|
body: { auto: true } as never,
|
||||||
query: { directory: params.directory },
|
query: { directory: params.directory },
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
|
|
||||||
export type Client = PluginInput["client"] & {
|
export type Client = PluginInput["client"] & {
|
||||||
session: {
|
session: {
|
||||||
prompt_async: (opts: {
|
promptAsync: (opts: {
|
||||||
path: { id: string }
|
path: { id: string }
|
||||||
body: { parts: Array<{ type: string; text: string }> }
|
body: { parts: Array<{ type: string; text: string }> }
|
||||||
query: { directory: string }
|
query: { directory: string }
|
||||||
|
|||||||
@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test"
|
||||||
|
import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
|
||||||
|
|
||||||
|
const mockReplaceEmptyTextParts = mock(() => Promise.resolve(false))
|
||||||
|
const mockInjectTextPart = mock(() => Promise.resolve(false))
|
||||||
|
|
||||||
|
mock.module("../session-recovery/storage/empty-text", () => ({
|
||||||
|
replaceEmptyTextPartsAsync: mockReplaceEmptyTextParts,
|
||||||
|
}))
|
||||||
|
mock.module("../session-recovery/storage/text-part-injector", () => ({
|
||||||
|
injectTextPartAsync: mockInjectTextPart,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createMockClient(messages: Array<{ info?: { id?: string }; parts?: Array<{ type?: string; text?: string }> }>) {
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
messages: mock(() => Promise.resolve({ data: messages })),
|
||||||
|
},
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fixEmptyMessagesWithSDK", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReplaceEmptyTextParts.mockReset()
|
||||||
|
mockInjectTextPart.mockReset()
|
||||||
|
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false))
|
||||||
|
mockInjectTextPart.mockReturnValue(Promise.resolve(false))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns fixed=false when no empty messages exist", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([
|
||||||
|
{ info: { id: "msg_1" }, parts: [{ type: "text", text: "Hello" }] },
|
||||||
|
])
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await fixEmptyMessagesWithSDK({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
client,
|
||||||
|
placeholderText: "[recovered]",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.fixed).toBe(false)
|
||||||
|
expect(result.fixedMessageIds).toEqual([])
|
||||||
|
expect(result.scannedEmptyCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fixes empty message via replace when scanning all", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([
|
||||||
|
{ info: { id: "msg_1" }, parts: [{ type: "text", text: "" }] },
|
||||||
|
])
|
||||||
|
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await fixEmptyMessagesWithSDK({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
client,
|
||||||
|
placeholderText: "[recovered]",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.fixed).toBe(true)
|
||||||
|
expect(result.fixedMessageIds).toContain("msg_1")
|
||||||
|
expect(result.scannedEmptyCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to inject when replace fails", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([
|
||||||
|
{ info: { id: "msg_1" }, parts: [] },
|
||||||
|
])
|
||||||
|
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false))
|
||||||
|
mockInjectTextPart.mockReturnValue(Promise.resolve(true))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await fixEmptyMessagesWithSDK({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
client,
|
||||||
|
placeholderText: "[recovered]",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.fixed).toBe(true)
|
||||||
|
expect(result.fixedMessageIds).toContain("msg_1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fixes target message by index when provided", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([
|
||||||
|
{ info: { id: "msg_0" }, parts: [{ type: "text", text: "ok" }] },
|
||||||
|
{ info: { id: "msg_1" }, parts: [] },
|
||||||
|
])
|
||||||
|
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await fixEmptyMessagesWithSDK({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
client,
|
||||||
|
placeholderText: "[recovered]",
|
||||||
|
messageIndex: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.fixed).toBe(true)
|
||||||
|
expect(result.fixedMessageIds).toContain("msg_1")
|
||||||
|
expect(result.scannedEmptyCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("skips messages without info.id", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([
|
||||||
|
{ parts: [] },
|
||||||
|
{ info: {}, parts: [] },
|
||||||
|
])
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await fixEmptyMessagesWithSDK({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
client,
|
||||||
|
placeholderText: "[recovered]",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.fixed).toBe(false)
|
||||||
|
expect(result.scannedEmptyCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats thinking-only messages as empty", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([
|
||||||
|
{ info: { id: "msg_1" }, parts: [{ type: "thinking", text: "hmm" }] },
|
||||||
|
])
|
||||||
|
mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await fixEmptyMessagesWithSDK({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
client,
|
||||||
|
placeholderText: "[recovered]",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.fixed).toBe(true)
|
||||||
|
expect(result.fixedMessageIds).toContain("msg_1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats tool_use messages as non-empty", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([
|
||||||
|
{ info: { id: "msg_1" }, parts: [{ type: "tool_use" }] },
|
||||||
|
])
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await fixEmptyMessagesWithSDK({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
client,
|
||||||
|
placeholderText: "[recovered]",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.fixed).toBe(false)
|
||||||
|
expect(result.scannedEmptyCount).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -99,7 +99,7 @@ describe("executeCompact lock management", () => {
|
|||||||
messages: mock(() => Promise.resolve({ data: [] })),
|
messages: mock(() => Promise.resolve({ data: [] })),
|
||||||
summarize: mock(() => Promise.resolve()),
|
summarize: mock(() => Promise.resolve()),
|
||||||
revert: mock(() => Promise.resolve()),
|
revert: mock(() => Promise.resolve()),
|
||||||
prompt_async: mock(() => Promise.resolve()),
|
promptAsync: mock(() => Promise.resolve()),
|
||||||
},
|
},
|
||||||
tui: {
|
tui: {
|
||||||
showToast: mock(() => Promise.resolve()),
|
showToast: mock(() => Promise.resolve()),
|
||||||
@ -283,9 +283,9 @@ describe("executeCompact lock management", () => {
|
|||||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("clears lock when prompt_async in continuation throws", async () => {
|
test("clears lock when promptAsync in continuation throws", async () => {
|
||||||
// given: prompt_async will fail during continuation
|
// given: promptAsync will fail during continuation
|
||||||
mockClient.session.prompt_async = mock(() =>
|
mockClient.session.promptAsync = mock(() =>
|
||||||
Promise.reject(new Error("Prompt failed")),
|
Promise.reject(new Error("Prompt failed")),
|
||||||
)
|
)
|
||||||
autoCompactState.errorDataBySession.set(sessionID, {
|
autoCompactState.errorDataBySession.set(sessionID, {
|
||||||
@ -378,8 +378,8 @@ describe("executeCompact lock management", () => {
|
|||||||
// then: Summarize should NOT be called (early return from sufficient truncation)
|
// then: Summarize should NOT be called (early return from sufficient truncation)
|
||||||
expect(mockClient.session.summarize).not.toHaveBeenCalled()
|
expect(mockClient.session.summarize).not.toHaveBeenCalled()
|
||||||
|
|
||||||
// then: prompt_async should be called (Continue after successful truncation)
|
// then: promptAsync should be called (Continue after successful truncation)
|
||||||
expect(mockClient.session.prompt_async).toHaveBeenCalled()
|
expect(mockClient.session.promptAsync).toHaveBeenCalled()
|
||||||
|
|
||||||
// then: Lock should be cleared
|
// then: Lock should be cleared
|
||||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 []
|
||||||
|
|||||||
@ -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 []
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -53,7 +53,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
|
|||||||
messages: mock(() => Promise.resolve({ data: [] })),
|
messages: mock(() => Promise.resolve({ data: [] })),
|
||||||
summarize: mock(() => summarizePromise),
|
summarize: mock(() => summarizePromise),
|
||||||
revert: mock(() => Promise.resolve()),
|
revert: mock(() => Promise.resolve()),
|
||||||
prompt_async: mock(() => Promise.resolve()),
|
promptAsync: mock(() => Promise.resolve()),
|
||||||
},
|
},
|
||||||
tui: {
|
tui: {
|
||||||
showToast: mock(() => Promise.resolve()),
|
showToast: mock(() => Promise.resolve()),
|
||||||
@ -97,7 +97,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
|
|||||||
messages: mock(() => Promise.resolve({ data: [] })),
|
messages: mock(() => Promise.resolve({ data: [] })),
|
||||||
summarize: mock(() => Promise.resolve()),
|
summarize: mock(() => Promise.resolve()),
|
||||||
revert: mock(() => Promise.resolve()),
|
revert: mock(() => Promise.resolve()),
|
||||||
prompt_async: mock(() => Promise.resolve()),
|
promptAsync: mock(() => Promise.resolve()),
|
||||||
},
|
},
|
||||||
tui: {
|
tui: {
|
||||||
showToast: mock(() => Promise.resolve()),
|
showToast: mock(() => Promise.resolve()),
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test"
|
||||||
|
import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk"
|
||||||
|
import type { MessageData } from "./types"
|
||||||
|
|
||||||
|
function createMockClient(messages: MessageData[]) {
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
messages: mock(() => Promise.resolve({ data: messages })),
|
||||||
|
},
|
||||||
|
} as never
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeps(overrides?: Partial<Parameters<typeof recoverEmptyContentMessageFromSDK>[4]>) {
|
||||||
|
return {
|
||||||
|
placeholderText: "[recovered]",
|
||||||
|
replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)),
|
||||||
|
injectTextPartAsync: mock(() => Promise.resolve(false)),
|
||||||
|
findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve([] as string[])),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMsg: MessageData = { info: { id: "msg_1", role: "assistant" }, parts: [] }
|
||||||
|
const contentMsg: MessageData = { info: { id: "msg_2", role: "assistant" }, parts: [{ type: "text", text: "Hello" }] }
|
||||||
|
const thinkingOnlyMsg: MessageData = { info: { id: "msg_3", role: "assistant" }, parts: [{ type: "thinking", text: "hmm" }] }
|
||||||
|
|
||||||
|
describe("recoverEmptyContentMessageFromSDK", () => {
|
||||||
|
it("returns false when no empty messages exist", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([contentMsg])
|
||||||
|
const deps = createDeps()
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await recoverEmptyContentMessageFromSDK(
|
||||||
|
client, "ses_1", contentMsg, new Error("test"), deps,
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fixes messages with empty text parts via replace", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([emptyMsg])
|
||||||
|
const deps = createDeps({
|
||||||
|
findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve(["msg_1"])),
|
||||||
|
replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)),
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await recoverEmptyContentMessageFromSDK(
|
||||||
|
client, "ses_1", emptyMsg, new Error("test"), deps,
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("injects text part into thinking-only messages", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([thinkingOnlyMsg])
|
||||||
|
const deps = createDeps({
|
||||||
|
injectTextPartAsync: mock(() => Promise.resolve(true)),
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await recoverEmptyContentMessageFromSDK(
|
||||||
|
client, "ses_1", thinkingOnlyMsg, new Error("test"), deps,
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(deps.injectTextPartAsync).toHaveBeenCalledWith(
|
||||||
|
client, "ses_1", "msg_3", "[recovered]",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("targets message by index from error", async () => {
|
||||||
|
//#given
|
||||||
|
const client = createMockClient([contentMsg, emptyMsg])
|
||||||
|
const error = new Error("messages: index 1 has empty content")
|
||||||
|
const deps = createDeps({
|
||||||
|
replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)),
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await recoverEmptyContentMessageFromSDK(
|
||||||
|
client, "ses_1", emptyMsg, error, deps,
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to failedID when targetIndex fix fails", async () => {
|
||||||
|
//#given
|
||||||
|
const failedMsg: MessageData = { info: { id: "msg_fail" }, parts: [] }
|
||||||
|
const client = createMockClient([contentMsg])
|
||||||
|
const deps = createDeps({
|
||||||
|
replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)),
|
||||||
|
injectTextPartAsync: mock(() => Promise.resolve(true)),
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await recoverEmptyContentMessageFromSDK(
|
||||||
|
client, "ses_1", failedMsg, new Error("test"), deps,
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(deps.injectTextPartAsync).toHaveBeenCalledWith(
|
||||||
|
client, "ses_1", "msg_fail", "[recovered]",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false when SDK throws during message read", async () => {
|
||||||
|
//#given
|
||||||
|
const client = { session: { messages: mock(() => Promise.reject(new Error("SDK error"))) } } as never
|
||||||
|
const deps = createDeps()
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await recoverEmptyContentMessageFromSDK(
|
||||||
|
client, "ses_1", emptyMsg, new Error("test"), deps,
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("scans all empty messages when no target index available", async () => {
|
||||||
|
//#given
|
||||||
|
const empty1: MessageData = { info: { id: "e1" }, parts: [] }
|
||||||
|
const empty2: MessageData = { info: { id: "e2" }, parts: [] }
|
||||||
|
const client = createMockClient([empty1, empty2])
|
||||||
|
const replaceMock = mock(() => Promise.resolve(true))
|
||||||
|
const deps = createDeps({ replaceEmptyTextPartsAsync: replaceMock })
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await recoverEmptyContentMessageFromSDK(
|
||||||
|
client, "ses_1", empty1, new Error("test"), deps,
|
||||||
|
)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 []
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
72
src/shared/normalize-sdk-response.test.ts
Normal file
72
src/shared/normalize-sdk-response.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
36
src/shared/normalize-sdk-response.ts
Normal file
36
src/shared/normalize-sdk-response.ts
Normal 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
|
||||||
|
}
|
||||||
@ -81,7 +81,7 @@ export async function patchPart(
|
|||||||
"Authorization": auth,
|
"Authorization": auth,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -123,7 +123,7 @@ export async function deletePart(
|
|||||||
headers: {
|
headers: {
|
||||||
"Authorization": auth,
|
"Authorization": auth,
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@ -15,15 +15,27 @@ const SQLITE_VERSION = "1.1.53"
|
|||||||
// Other files (e.g., opencode-message-dir.test.ts) mock ./opencode-storage-detection globally,
|
// Other files (e.g., opencode-message-dir.test.ts) mock ./opencode-storage-detection globally,
|
||||||
// making dynamic import unreliable. By inlining, we test the actual logic with controlled deps.
|
// making dynamic import unreliable. By inlining, we test the actual logic with controlled deps.
|
||||||
const NOT_CACHED = Symbol("NOT_CACHED")
|
const NOT_CACHED = Symbol("NOT_CACHED")
|
||||||
let cachedResult: boolean | typeof NOT_CACHED = NOT_CACHED
|
const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY")
|
||||||
|
let cachedResult: true | false | typeof NOT_CACHED | typeof FALSE_PENDING_RETRY = NOT_CACHED
|
||||||
|
|
||||||
function isSqliteBackend(): boolean {
|
function isSqliteBackend(): boolean {
|
||||||
if (cachedResult !== NOT_CACHED) return cachedResult as boolean
|
if (cachedResult === true) return true
|
||||||
|
if (cachedResult === false) return false
|
||||||
|
if (cachedResult === FALSE_PENDING_RETRY) {
|
||||||
|
const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })()
|
||||||
|
const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db")
|
||||||
|
const dbExists = existsSync(dbPath)
|
||||||
|
const result = versionOk && dbExists
|
||||||
|
cachedResult = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })()
|
const versionOk = (() => { versionCheckCalls.push(SQLITE_VERSION); return versionReturnValue })()
|
||||||
const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db")
|
const dbPath = join(TEST_DATA_DIR, "opencode", "opencode.db")
|
||||||
const dbExists = existsSync(dbPath)
|
const dbExists = existsSync(dbPath)
|
||||||
cachedResult = versionOk && dbExists
|
const result = versionOk && dbExists
|
||||||
return cachedResult
|
if (result) { cachedResult = true }
|
||||||
|
else { cachedResult = FALSE_PENDING_RETRY }
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSqliteBackendCache(): void {
|
function resetSqliteBackendCache(): void {
|
||||||
@ -77,7 +89,7 @@ describe("isSqliteBackend", () => {
|
|||||||
expect(versionCheckCalls).toContain("1.1.53")
|
expect(versionCheckCalls).toContain("1.1.53")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("caches the result and does not re-check on subsequent calls", () => {
|
it("caches true permanently and does not re-check", () => {
|
||||||
//#given
|
//#given
|
||||||
versionReturnValue = true
|
versionReturnValue = true
|
||||||
mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true })
|
mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true })
|
||||||
@ -91,4 +103,59 @@ describe("isSqliteBackend", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(versionCheckCalls.length).toBe(1)
|
expect(versionCheckCalls.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("retries once when first result is false, then caches permanently", () => {
|
||||||
|
//#given
|
||||||
|
versionReturnValue = true
|
||||||
|
|
||||||
|
//#when: first call — DB does not exist
|
||||||
|
const first = isSqliteBackend()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(first).toBe(false)
|
||||||
|
expect(versionCheckCalls.length).toBe(1)
|
||||||
|
|
||||||
|
//#when: second call — DB still does not exist (retry)
|
||||||
|
const second = isSqliteBackend()
|
||||||
|
|
||||||
|
//#then: retried once
|
||||||
|
expect(second).toBe(false)
|
||||||
|
expect(versionCheckCalls.length).toBe(2)
|
||||||
|
|
||||||
|
//#when: third call — no more retries
|
||||||
|
const third = isSqliteBackend()
|
||||||
|
|
||||||
|
//#then: no further checks
|
||||||
|
expect(third).toBe(false)
|
||||||
|
expect(versionCheckCalls.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("recovers on retry when DB appears after first false", () => {
|
||||||
|
//#given
|
||||||
|
versionReturnValue = true
|
||||||
|
|
||||||
|
//#when: first call — DB does not exist
|
||||||
|
const first = isSqliteBackend()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(first).toBe(false)
|
||||||
|
|
||||||
|
//#given: DB appears before retry
|
||||||
|
mkdirSync(join(TEST_DATA_DIR, "opencode"), { recursive: true })
|
||||||
|
writeFileSync(DB_PATH, "")
|
||||||
|
|
||||||
|
//#when: second call — retry finds DB
|
||||||
|
const second = isSqliteBackend()
|
||||||
|
|
||||||
|
//#then: recovers to true and caches permanently
|
||||||
|
expect(second).toBe(true)
|
||||||
|
expect(versionCheckCalls.length).toBe(2)
|
||||||
|
|
||||||
|
//#when: third call — cached true
|
||||||
|
const third = isSqliteBackend()
|
||||||
|
|
||||||
|
//#then: no further checks
|
||||||
|
expect(third).toBe(true)
|
||||||
|
expect(versionCheckCalls.length).toBe(2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@ -4,19 +4,29 @@ import { getDataDir } from "./data-path"
|
|||||||
import { isOpenCodeVersionAtLeast, OPENCODE_SQLITE_VERSION } from "./opencode-version"
|
import { isOpenCodeVersionAtLeast, OPENCODE_SQLITE_VERSION } from "./opencode-version"
|
||||||
|
|
||||||
const NOT_CACHED = Symbol("NOT_CACHED")
|
const NOT_CACHED = Symbol("NOT_CACHED")
|
||||||
let cachedResult: boolean | typeof NOT_CACHED = NOT_CACHED
|
const FALSE_PENDING_RETRY = Symbol("FALSE_PENDING_RETRY")
|
||||||
|
let cachedResult: true | false | typeof NOT_CACHED | typeof FALSE_PENDING_RETRY = NOT_CACHED
|
||||||
|
|
||||||
export function isSqliteBackend(): boolean {
|
export function isSqliteBackend(): boolean {
|
||||||
if (cachedResult !== NOT_CACHED) {
|
if (cachedResult === true) return true
|
||||||
return cachedResult
|
if (cachedResult === false) return false
|
||||||
|
|
||||||
|
const check = (): boolean => {
|
||||||
|
const versionOk = isOpenCodeVersionAtLeast(OPENCODE_SQLITE_VERSION)
|
||||||
|
const dbPath = join(getDataDir(), "opencode", "opencode.db")
|
||||||
|
return versionOk && existsSync(dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionOk = isOpenCodeVersionAtLeast(OPENCODE_SQLITE_VERSION)
|
if (cachedResult === FALSE_PENDING_RETRY) {
|
||||||
const dbPath = join(getDataDir(), "opencode", "opencode.db")
|
const result = check()
|
||||||
const dbExists = existsSync(dbPath)
|
cachedResult = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
cachedResult = versionOk && dbExists
|
const result = check()
|
||||||
return cachedResult
|
if (result) { cachedResult = true }
|
||||||
|
else { cachedResult = FALSE_PENDING_RETRY }
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetSqliteBackendCache(): void {
|
export function resetSqliteBackendCache(): void {
|
||||||
|
|||||||
@ -2,18 +2,12 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin
|
|||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import type { BackgroundTaskArgs } from "./types"
|
import type { BackgroundTaskArgs } from "./types"
|
||||||
import { BACKGROUND_TASK_DESCRIPTION } from "./constants"
|
import { BACKGROUND_TASK_DESCRIPTION } from "./constants"
|
||||||
import {
|
import { resolveMessageContext } from "../../features/hook-message-injector"
|
||||||
findFirstMessageWithAgent,
|
|
||||||
findFirstMessageWithAgentFromSDK,
|
|
||||||
findNearestMessageWithFields,
|
|
||||||
findNearestMessageWithFieldsFromSDK,
|
|
||||||
} from "../../features/hook-message-injector"
|
|
||||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { delay } from "./delay"
|
import { delay } from "./delay"
|
||||||
import { getMessageDir } from "./message-dir"
|
import { getMessageDir } from "./message-dir"
|
||||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
|
||||||
|
|
||||||
type ToolContextWithMetadata = {
|
type ToolContextWithMetadata = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@ -44,16 +38,11 @@ export function createBackgroundTask(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const messageDir = getMessageDir(ctx.sessionID)
|
const messageDir = getMessageDir(ctx.sessionID)
|
||||||
|
const { prevMessage, firstMessageAgent } = await resolveMessageContext(
|
||||||
const [prevMessage, firstMessageAgent] = isSqliteBackend()
|
ctx.sessionID,
|
||||||
? await Promise.all([
|
client,
|
||||||
findNearestMessageWithFieldsFromSDK(client, ctx.sessionID),
|
messageDir
|
||||||
findFirstMessageWithAgentFromSDK(client, ctx.sessionID),
|
)
|
||||||
])
|
|
||||||
: [
|
|
||||||
messageDir ? findNearestMessageWithFields(messageDir) : null,
|
|
||||||
messageDir ? findFirstMessageWithAgent(messageDir) : null,
|
|
||||||
]
|
|
||||||
|
|
||||||
const sessionAgent = getSessionAgent(ctx.sessionID)
|
const sessionAgent = getSessionAgent(ctx.sessionID)
|
||||||
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||||
|
|||||||
@ -1,18 +1,12 @@
|
|||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import {
|
import { resolveMessageContext } from "../../features/hook-message-injector"
|
||||||
findFirstMessageWithAgent,
|
|
||||||
findFirstMessageWithAgentFromSDK,
|
|
||||||
findNearestMessageWithFields,
|
|
||||||
findNearestMessageWithFieldsFromSDK,
|
|
||||||
} from "../../features/hook-message-injector"
|
|
||||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import type { CallOmoAgentArgs } from "./types"
|
import type { CallOmoAgentArgs } from "./types"
|
||||||
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
|
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
|
||||||
import { getMessageDir } from "./message-storage-directory"
|
import { getMessageDir } from "./message-storage-directory"
|
||||||
import { getSessionTools } from "../../shared/session-tools-store"
|
import { getSessionTools } from "../../shared/session-tools-store"
|
||||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
|
||||||
|
|
||||||
export async function executeBackgroundAgent(
|
export async function executeBackgroundAgent(
|
||||||
args: CallOmoAgentArgs,
|
args: CallOmoAgentArgs,
|
||||||
@ -22,16 +16,11 @@ export async function executeBackgroundAgent(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const messageDir = getMessageDir(toolContext.sessionID)
|
const messageDir = getMessageDir(toolContext.sessionID)
|
||||||
|
const { prevMessage, firstMessageAgent } = await resolveMessageContext(
|
||||||
const [prevMessage, firstMessageAgent] = isSqliteBackend()
|
toolContext.sessionID,
|
||||||
? await Promise.all([
|
client,
|
||||||
findNearestMessageWithFieldsFromSDK(client, toolContext.sessionID),
|
messageDir
|
||||||
findFirstMessageWithAgentFromSDK(client, toolContext.sessionID),
|
)
|
||||||
])
|
|
||||||
: [
|
|
||||||
messageDir ? findNearestMessageWithFields(messageDir) : null,
|
|
||||||
messageDir ? findFirstMessageWithAgent(messageDir) : null,
|
|
||||||
]
|
|
||||||
|
|
||||||
const sessionAgent = getSessionAgent(toolContext.sessionID)
|
const sessionAgent = getSessionAgent(toolContext.sessionID)
|
||||||
const parentAgent =
|
const parentAgent =
|
||||||
|
|||||||
@ -3,16 +3,10 @@ import type { BackgroundManager } from "../../features/background-agent"
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { consumeNewMessages } from "../../shared/session-cursor"
|
import { consumeNewMessages } from "../../shared/session-cursor"
|
||||||
import {
|
import { resolveMessageContext } from "../../features/hook-message-injector"
|
||||||
findFirstMessageWithAgent,
|
|
||||||
findFirstMessageWithAgentFromSDK,
|
|
||||||
findNearestMessageWithFields,
|
|
||||||
findNearestMessageWithFieldsFromSDK,
|
|
||||||
} from "../../features/hook-message-injector"
|
|
||||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { getMessageDir } from "./message-dir"
|
import { getMessageDir } from "./message-dir"
|
||||||
import { getSessionTools } from "../../shared/session-tools-store"
|
import { getSessionTools } from "../../shared/session-tools-store"
|
||||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
|
||||||
|
|
||||||
export async function executeBackground(
|
export async function executeBackground(
|
||||||
args: CallOmoAgentArgs,
|
args: CallOmoAgentArgs,
|
||||||
@ -28,16 +22,11 @@ export async function executeBackground(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const messageDir = getMessageDir(toolContext.sessionID)
|
const messageDir = getMessageDir(toolContext.sessionID)
|
||||||
|
const { prevMessage, firstMessageAgent } = await resolveMessageContext(
|
||||||
const [prevMessage, firstMessageAgent] = isSqliteBackend()
|
toolContext.sessionID,
|
||||||
? await Promise.all([
|
client,
|
||||||
findNearestMessageWithFieldsFromSDK(client, toolContext.sessionID),
|
messageDir
|
||||||
findFirstMessageWithAgentFromSDK(client, toolContext.sessionID),
|
)
|
||||||
])
|
|
||||||
: [
|
|
||||||
messageDir ? findNearestMessageWithFields(messageDir) : null,
|
|
||||||
messageDir ? findFirstMessageWithAgent(messageDir) : null,
|
|
||||||
]
|
|
||||||
|
|
||||||
const sessionAgent = getSessionAgent(toolContext.sessionID)
|
const sessionAgent = getSessionAgent(toolContext.sessionID)
|
||||||
const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
const parentAgent = toolContext.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -1,32 +1,21 @@
|
|||||||
import type { ToolContextWithMetadata } from "./types"
|
import type { ToolContextWithMetadata } from "./types"
|
||||||
import type { OpencodeClient } from "./types"
|
import type { OpencodeClient } from "./types"
|
||||||
import type { ParentContext } from "./executor-types"
|
import type { ParentContext } from "./executor-types"
|
||||||
import {
|
import { resolveMessageContext } from "../../features/hook-message-injector"
|
||||||
findFirstMessageWithAgent,
|
|
||||||
findFirstMessageWithAgentFromSDK,
|
|
||||||
findNearestMessageWithFields,
|
|
||||||
findNearestMessageWithFieldsFromSDK,
|
|
||||||
} from "../../features/hook-message-injector"
|
|
||||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
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"
|
|
||||||
|
|
||||||
export async function resolveParentContext(
|
export async function resolveParentContext(
|
||||||
ctx: ToolContextWithMetadata,
|
ctx: ToolContextWithMetadata,
|
||||||
client: OpencodeClient
|
client: OpencodeClient
|
||||||
): Promise<ParentContext> {
|
): Promise<ParentContext> {
|
||||||
const messageDir = getMessageDir(ctx.sessionID)
|
const messageDir = getMessageDir(ctx.sessionID)
|
||||||
|
const { prevMessage, firstMessageAgent } = await resolveMessageContext(
|
||||||
const [prevMessage, firstMessageAgent] = isSqliteBackend()
|
ctx.sessionID,
|
||||||
? await Promise.all([
|
client,
|
||||||
findNearestMessageWithFieldsFromSDK(client, ctx.sessionID),
|
messageDir
|
||||||
findFirstMessageWithAgentFromSDK(client, ctx.sessionID),
|
)
|
||||||
])
|
|
||||||
: [
|
|
||||||
messageDir ? findNearestMessageWithFields(messageDir) : null,
|
|
||||||
messageDir ? findFirstMessageWithAgent(messageDir) : null,
|
|
||||||
]
|
|
||||||
|
|
||||||
const sessionAgent = getSessionAgent(ctx.sessionID)
|
const sessionAgent = getSessionAgent(ctx.sessionID)
|
||||||
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -31,6 +31,13 @@ mock.module("../../shared/opencode-storage-detection", () => ({
|
|||||||
resetSqliteBackendCache: () => {},
|
resetSqliteBackendCache: () => {},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mock.module("../../shared/opencode-storage-paths", () => ({
|
||||||
|
OPENCODE_STORAGE: TEST_DIR,
|
||||||
|
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
|
||||||
|
PART_STORAGE: TEST_PART_STORAGE,
|
||||||
|
SESSION_STORAGE: TEST_SESSION_STORAGE,
|
||||||
|
}))
|
||||||
|
|
||||||
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
|
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
|
||||||
await import("./storage")
|
await import("./storage")
|
||||||
|
|
||||||
|
|||||||
@ -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 []
|
||||||
@ -121,13 +122,9 @@ 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) {
|
||||||
try {
|
const response = await sdkClient.session.list()
|
||||||
const response = await sdkClient.session.list()
|
const sessions = normalizeSDKResponse(response, [] as Array<{ id?: string }>)
|
||||||
const sessions = (response.data || []) as Array<{ id?: string }>
|
return sessions.some((s) => s.id === sessionID)
|
||||||
return sessions.some((s) => s.id === sessionID)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return getMessageDir(sessionID) !== null
|
return getMessageDir(sessionID) !== null
|
||||||
}
|
}
|
||||||
@ -137,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
|
||||||
@ -155,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) => ({
|
||||||
@ -258,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 || "",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user