fix(plugin): verify event hook compatibility with v1.4.0

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-08 13:08:03 +09:00
parent e52dd340c6
commit 78e6d780eb
15 changed files with 383 additions and 28 deletions
@@ -3,6 +3,7 @@ const { afterEach, describe, expect, mock, test, afterAll } = require("bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { PART_STORAGE } from "../../shared"
const testDirs: string[] = []
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
@@ -64,4 +65,36 @@ describe("getLastAgentFromSession JSON backend", () => {
// then
expect(result).toBe("atlas")
})
test("skips JSON messages whose part storage contains a compaction marker", async () => {
// given
const sessionID = "ses_json_compaction_marker"
const messageDir = createTempMessageDir(sessionID)
const compactionMessageID = "msg_test_atlas_compaction_marker"
const partDir = join(PART_STORAGE, compactionMessageID)
testDirs.push(partDir)
writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({
id: compactionMessageID,
agent: "atlas",
time: { created: 200 },
}), "utf-8")
mkdirSync(partDir, { recursive: true })
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({
type: "compaction",
}), "utf-8")
writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({
id: "msg_0002",
agent: "sisyphus-junior",
time: { created: 100 },
}), "utf-8")
const { getLastAgentFromSession } = await import("./session-last-agent")
// when
const result = await getLastAgentFromSession(sessionID)
// then
expect(result).toBe("sisyphus-junior")
})
})
@@ -52,6 +52,30 @@ describe("getLastAgentFromSession SQLite backend ordering", () => {
expect(result).toBe("sisyphus-junior")
})
test("skips compaction marker user messages that retain the original agent", async () => {
// given
const client = {
session: {
messages: async () => ({
data: [
{ id: "msg_real", info: { agent: "sisyphus", time: { created: 100 } } },
{
id: "msg_compaction",
info: { agent: "atlas", time: { created: 200 } },
parts: [{ type: "compaction" }],
},
],
}),
},
}
// when
const result = await getLastAgentFromSession("ses_sqlite_compaction_marker", client as never)
// then
expect(result).toBe("sisyphus")
})
test("returns null instead of throwing when SQLite message lookup fails", async () => {
// given
const client = {
+21 -10
View File
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
type SessionMessagesClient = {
session: {
@@ -9,10 +10,6 @@ type SessionMessagesClient = {
}
}
function isCompactionAgent(agent: unknown): boolean {
return typeof agent === "string" && agent.toLowerCase() === "compaction"
}
function getLastAgentFromMessageDir(messageDir: string): string | null {
try {
const messages = readdirSync(messageDir)
@@ -20,9 +17,10 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
.map((fileName) => {
try {
const content = readFileSync(join(messageDir, fileName), "utf-8")
const parsed = JSON.parse(content) as { agent?: unknown; time?: { created?: unknown } }
const parsed = JSON.parse(content) as { id?: string; agent?: unknown; time?: { created?: unknown } }
return {
fileName,
id: parsed.id,
agent: parsed.agent,
createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY,
}
@@ -30,11 +28,16 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
return null
}
})
.filter((message): message is { fileName: string; agent: unknown; createdAt: number } => message !== null)
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
.filter((message): message is { fileName: string; id: string | undefined; agent: unknown; createdAt: number } => message !== null)
.sort((left, right) => (right?.createdAt ?? 0) - (left?.createdAt ?? 0) || (right?.fileName ?? "").localeCompare(left?.fileName ?? ""))
for (const message of messages) {
if (typeof message.agent === "string" && !isCompactionAgent(message.agent)) {
if (!message) continue
if (isCompactionMessage({ agent: message.agent }) || hasCompactionPartInStorage(message?.id)) {
continue
}
if (typeof message.agent === "string") {
return message.agent.toLowerCase()
}
}
@@ -52,7 +55,11 @@ export async function getLastAgentFromSession(
if (isSqliteBackend() && client) {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as Array<{ id?: string; info?: { agent?: string; time?: { created?: number } } }>, {
const messages = normalizeSDKResponse(response, [] as Array<{
id?: string
info?: { agent?: string; time?: { created?: number } }
parts?: Array<{ type?: string }>
}>, {
preferResponseOnMissingData: true,
}).sort((left, right) => {
const leftTime = (left as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY
@@ -67,8 +74,12 @@ export async function getLastAgentFromSession(
})
for (const message of messages) {
if (isCompactionMessage(message)) {
continue
}
const agent = message.info?.agent
if (typeof agent === "string" && !isCompactionAgent(agent)) {
if (typeof agent === "string") {
return agent.toLowerCase()
}
}
@@ -150,14 +150,21 @@ export async function handleSessionIdle(args: {
let resolvedInfo: ResolvedMessageInfo | undefined
let encounteredCompaction = false
let latestMessageWasCompaction = false
try {
const messageInfoResult = await resolveLatestMessageInfo(ctx, sessionID, prefetchedMessages)
resolvedInfo = messageInfoResult.resolvedInfo
encounteredCompaction = messageInfoResult.encounteredCompaction
latestMessageWasCompaction = messageInfoResult.latestMessageWasCompaction
} catch (error) {
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) })
}
if (latestMessageWasCompaction) {
log(`[${HOOK_NAME}] Skipped: latest message is a compaction marker`, { sessionID })
return
}
const sessionAgent = getSessionAgent(sessionID)
if (!resolvedInfo?.agent && sessionAgent) {
resolvedInfo = { ...resolvedInfo, agent: sessionAgent }
@@ -2,7 +2,7 @@ import { log } from "../../shared/logger"
import { HOOK_NAME } from "./constants"
interface MessagePart {
type: string
type?: string
name?: string
toolName?: string
}
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSDKResponse } from "../../shared"
import { isCompactionMessage } from "../../shared/compaction-marker"
import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types"
@@ -16,10 +17,17 @@ export async function resolveLatestMessageInfo(
[] as MessageWithInfo[],
)
let encounteredCompaction = false
let latestMessageWasCompaction = false
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent === "compaction") {
const message = messages[i]
const info = message.info
const isCompaction = isCompactionMessage(message)
if (i === messages.length - 1) {
latestMessageWasCompaction = isCompaction
}
if (isCompaction) {
encounteredCompaction = true
continue
}
@@ -31,9 +39,10 @@ export async function resolveLatestMessageInfo(
tools: info.tools,
},
encounteredCompaction,
latestMessageWasCompaction,
}
}
}
return { resolvedInfo: undefined, encounteredCompaction }
return { resolvedInfo: undefined, encounteredCompaction, latestMessageWasCompaction }
}
@@ -1594,8 +1594,8 @@ describe("todo-continuation-enforcer", () => {
// when resolving agent info, preventing infinite continuation loops
// ============================================================
test("should skip compaction agent messages when resolving agent info", async () => {
// given - session where last message is from compaction agent but previous was Sisyphus
test("should skip injection while the latest message is from the compaction agent", async () => {
// given - session where the latest activity is still the compaction assistant turn
const sessionID = "main-compaction-filter"
setMainSession(sessionID)
@@ -1644,9 +1644,8 @@ describe("todo-continuation-enforcer", () => {
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500)
// then - continuation uses Sisyphus (skipped compaction agent)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].agent).toBe("sisyphus")
// then - no continuation while compaction is still the latest event
expect(promptCalls).toHaveLength(0)
})
test("should skip injection when only compaction agent messages exist", async () => {
@@ -1702,6 +1701,62 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0)
})
test("should skip compaction marker user messages when resolving agent info", async () => {
// given - latest user message is the OpenCode compaction marker, not a real turn
const sessionID = "main-compaction-marker-filter"
setMainSession(sessionID)
const mockMessagesWithCompactionMarker = [
{ info: { id: "msg-1", role: "assistant", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
{
info: { id: "msg-2", role: "user", agent: "atlas", model: { providerID: "openai", modelID: "gpt-5.4" } },
parts: [{ type: "compaction" }],
},
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesWithCompactionMarker }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false),
})
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(3000)
// then - no continuation while the compaction marker is the latest event
expect(promptCalls).toHaveLength(0)
})
test("should skip injection when prometheus agent is after compaction", async () => {
// given - prometheus session that was compacted
const sessionID = "main-prometheus-compacted"
@@ -54,6 +54,7 @@ export interface MessageInfo {
export interface MessageWithInfo {
info?: MessageInfo
parts?: Array<{ type?: string }>
}
export interface ResolvedMessageInfo {
@@ -65,6 +66,7 @@ export interface ResolvedMessageInfo {
export interface ResolveLatestMessageInfoResult {
resolvedInfo?: ResolvedMessageInfo
encounteredCompaction: boolean
latestMessageWasCompaction: boolean
}
export interface ContinuationProgressOptions {