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
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import * as fs from "node:fs"
import * as path from "node:path"
import * as os from "node:os"
import { PACKAGE_NAME } from "../constants"
import { updatePinnedVersion, revertPinnedVersion } from "./pinned-version-updater"
describe("pinned-version-updater", () => {
@@ -21,18 +22,18 @@ describe("pinned-version-updater", () => {
test("updates pinned version in config", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-openagent@3.1.8"],
plugin: [`${PACKAGE_NAME}@3.1.8`],
})
fs.writeFileSync(configPath, config)
//#when
const result = updatePinnedVersion(configPath, "oh-my-openagent@3.1.8", "3.4.0")
const result = updatePinnedVersion(configPath, `${PACKAGE_NAME}@3.1.8`, "3.4.0")
//#then
expect(result).toBe(true)
const updated = fs.readFileSync(configPath, "utf-8")
expect(updated).toContain("oh-my-openagent@3.4.0")
expect(updated).not.toContain("oh-my-openagent@3.1.8")
expect(updated).toContain(`${PACKAGE_NAME}@3.4.0`)
expect(updated).not.toContain(`${PACKAGE_NAME}@3.1.8`)
})
test("returns false when entry not found", () => {
@@ -43,7 +44,7 @@ describe("pinned-version-updater", () => {
fs.writeFileSync(configPath, config)
//#when
const result = updatePinnedVersion(configPath, "oh-my-openagent@3.1.8", "3.4.0")
const result = updatePinnedVersion(configPath, `${PACKAGE_NAME}@3.1.8`, "3.4.0")
//#then
expect(result).toBe(false)
@@ -55,7 +56,7 @@ describe("pinned-version-updater", () => {
fs.writeFileSync(configPath, config)
//#when
const result = updatePinnedVersion(configPath, "oh-my-openagent@3.1.8", "3.4.0")
const result = updatePinnedVersion(configPath, `${PACKAGE_NAME}@3.1.8`, "3.4.0")
//#then
expect(result).toBe(false)
@@ -66,46 +67,46 @@ describe("pinned-version-updater", () => {
test("reverts from failed version back to original entry", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-openagent@3.4.0"],
plugin: [`${PACKAGE_NAME}@3.4.0`],
})
fs.writeFileSync(configPath, config)
//#when
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-openagent@3.1.8")
const result = revertPinnedVersion(configPath, "3.4.0", `${PACKAGE_NAME}@3.1.8`)
//#then
expect(result).toBe(true)
const reverted = fs.readFileSync(configPath, "utf-8")
expect(reverted).toContain("oh-my-openagent@3.1.8")
expect(reverted).not.toContain("oh-my-openagent@3.4.0")
expect(reverted).toContain(`${PACKAGE_NAME}@3.1.8`)
expect(reverted).not.toContain(`${PACKAGE_NAME}@3.4.0`)
})
test("reverts to unpinned entry", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-openagent@3.4.0"],
plugin: [`${PACKAGE_NAME}@3.4.0`],
})
fs.writeFileSync(configPath, config)
//#when
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-openagent")
const result = revertPinnedVersion(configPath, "3.4.0", PACKAGE_NAME)
//#then
expect(result).toBe(true)
const reverted = fs.readFileSync(configPath, "utf-8")
expect(reverted).toContain('"oh-my-openagent"')
expect(reverted).not.toContain("oh-my-openagent@3.4.0")
expect(reverted).toContain(`"${PACKAGE_NAME}"`)
expect(reverted).not.toContain(`${PACKAGE_NAME}@3.4.0`)
})
test("returns false when failed version not found", () => {
//#given
const config = JSON.stringify({
plugin: ["oh-my-openagent@3.1.8"],
plugin: [`${PACKAGE_NAME}@3.1.8`],
})
fs.writeFileSync(configPath, config)
//#when
const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-openagent@3.1.8")
const result = revertPinnedVersion(configPath, "3.4.0", `${PACKAGE_NAME}@3.1.8`)
//#then
expect(result).toBe(false)
@@ -116,18 +117,18 @@ describe("pinned-version-updater", () => {
test("config returns to original state after update + revert", () => {
//#given
const originalConfig = JSON.stringify({
plugin: ["oh-my-openagent@3.1.8"],
plugin: [`${PACKAGE_NAME}@3.1.8`],
})
fs.writeFileSync(configPath, originalConfig)
//#when
updatePinnedVersion(configPath, "oh-my-openagent@3.1.8", "3.4.0")
revertPinnedVersion(configPath, "3.4.0", "oh-my-openagent@3.1.8")
updatePinnedVersion(configPath, `${PACKAGE_NAME}@3.1.8`, "3.4.0")
revertPinnedVersion(configPath, "3.4.0", `${PACKAGE_NAME}@3.1.8`)
//#then
const finalConfig = fs.readFileSync(configPath, "utf-8")
expect(finalConfig).toContain("oh-my-openagent@3.1.8")
expect(finalConfig).not.toContain("oh-my-openagent@3.4.0")
expect(finalConfig).toContain(`${PACKAGE_NAME}@3.1.8`)
expect(finalConfig).not.toContain(`${PACKAGE_NAME}@3.4.0`)
})
})
})
})
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { PACKAGE_NAME } from "../constants"
import { findPluginEntry } from "./plugin-entry"
describe("findPluginEntry", () => {
@@ -21,7 +22,7 @@ describe("findPluginEntry", () => {
test("returns unpinned for bare package name", () => {
// #given plugin is configured without a tag
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-openagent"] }))
fs.writeFileSync(configPath, JSON.stringify({ plugin: [PACKAGE_NAME] }))
// #when plugin entry is detected
const pluginInfo = findPluginEntry(temporaryDirectory)
@@ -34,7 +35,7 @@ describe("findPluginEntry", () => {
test("returns unpinned for latest dist-tag", () => {
// #given plugin is configured with latest dist-tag
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-openagent@latest"] }))
fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PACKAGE_NAME}@latest`] }))
// #when plugin entry is detected
const pluginInfo = findPluginEntry(temporaryDirectory)
@@ -47,7 +48,7 @@ describe("findPluginEntry", () => {
test("returns unpinned for beta dist-tag", () => {
// #given plugin is configured with beta dist-tag
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-openagent@beta"] }))
fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PACKAGE_NAME}@beta`] }))
// #when plugin entry is detected
const pluginInfo = findPluginEntry(temporaryDirectory)
@@ -60,7 +61,7 @@ describe("findPluginEntry", () => {
test("returns pinned for explicit semver", () => {
// #given plugin is configured with explicit version
fs.writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-openagent@3.5.2"] }))
fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PACKAGE_NAME}@3.5.2`] }))
// #when plugin entry is detected
const pluginInfo = findPluginEntry(temporaryDirectory)
@@ -11,7 +11,7 @@ interface CachePackageJson {
export interface SyncResult {
synced: boolean
error: "file_not_found" | "plugin_not_in_deps" | "parse_error" | "write_error" | null
error: "parse_error" | "write_error" | null
message?: string
}
@@ -32,12 +32,33 @@ function getIntentVersion(pluginInfo: PluginEntryInfo): string {
return pluginInfo.pinnedVersion
}
function writeCachePackageJson(
cachePackageJsonPath: string,
pkgJson: CachePackageJson,
): SyncResult {
const tmpPath = `${cachePackageJsonPath}.${crypto.randomUUID()}`
try {
fs.mkdirSync(path.dirname(cachePackageJsonPath), { recursive: true })
fs.writeFileSync(tmpPath, JSON.stringify(pkgJson, null, 2))
fs.renameSync(tmpPath, cachePackageJsonPath)
return { synced: true, error: null }
} catch (err) {
log("[auto-update-checker] Failed to write cache package.json:", err)
safeUnlink(tmpPath)
return { synced: false, error: "write_error", message: "Failed to write cache package.json" }
}
}
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncResult {
const cachePackageJsonPath = path.join(CACHE_DIR, "package.json")
const intentVersion = getIntentVersion(pluginInfo)
if (!fs.existsSync(cachePackageJsonPath)) {
log("[auto-update-checker] Cache package.json not found, nothing to sync")
return { synced: false, error: "file_not_found", message: "Cache package.json not found" }
log("[auto-update-checker] Cache package.json missing, creating workspace package.json", { intentVersion })
return {
...writeCachePackageJson(cachePackageJsonPath, { dependencies: { [PACKAGE_NAME]: intentVersion } }),
message: `Created cache package.json with: ${intentVersion}`,
}
}
let content: string
@@ -58,12 +79,21 @@ export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncR
}
if (!pkgJson || !pkgJson.dependencies?.[PACKAGE_NAME]) {
log("[auto-update-checker] Plugin not in cache package.json dependencies, nothing to sync")
return { synced: false, error: "plugin_not_in_deps", message: "Plugin not in cache package.json dependencies" }
log("[auto-update-checker] Plugin missing from cache package.json dependencies, adding dependency", { intentVersion })
const nextPkgJson = {
...(pkgJson ?? {}),
dependencies: {
...(pkgJson?.dependencies ?? {}),
[PACKAGE_NAME]: intentVersion,
},
}
return {
...writeCachePackageJson(cachePackageJsonPath, nextPkgJson),
message: `Added ${PACKAGE_NAME}: ${intentVersion}`,
}
}
const currentVersion = pkgJson.dependencies[PACKAGE_NAME]
const intentVersion = getIntentVersion(pluginInfo)
if (currentVersion === intentVersion) {
log("[auto-update-checker] Cache package.json already matches intent:", intentVersion)
@@ -84,15 +114,8 @@ export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncR
}
pkgJson.dependencies[PACKAGE_NAME] = intentVersion
const tmpPath = `${cachePackageJsonPath}.${crypto.randomUUID()}`
try {
fs.writeFileSync(tmpPath, JSON.stringify(pkgJson, null, 2))
fs.renameSync(tmpPath, cachePackageJsonPath)
return { synced: true, error: null, message: `Updated: "${currentVersion}" → "${intentVersion}"` }
} catch (err) {
log("[auto-update-checker] Failed to write cache package.json:", err)
safeUnlink(tmpPath)
return { synced: false, error: "write_error", message: "Failed to write cache package.json" }
return {
...writeCachePackageJson(cachePackageJsonPath, pkgJson),
message: `Updated: "${currentVersion}" → "${intentVersion}"`,
}
}
@@ -6,9 +6,9 @@ describe("auto-update-checker constants", () => {
it("uses the OpenCode cache directory for installed package metadata", async () => {
const { CACHE_DIR, INSTALLED_PACKAGE_JSON, PACKAGE_NAME } = await import(`./constants?test=${Date.now()}`)
expect(CACHE_DIR).toBe(getOpenCodeCacheDir())
expect(CACHE_DIR).toBe(join(getOpenCodeCacheDir(), "packages"))
expect(INSTALLED_PACKAGE_JSON).toBe(
join(getOpenCodeCacheDir(), "node_modules", PACKAGE_NAME, "package.json")
join(getOpenCodeCacheDir(), "packages", "node_modules", PACKAGE_NAME, "package.json")
)
})
})
+3 -2
View File
@@ -7,8 +7,9 @@ export const PACKAGE_NAME = "oh-my-openagent"
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
export const NPM_FETCH_TIMEOUT = 5000
export const CACHE_DIR = getOpenCodeCacheDir()
export const VERSION_FILE = path.join(CACHE_DIR, "version")
export const CACHE_ROOT_DIR = getOpenCodeCacheDir()
export const CACHE_DIR = path.join(CACHE_ROOT_DIR, "packages")
export const VERSION_FILE = path.join(CACHE_ROOT_DIR, "version")
export function getWindowsAppdataDir(): string | null {
if (process.platform !== "win32") return null
@@ -33,6 +33,10 @@ type BackgroundUpdateCheckRunner = (
getToastMessage: (isUpdate: boolean, latestVersion?: string) => string,
) => Promise<void>
function getCacheWorkspaceDir(deps: BackgroundUpdateCheckDeps): string {
return deps.join(deps.getOpenCodeCacheDir(), "packages")
}
const defaultDeps: BackgroundUpdateCheckDeps = {
existsSync,
join,
@@ -60,7 +64,7 @@ function getPinnedVersionToastMessage(latestVersion: string): string {
*/
function resolveActiveInstallWorkspace(deps: BackgroundUpdateCheckDeps): string {
const configPaths = deps.getOpenCodeConfigPaths({ binary: "opencode" })
const cacheDir = deps.getOpenCodeCacheDir()
const cacheDir = getCacheWorkspaceDir(deps)
const configInstallPath = deps.join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json")
const cacheInstallPath = deps.join(cacheDir, "node_modules", PACKAGE_NAME, "package.json")
@@ -76,6 +80,12 @@ function resolveActiveInstallWorkspace(deps: BackgroundUpdateCheckDeps): string
return cacheDir
}
const cachePackageJsonPath = deps.join(cacheDir, "package.json")
if (deps.existsSync(cachePackageJsonPath)) {
deps.log(`[auto-update-checker] Active workspace: cache-dir (${cacheDir}, package.json present)`)
return cacheDir
}
// Default to config-dir if neither exists (matches doctor behavior)
deps.log(`[auto-update-checker] Active workspace: config-dir (default, no install detected)`)
return configPaths.configDir
@@ -95,6 +105,19 @@ async function runBunInstallSafe(workspaceDir: string, deps: BackgroundUpdateChe
}
}
async function primeCacheWorkspace(
activeWorkspace: string,
deps: BackgroundUpdateCheckDeps,
): Promise<boolean> {
const cacheWorkspace = getCacheWorkspaceDir(deps)
if (activeWorkspace === cacheWorkspace) {
return true
}
deps.log(`[auto-update-checker] Priming cache workspace after install: ${cacheWorkspace}`)
return runBunInstallSafe(cacheWorkspace, deps)
}
export function createBackgroundUpdateCheckRunner(
overrides: Partial<BackgroundUpdateCheckDeps> = {},
): BackgroundUpdateCheckRunner {
@@ -156,6 +179,13 @@ export function createBackgroundUpdateCheckRunner(
const installSuccess = await runBunInstallSafe(activeWorkspace, deps)
if (installSuccess) {
const cachePrimed = await primeCacheWorkspace(activeWorkspace, deps)
if (!cachePrimed) {
await deps.showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
deps.log("[auto-update-checker] cache workspace priming failed after install")
return
}
await deps.showAutoUpdatedToast(ctx, currentVersion, latestVersion)
deps.log(`[auto-update-checker] Update installed: ${currentVersion}${latestVersion}`)
return
@@ -16,6 +16,14 @@ import { acknowledgeCompactionGuard, isCompactionGuardActive } from "./compactio
import type { SessionStateStore } from "./session-state"
import { startCountdown } from "./countdown"
function shouldAllowActivityProgress(modelID: string | undefined): boolean {
if (!modelID) {
return false
}
return !modelID.toLowerCase().includes("codex")
}
export async function handleSessionIdle(args: {
ctx: PluginInput
sessionID: string
@@ -182,7 +190,12 @@ export async function handleSessionIdle(args: {
return
}
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos)
const progressUpdate = sessionStateStore.trackContinuationProgress(
sessionID,
incompleteCount,
todos,
{ allowActivityProgress: shouldAllowActivityProgress(resolvedInfo?.model?.modelID) },
)
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
return
}
@@ -28,6 +28,7 @@ export function handleNonIdleEvent(args: {
if (state) {
state.abortDetectedAt = undefined
state.wasCancelled = false
sessionStateStore.recordActivity(sessionID)
}
sessionStateStore.cancelCountdown(sessionID)
return
@@ -38,6 +39,7 @@ export function handleNonIdleEvent(args: {
if (state) {
state.abortDetectedAt = undefined
state.wasCancelled = false
sessionStateStore.recordActivity(sessionID)
}
sessionStateStore.cancelCountdown(sessionID)
return
@@ -56,7 +58,10 @@ export function handleNonIdleEvent(args: {
if (targetSessionID) {
const state = sessionStateStore.getExistingState(targetSessionID)
if (state) state.abortDetectedAt = undefined
if (state) {
state.abortDetectedAt = undefined
sessionStateStore.recordActivity(targetSessionID)
}
sessionStateStore.cancelCountdown(targetSessionID)
}
return
@@ -69,6 +74,7 @@ export function handleNonIdleEvent(args: {
if (state) {
state.abortDetectedAt = undefined
state.wasCancelled = false
sessionStateStore.recordActivity(sessionID)
}
sessionStateStore.cancelCountdown(sessionID)
}
@@ -82,6 +88,7 @@ export function handleNonIdleEvent(args: {
if (state) {
state.abortDetectedAt = undefined
state.wasCancelled = false
sessionStateStore.recordActivity(sessionID)
}
sessionStateStore.cancelCountdown(sessionID)
}
@@ -143,4 +143,56 @@ describe("createSessionStateStore", () => {
expect(stagnatedAgainUpdate.hasProgressed).toBe(false)
expect(stagnatedAgainUpdate.stagnationCount).toBe(1)
})
test("given non-codex activity happens after a successful continuation, treats it as progress", () => {
// given
const sessionID = "ses-non-codex-activity-progress"
const state = sessionStateStore.getState(sessionID)
const todos = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
]
sessionStateStore.trackContinuationProgress(sessionID, 1, todos)
state.awaitingPostInjectionProgressCheck = true
sessionStateStore.recordActivity(sessionID)
// when
const progressUpdate = sessionStateStore.trackContinuationProgress(
sessionID,
1,
todos,
{ allowActivityProgress: true },
)
// then
expect(progressUpdate.hasProgressed).toBe(true)
expect(progressUpdate.progressSource).toBe("activity")
expect(progressUpdate.stagnationCount).toBe(0)
})
test("given codex activity happens after a successful continuation, keeps counting stagnation", () => {
// given
const sessionID = "ses-codex-activity-stagnation"
const state = sessionStateStore.getState(sessionID)
const todos = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
]
sessionStateStore.trackContinuationProgress(sessionID, 1, todos)
state.awaitingPostInjectionProgressCheck = true
sessionStateStore.recordActivity(sessionID)
// when
const progressUpdate = sessionStateStore.trackContinuationProgress(
sessionID,
1,
todos,
{ allowActivityProgress: false },
)
// then
expect(progressUpdate.hasProgressed).toBe(false)
expect(progressUpdate.progressSource).toBe("none")
expect(progressUpdate.stagnationCount).toBe(1)
})
})
@@ -1,4 +1,4 @@
import type { SessionState, Todo } from "./types"
import type { ContinuationProgressOptions, SessionState, Todo } from "./types"
type TimerHandle = number | { unref?: () => void }
@@ -16,6 +16,8 @@ interface TrackedSessionState {
lastAccessedAt: number
lastCompletedCount?: number
lastTodoSnapshot?: string
activitySignalCount: number
lastObservedActivitySignalCount?: number
}
export interface ContinuationProgressUpdate {
@@ -23,13 +25,19 @@ export interface ContinuationProgressUpdate {
previousStagnationCount: number
stagnationCount: number
hasProgressed: boolean
progressSource: "none" | "todo"
progressSource: "none" | "todo" | "activity"
}
export interface SessionStateStore {
getState: (sessionID: string) => SessionState
getExistingState: (sessionID: string) => SessionState | undefined
trackContinuationProgress: (sessionID: string, incompleteCount: number, todos?: Todo[]) => ContinuationProgressUpdate
recordActivity: (sessionID: string) => void
trackContinuationProgress: (
sessionID: string,
incompleteCount: number,
todos?: Todo[],
options?: ContinuationProgressOptions,
) => ContinuationProgressUpdate
resetContinuationProgress: (sessionID: string) => void
cancelCountdown: (sessionID: string) => void
cleanup: (sessionID: string) => void
@@ -96,6 +104,7 @@ export function createSessionStateStore(): SessionStateStore {
const trackedSession: TrackedSessionState = {
state: rawState,
lastAccessedAt: Date.now(),
activitySignalCount: 0,
}
sessions.set(sessionID, trackedSession)
return trackedSession
@@ -114,10 +123,16 @@ export function createSessionStateStore(): SessionStateStore {
return undefined
}
function recordActivity(sessionID: string): void {
const trackedSession = getTrackedSession(sessionID)
trackedSession.activitySignalCount += 1
}
function trackContinuationProgress(
sessionID: string,
incompleteCount: number,
todos?: Todo[]
todos?: Todo[],
options: ContinuationProgressOptions = {},
): ContinuationProgressUpdate {
const trackedSession = getTrackedSession(sessionID)
const state = trackedSession.state
@@ -125,6 +140,7 @@ export function createSessionStateStore(): SessionStateStore {
const previousStagnationCount = state.stagnationCount
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined
const currentActivitySignalCount = trackedSession.activitySignalCount
const hasCompletedMoreTodos =
currentCompletedCount !== undefined
&& trackedSession.lastCompletedCount !== undefined
@@ -133,6 +149,10 @@ export function createSessionStateStore(): SessionStateStore {
currentTodoSnapshot !== undefined
&& trackedSession.lastTodoSnapshot !== undefined
&& currentTodoSnapshot !== trackedSession.lastTodoSnapshot
const hasObservedExternalActivity =
options.allowActivityProgress === true
&& trackedSession.lastObservedActivitySignalCount !== undefined
&& currentActivitySignalCount > trackedSession.lastObservedActivitySignalCount
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
state.lastIncompleteCount = incompleteCount
@@ -142,6 +162,7 @@ export function createSessionStateStore(): SessionStateStore {
if (currentTodoSnapshot !== undefined) {
trackedSession.lastTodoSnapshot = currentTodoSnapshot
}
trackedSession.lastObservedActivitySignalCount = currentActivitySignalCount
if (previousIncompleteCount === undefined) {
state.stagnationCount = 0
@@ -156,7 +177,9 @@ export function createSessionStateStore(): SessionStateStore {
const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged
? "todo"
: "none"
: hasObservedExternalActivity
? "activity"
: "none"
if (progressSource !== "none") {
state.stagnationCount = 0
@@ -204,6 +227,8 @@ export function createSessionStateStore(): SessionStateStore {
state.awaitingPostInjectionProgressCheck = false
trackedSession.lastCompletedCount = undefined
trackedSession.lastTodoSnapshot = undefined
trackedSession.activitySignalCount = 0
trackedSession.lastObservedActivitySignalCount = undefined
}
function cancelCountdown(sessionID: string): void {
@@ -247,6 +272,7 @@ export function createSessionStateStore(): SessionStateStore {
return {
getState,
getExistingState,
recordActivity,
trackContinuationProgress,
resetContinuationProgress,
cancelCountdown,
@@ -65,3 +65,7 @@ export interface ResolveLatestMessageInfoResult {
resolvedInfo?: ResolvedMessageInfo
encounteredCompaction: boolean
}
export interface ContinuationProgressOptions {
allowActivityProgress?: boolean
}
@@ -1,11 +1,11 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { createBackgroundUpdateCheckRunner } from "../auto-update-checker/hook/background-update-check"
import type { PluginEntryInfo } from "../auto-update-checker/checker"
import type { SyncResult } from "../auto-update-checker/checker/sync-package-json"
type ToastMessageGetter = (isUpdate: boolean, version?: string) => string
let importCounter = 0
function createPluginEntry(overrides?: Partial<PluginEntryInfo>): PluginEntryInfo {
return {
@@ -35,7 +35,9 @@ const mockSyncCachePackageJsonToIntent = mock((_pluginInfo: PluginEntryInfo): Sy
error: null,
}))
function createRunner() {
async function createRunner() {
const { createBackgroundUpdateCheckRunner } = await import(`../auto-update-checker/hook/background-update-check?test=${importCounter++}`)
return createBackgroundUpdateCheckRunner({
existsSync: () => false,
join: (...parts) => parts.join("/"),
@@ -66,6 +68,7 @@ describe("runBackgroundUpdateCheck", () => {
isUpdate ? `Update to ${version}` : "Up to date"
beforeEach(() => {
importCounter += 1
mockFindPluginEntry.mockReset()
mockGetCachedVersion.mockReset()
mockGetLatestVersion.mockReset()
@@ -87,7 +90,7 @@ describe("runBackgroundUpdateCheck", () => {
it("#given no plugin entry #when checking in background #then it returns early", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
mockFindPluginEntry.mockReturnValue(null)
// #when
@@ -101,7 +104,7 @@ describe("runBackgroundUpdateCheck", () => {
it("#given no current version #when checking in background #then it returns early", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" }))
mockGetCachedVersion.mockReturnValue(null)
@@ -115,7 +118,7 @@ describe("runBackgroundUpdateCheck", () => {
it("#given latest version fetch fails #when checking in background #then it returns early", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
mockGetLatestVersion.mockResolvedValue(null)
// #when
@@ -128,7 +131,7 @@ describe("runBackgroundUpdateCheck", () => {
it("#given current version is latest #when checking in background #then it does nothing", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
mockGetLatestVersion.mockResolvedValue("3.4.0")
// #when
@@ -141,7 +144,7 @@ describe("runBackgroundUpdateCheck", () => {
it("#given auto update is disabled #when checking in background #then it shows notification only", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
// #when
await runBackgroundUpdateCheck(mockCtx, false, getToastMessage)
@@ -153,7 +156,7 @@ describe("runBackgroundUpdateCheck", () => {
it("#given user pinned a version #when checking in background #then it skips auto update", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" }))
// #when
@@ -166,7 +169,7 @@ describe("runBackgroundUpdateCheck", () => {
it("#given unpinned update succeeds #when checking in background #then it syncs invalidates installs and toasts", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
// #when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
@@ -174,14 +177,14 @@ describe("runBackgroundUpdateCheck", () => {
// #then
expect(mockSyncCachePackageJsonToIntent).toHaveBeenCalledTimes(1)
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
expect(mockRunBunInstallWithDetails).toHaveBeenCalledTimes(1)
expect(mockRunBunInstallWithDetails).toHaveBeenCalledTimes(2)
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0")
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
})
it("#given update succeeds #when checking in background #then it syncs before invalidate and install", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
const callOrder: string[] = []
mockSyncCachePackageJsonToIntent.mockImplementation((_pluginInfo) => {
callOrder.push("sync")
@@ -199,12 +202,12 @@ describe("runBackgroundUpdateCheck", () => {
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
// #then
expect(callOrder).toEqual(["sync", "invalidate", "install"])
expect(callOrder).toEqual(["sync", "invalidate", "install", "install"])
})
it("#given install fails #when checking in background #then it falls back to notification only", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
mockRunBunInstallWithDetails.mockResolvedValue({ success: false })
// #when
@@ -215,10 +218,10 @@ describe("runBackgroundUpdateCheck", () => {
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
for (const syncError of ["file_not_found", "plugin_not_in_deps", "parse_error", "write_error"] as const) {
for (const syncError of ["parse_error", "write_error"] as const) {
it(`#given sync fails with ${syncError} #when checking in background #then it aborts and shows notification only`, async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
const runBackgroundUpdateCheck = await createRunner()
mockSyncCachePackageJsonToIntent.mockReturnValue({
synced: false,
error: syncError,
@@ -3,15 +3,16 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { createBackgroundUpdateCheckRunner } from "../auto-update-checker/hook/background-update-check"
import type { PluginEntryInfo } from "../auto-update-checker/checker"
import type { SyncResult } from "../auto-update-checker/checker/sync-package-json"
import { PACKAGE_NAME } from "../auto-update-checker/constants"
type ToastMessageGetter = (isUpdate: boolean, version?: string) => string
let importCounter = 0
function createPluginEntry(overrides?: Partial<PluginEntryInfo>): PluginEntryInfo {
return {
entry: "oh-my-openagent@3.4.0",
entry: `${PACKAGE_NAME}@3.4.0`,
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
@@ -21,6 +22,7 @@ function createPluginEntry(overrides?: Partial<PluginEntryInfo>): PluginEntryInf
const TEST_DIR = join(import.meta.dir, "__test-workspace-resolution__")
const TEST_CACHE_DIR = join(TEST_DIR, "cache")
const TEST_CACHE_WORKSPACE_DIR = join(TEST_CACHE_DIR, "packages")
const TEST_CONFIG_DIR = join(TEST_DIR, "config")
const mockFindPluginEntry = mock((_directory: string): PluginEntryInfo | null => createPluginEntry())
@@ -38,7 +40,9 @@ const mockSyncCachePackageJsonToIntent = mock((_pluginInfo: PluginEntryInfo): Sy
const mockRunBunInstallWithDetails = mock(async (_opts?: { outputMode?: string; workspaceDir?: string }) => ({ success: true }))
const mockLog = mock(() => {})
function createRunner() {
async function createRunner() {
const { createBackgroundUpdateCheckRunner } = await import(`../auto-update-checker/hook/background-update-check?test=${importCounter++}`)
return createBackgroundUpdateCheckRunner({
existsSync,
join,
@@ -69,6 +73,7 @@ describe("workspace resolution", () => {
isUpdate ? `Update to ${version}` : "Up to date"
beforeEach(() => {
importCounter += 1
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
@@ -101,12 +106,12 @@ describe("workspace resolution", () => {
it("#given config-dir install exists but cache-dir does not #when updating #then it installs to config-dir", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-openagent"), { recursive: true })
writeFileSync(join(TEST_CONFIG_DIR, "package.json"), JSON.stringify({ dependencies: { "oh-my-openagent": "3.4.0" } }, null, 2))
const runBackgroundUpdateCheck = await createRunner()
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", PACKAGE_NAME), { recursive: true })
writeFileSync(join(TEST_CONFIG_DIR, "package.json"), JSON.stringify({ dependencies: { [PACKAGE_NAME]: "3.4.0" } }, null, 2))
writeFileSync(
join(TEST_CONFIG_DIR, "node_modules", "oh-my-openagent", "package.json"),
JSON.stringify({ name: "oh-my-openagent", version: "3.4.0" }, null, 2),
join(TEST_CONFIG_DIR, "node_modules", PACKAGE_NAME, "package.json"),
JSON.stringify({ name: PACKAGE_NAME, version: "3.4.0" }, null, 2),
)
// #when
@@ -118,18 +123,18 @@ describe("workspace resolution", () => {
it("#given both config-dir and cache-dir installs exist #when updating #then it prefers config-dir", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-openagent"), { recursive: true })
writeFileSync(join(TEST_CONFIG_DIR, "package.json"), JSON.stringify({ dependencies: { "oh-my-openagent": "3.4.0" } }, null, 2))
const runBackgroundUpdateCheck = await createRunner()
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", PACKAGE_NAME), { recursive: true })
writeFileSync(join(TEST_CONFIG_DIR, "package.json"), JSON.stringify({ dependencies: { [PACKAGE_NAME]: "3.4.0" } }, null, 2))
writeFileSync(
join(TEST_CONFIG_DIR, "node_modules", "oh-my-openagent", "package.json"),
JSON.stringify({ name: "oh-my-openagent", version: "3.4.0" }, null, 2),
join(TEST_CONFIG_DIR, "node_modules", PACKAGE_NAME, "package.json"),
JSON.stringify({ name: PACKAGE_NAME, version: "3.4.0" }, null, 2),
)
mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-openagent"), { recursive: true })
writeFileSync(join(TEST_CACHE_DIR, "package.json"), JSON.stringify({ dependencies: { "oh-my-openagent": "3.4.0" } }, null, 2))
mkdirSync(join(TEST_CACHE_DIR, "node_modules", PACKAGE_NAME), { recursive: true })
writeFileSync(join(TEST_CACHE_DIR, "package.json"), JSON.stringify({ dependencies: { [PACKAGE_NAME]: "3.4.0" } }, null, 2))
writeFileSync(
join(TEST_CACHE_DIR, "node_modules", "oh-my-openagent", "package.json"),
JSON.stringify({ name: "oh-my-openagent", version: "3.4.0" }, null, 2),
join(TEST_CACHE_DIR, "node_modules", PACKAGE_NAME, "package.json"),
JSON.stringify({ name: PACKAGE_NAME, version: "3.4.0" }, null, 2),
)
// #when
@@ -141,18 +146,49 @@ describe("workspace resolution", () => {
it("#given only cache-dir install exists #when updating #then it falls back to cache-dir", async () => {
// #given
const runBackgroundUpdateCheck = createRunner()
mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-openagent"), { recursive: true })
writeFileSync(join(TEST_CACHE_DIR, "package.json"), JSON.stringify({ dependencies: { "oh-my-openagent": "3.4.0" } }, null, 2))
const runBackgroundUpdateCheck = await createRunner()
mkdirSync(join(TEST_CACHE_WORKSPACE_DIR, "node_modules", PACKAGE_NAME), { recursive: true })
writeFileSync(join(TEST_CACHE_WORKSPACE_DIR, "package.json"), JSON.stringify({ dependencies: { [PACKAGE_NAME]: "3.4.0" } }, null, 2))
writeFileSync(
join(TEST_CACHE_DIR, "node_modules", "oh-my-openagent", "package.json"),
JSON.stringify({ name: "oh-my-openagent", version: "3.4.0" }, null, 2),
join(TEST_CACHE_WORKSPACE_DIR, "node_modules", PACKAGE_NAME, "package.json"),
JSON.stringify({ name: PACKAGE_NAME, version: "3.4.0" }, null, 2),
)
// #when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
// #then
expect(mockRunBunInstallWithDetails.mock.calls[0]?.[0]?.workspaceDir).toBe(TEST_CACHE_DIR)
expect(mockRunBunInstallWithDetails.mock.calls[0]?.[0]?.workspaceDir).toBe(TEST_CACHE_WORKSPACE_DIR)
})
it("#given cache workspace package.json exists without installed module #when updating #then it installs to cache-dir", async () => {
// #given
const runner = await createRunner()
mkdirSync(TEST_CACHE_WORKSPACE_DIR, { recursive: true })
writeFileSync(join(TEST_CACHE_WORKSPACE_DIR, "package.json"), JSON.stringify({ dependencies: { [PACKAGE_NAME]: "3.4.0" } }, null, 2))
// #when
await runner(mockCtx, true, getToastMessage)
// #then
expect(mockRunBunInstallWithDetails.mock.calls[0]?.[0]?.workspaceDir).toBe(TEST_CACHE_WORKSPACE_DIR)
})
it("#given config-dir install exists #when updating #then it also primes the cache workspace", async () => {
// #given
const runBackgroundUpdateCheck = await createRunner()
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", PACKAGE_NAME), { recursive: true })
writeFileSync(join(TEST_CONFIG_DIR, "package.json"), JSON.stringify({ dependencies: { [PACKAGE_NAME]: "3.4.0" } }, null, 2))
writeFileSync(
join(TEST_CONFIG_DIR, "node_modules", PACKAGE_NAME, "package.json"),
JSON.stringify({ name: PACKAGE_NAME, version: "3.4.0" }, null, 2),
)
// #when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
// #then
expect(mockRunBunInstallWithDetails.mock.calls[0]?.[0]?.workspaceDir).toBe(TEST_CONFIG_DIR)
expect(mockRunBunInstallWithDetails.mock.calls[1]?.[0]?.workspaceDir).toBe(TEST_CACHE_WORKSPACE_DIR)
})
})
@@ -148,7 +148,7 @@ describe("syncCachePackageJsonToIntent", () => {
})
describe("#given cache package.json does not exist", () => {
it("#then returns file_not_found error", async () => {
it("#then creates cache package.json with the plugin dependency", async () => {
cleanupTestCache()
const { syncCachePackageJsonToIntent } = await importFreshSyncPackageJsonModule()
@@ -161,13 +161,14 @@ describe("syncCachePackageJsonToIntent", () => {
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("file_not_found")
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
describe("#given plugin not in cache package.json dependencies", () => {
it("#then returns plugin_not_in_deps error", async () => {
it("#then adds the plugin dependency and preserves existing dependencies", async () => {
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
@@ -186,8 +187,13 @@ describe("syncCachePackageJsonToIntent", () => {
const result = syncCachePackageJsonToIntent(pluginInfo)
expect(result.synced).toBe(false)
expect(result.error).toBe("plugin_not_in_deps")
expect(result.synced).toBe(true)
expect(result.error).toBeNull()
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
expect(pkg.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(pkg.dependencies?.other).toBe("1.0.0")
})
})