fix: resolve #3124 #3125 #3127 session tools, cache priming, and compaction loop

- #3124: Session tools now merge SDK and file-backed sessions for SQLite backend
- #3125: Cache priming fixed for OpenCode >=1.3.14 empty workspace
- #3127: Activity-based progress detection prevents infinite compaction on Kimi/Minimax

All 29 new tests pass, 4885 total tests passing.
This commit is contained in:
YeonGyu-Kim
2026-04-05 09:30:19 +09:00
parent aeb9c97c30
commit 130f4ac080
22 changed files with 538 additions and 114 deletions
@@ -165,6 +165,58 @@ describe("pollSyncSession", () => {
expect(callCount).toBeGreaterThan(1)
})
test("keeps polling when finish is 'stop' but assistant still has tool-call parts", async () => {
//#given
const { pollSyncSession } = require("./sync-session-poller")
let callCount = 0
const mockClient = {
session: {
messages: async () => {
callCount++
if (callCount <= 1) {
return {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
],
}
}
return {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
{ info: { id: "msg_003", role: "user", time: { created: 3000 } } },
{
info: { id: "msg_004", role: "assistant", time: { created: 4000 }, finish: "stop" },
parts: [{ type: "text", text: "Done" }],
},
],
}
},
status: async () => ({ data: { "ses_test": { type: "idle" } } }),
},
}
//#when
const result = await pollSyncSession(createMockCtx(), mockClient, {
sessionID: "ses_test",
agentToUse: "test-agent",
toastManager: null,
taskId: undefined,
})
//#then
expect(result).toBeNull()
expect(callCount).toBeGreaterThan(1)
})
test("does not complete when assistant id < user id (user sent after assistant)", async () => {
//#given - assistant finished but user message came after it (agent still processing)
const { pollSyncSession } = require("./sync-session-poller")
@@ -421,6 +473,44 @@ describe("pollSyncSession", () => {
expect(result).toBe(false)
})
test("returns false when finish is stop but assistant has tool-call parts", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - provider marks stop even though tool execution is still pending
const messages = [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false because tool execution is still pending
expect(result).toBe(false)
})
test("returns false when finish is end_turn but assistant has tool-call parts", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - assistant emitted a terminal finish but still contains pending tool calls
const messages = [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false because tool execution is still pending
expect(result).toBe(false)
})
test("returns false when user message has missing info.id field", () => {
const { isSessionComplete } = require("./sync-session-poller")
@@ -438,7 +528,7 @@ describe("pollSyncSession", () => {
//#then - should return false (missing user id)
expect(result).toBe(false)
})
})
})
})
@@ -5,6 +5,7 @@ import { log } from "../../shared/logger"
import { normalizeSDKResponse } from "../../shared"
const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"])
const PENDING_TOOL_PART_TYPES = new Set(["tool", "tool_use", "tool-call"])
function wait(milliseconds: number): Promise<void> {
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)
@@ -35,6 +36,7 @@ export function isSessionComplete(messages: SessionMessage[]): boolean {
if (!lastAssistant?.info?.finish) return false
if (NON_TERMINAL_FINISH_REASONS.has(lastAssistant.info.finish)) return false
if (lastAssistant.parts?.some((part) => part.type && PENDING_TOOL_PART_TYPES.has(part.type))) return false
if (!lastUser?.info?.id || !lastAssistant?.info?.id) return false
return lastUser.info.id < lastAssistant.info.id
}
@@ -128,6 +128,60 @@ describe("session-manager storage fallback", () => {
expect(sessions[0].id).toBe("ses_file")
})
test("#given empty SDK list response #when getMainSessions runs #then returns file-backed pre-migration sessions", async () => {
createSessionMetadata("proj_test", "ses_file", "/workspace/project", 2_000)
mockClient.session.list.mockImplementation(() => Promise.resolve({ data: [] }))
const sessions = await storage.getMainSessions({ directory: "/workspace/project" })
expect(sessions).toHaveLength(1)
expect(sessions[0].id).toBe("ses_file")
})
test("#given SDK and file sessions overlap #when getMainSessions runs #then dedupes by id and keeps SDK metadata", async () => {
createSessionMetadata("proj_test", "ses_file", "/workspace/project", 2_000)
createSessionMetadata("proj_test", "ses_sdk", "/workspace/project", 1_500)
mockClient.session.list.mockImplementation(() => Promise.resolve({
data: [
{
id: "ses_sdk",
projectID: "sdk_project",
directory: "/workspace/project",
time: { created: 3_000, updated: 4_000 },
},
],
}))
const sessions = await storage.getMainSessions({ directory: "/workspace/project" })
expect(sessions).toHaveLength(2)
expect(sessions.map((session) => session.id)).toEqual(["ses_sdk", "ses_file"])
expect(sessions[0].projectID).toBe("sdk_project")
})
test("#given empty SDK session list #when getAllSessions runs #then returns file-backed session ids", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.list.mockImplementation(() => Promise.resolve({ data: [] }))
const sessionIds = await storage.getAllSessions()
expect(sessionIds).toEqual(["ses_file"])
})
test("#given SDK and file session ids overlap #when getAllSessions runs #then returns deduped union", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
createSessionMessage("ses_sdk", "msg_002", 2_000)
mockClient.session.list.mockImplementation(() => Promise.resolve({
data: [
{ id: "ses_sdk" },
],
}))
const sessionIds = await storage.getAllSessions()
expect(sessionIds).toEqual(["ses_sdk", "ses_file"])
})
test("#given unreachable SDK messages error #when readSessionMessages runs #then falls back to file messages", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.messages.mockImplementation(() => Promise.reject(createSdkUnavailableError("Unable to connect to http://localhost:4096")))
@@ -138,6 +192,16 @@ describe("session-manager storage fallback", () => {
expect(messages[0].id).toBe("msg_001")
})
test("#given empty SDK messages response #when readSessionMessages runs #then falls back to file messages", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.messages.mockImplementation(() => Promise.resolve({ data: [] }))
const messages = await storage.readSessionMessages("ses_file")
expect(messages).toHaveLength(1)
expect(messages[0].id).toBe("msg_001")
})
test("#given unreachable SDK todo response #when readSessionTodos runs #then falls back to file todos", async () => {
createSessionTodo("ses_file", [{ id: "todo_1", content: "Fallback todo", status: "pending" }])
mockClient.session.todo.mockImplementation(() => Promise.resolve({ error: createSdkUnavailableError("network error: server unreachable") }))
@@ -148,6 +212,16 @@ describe("session-manager storage fallback", () => {
expect(todos[0].content).toBe("Fallback todo")
})
test("#given empty SDK todo response #when readSessionTodos runs #then falls back to file todos", async () => {
createSessionTodo("ses_file", [{ id: "todo_1", content: "Fallback todo", status: "pending" }])
mockClient.session.todo.mockImplementation(() => Promise.resolve({ data: [] }))
const todos = await storage.readSessionTodos("ses_file")
expect(todos).toHaveLength(1)
expect(todos[0].content).toBe("Fallback todo")
})
test("#given unreachable SDK list error #when sessionExists runs #then falls back to file existence", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.list.mockImplementation(() => Promise.reject(createSdkUnavailableError("ETIMEDOUT while connecting")))
@@ -157,6 +231,15 @@ describe("session-manager storage fallback", () => {
expect(exists).toBe(true)
})
test("#given empty SDK session list #when sessionExists runs #then falls back to file existence", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.list.mockImplementation(() => Promise.resolve({ data: [] }))
const exists = await storage.sessionExists("ses_file")
expect(exists).toBe(true)
})
test("#given semantic SDK error #when readSessionMessages runs #then rethrows instead of hiding bug", async () => {
mockClient.session.messages.mockImplementation(() => Promise.resolve({ error: new Error("session not found") }))
+33 -5
View File
@@ -9,6 +9,27 @@ export interface GetMainSessionsOptions {
directory?: string
}
function mergeSessionMetadataLists(
sdkSessions: SessionMetadata[],
fileSessions: SessionMetadata[],
): SessionMetadata[] {
const merged = new Map<string, SessionMetadata>()
for (const session of fileSessions) {
merged.set(session.id, session)
}
for (const session of sdkSessions) {
merged.set(session.id, session)
}
return [...merged.values()].sort((a, b) => b.time.updated - a.time.updated)
}
function mergeSessionIds(sdkSessionIds: string[], fileSessionIds: string[]): string[] {
return [...new Set([...sdkSessionIds, ...fileSessionIds])]
}
// SDK client reference for beta mode
let sdkClient: PluginInput["client"] | null = null
@@ -23,7 +44,9 @@ export function resetStorageClient(): void {
export async function getMainSessions(options: GetMainSessionsOptions): Promise<SessionMetadata[]> {
if (isSqliteBackend() && sdkClient) {
try {
return await getSdkMainSessions(sdkClient, options.directory)
const sdkSessions = await getSdkMainSessions(sdkClient, options.directory)
const fileSessions = await getFileMainSessions(options.directory)
return mergeSessionMetadataLists(sdkSessions, fileSessions)
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file session list after SDK unavailable error", { error: String(error) })
@@ -36,7 +59,9 @@ export async function getMainSessions(options: GetMainSessionsOptions): Promise<
export async function getAllSessions(): Promise<string[]> {
if (isSqliteBackend() && sdkClient) {
try {
return await getSdkAllSessions(sdkClient)
const sdkSessionIds = await getSdkAllSessions(sdkClient)
const fileSessionIds = await getFileAllSessions()
return mergeSessionIds(sdkSessionIds, fileSessionIds)
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file session ids after SDK unavailable error", { error: String(error) })
@@ -51,7 +76,8 @@ export { getMessageDir } from "../../shared/opencode-message-dir"
export async function sessionExists(sessionID: string): Promise<boolean> {
if (isSqliteBackend() && sdkClient) {
try {
return await sdkSessionExists(sdkClient, sessionID)
const existsInSdk = await sdkSessionExists(sdkClient, sessionID)
if (existsInSdk) return true
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file sessionExists after SDK unavailable error", { error: String(error), sessionID })
@@ -63,7 +89,8 @@ export async function sessionExists(sessionID: string): Promise<boolean> {
export async function readSessionMessages(sessionID: string): Promise<SessionMessage[]> {
if (isSqliteBackend() && sdkClient) {
try {
return await getSdkSessionMessages(sdkClient, sessionID)
const sdkMessages = await getSdkSessionMessages(sdkClient, sessionID)
if (sdkMessages.length > 0) return sdkMessages
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file session messages after SDK unavailable error", { error: String(error), sessionID })
@@ -76,7 +103,8 @@ export async function readSessionMessages(sessionID: string): Promise<SessionMes
export async function readSessionTodos(sessionID: string): Promise<TodoItem[]> {
if (isSqliteBackend() && sdkClient) {
try {
return await getSdkSessionTodos(sdkClient, sessionID)
const sdkTodos = await getSdkSessionTodos(sdkClient, sessionID)
if (sdkTodos.length > 0) return sdkTodos
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file session todos after SDK unavailable error", { error: String(error), sessionID })