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:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
clearCompactionAgentConfigCheckpoint,
|
clearCompactionAgentConfigCheckpoint,
|
||||||
setCompactionAgentConfigCheckpoint,
|
setCompactionAgentConfigCheckpoint,
|
||||||
} from "../../shared/compaction-agent-config-checkpoint"
|
} from "../../shared/compaction-agent-config-checkpoint"
|
||||||
|
import { PART_STORAGE } from "../../shared"
|
||||||
|
|
||||||
describe("isCompactionAgent", () => {
|
describe("isCompactionAgent", () => {
|
||||||
describe("#given agent name variations", () => {
|
describe("#given agent name variations", () => {
|
||||||
@@ -73,6 +74,7 @@ describe("findNearestMessageExcludingCompaction", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tempDir, { force: true, recursive: true })
|
rmSync(tempDir, { force: true, recursive: true })
|
||||||
|
rmSync(join(PART_STORAGE, "msg_test_background_compaction_marker"), { force: true, recursive: true })
|
||||||
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
|
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,6 +118,30 @@ describe("findNearestMessageExcludingCompaction", () => {
|
|||||||
expect(result?.agent).toBe("sisyphus")
|
expect(result?.agent).toBe("sisyphus")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("skips JSON messages whose part storage contains a compaction marker", () => {
|
||||||
|
// given
|
||||||
|
const compactionMessageID = "msg_test_background_compaction_marker"
|
||||||
|
const partDir = join(PART_STORAGE, compactionMessageID)
|
||||||
|
writeFileSync(join(tempDir, "002.json"), JSON.stringify({
|
||||||
|
id: compactionMessageID,
|
||||||
|
agent: "atlas",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
}))
|
||||||
|
writeFileSync(join(tempDir, "001.json"), JSON.stringify({
|
||||||
|
id: "msg_001",
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
}))
|
||||||
|
mkdirSync(partDir, { recursive: true })
|
||||||
|
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" }))
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = findNearestMessageExcludingCompaction(tempDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result?.agent).toBe("sisyphus")
|
||||||
|
})
|
||||||
|
|
||||||
test("falls back to partial agent/model match", () => {
|
test("falls back to partial agent/model match", () => {
|
||||||
// given
|
// given
|
||||||
const messageWithAgentOnly = {
|
const messageWithAgentOnly = {
|
||||||
@@ -256,4 +282,28 @@ describe("resolvePromptContextFromSessionMessages", () => {
|
|||||||
tools: { bash: true },
|
tools: { bash: true },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("skips SDK messages that only exist to mark compaction", () => {
|
||||||
|
// given
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "msg_compaction",
|
||||||
|
info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" } },
|
||||||
|
parts: [{ type: "compaction" }],
|
||||||
|
},
|
||||||
|
{ info: { agent: "sisyphus" } },
|
||||||
|
{ info: { model: { providerID: "anthropic", modelID: "claude-opus-4-1" } } },
|
||||||
|
{ info: { tools: { bash: true } } },
|
||||||
|
]
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = resolvePromptContextFromSessionMessages(messages)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual({
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||||
|
tools: { bash: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ import { readdirSync, readFileSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type { StoredMessage } from "../hook-message-injector"
|
import type { StoredMessage } from "../hook-message-injector"
|
||||||
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||||
|
import {
|
||||||
|
hasCompactionPartInStorage,
|
||||||
|
isCompactionAgent,
|
||||||
|
isCompactionMessage,
|
||||||
|
} from "../../shared/compaction-marker"
|
||||||
|
|
||||||
|
export { isCompactionAgent } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
type SessionMessage = {
|
type SessionMessage = {
|
||||||
|
id?: string
|
||||||
info?: {
|
info?: {
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: {
|
model?: {
|
||||||
@@ -15,10 +23,7 @@ type SessionMessage = {
|
|||||||
modelID?: string
|
modelID?: string
|
||||||
tools?: StoredMessage["tools"]
|
tools?: StoredMessage["tools"]
|
||||||
}
|
}
|
||||||
}
|
parts?: Array<{ type?: string }>
|
||||||
|
|
||||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
|
||||||
return agent?.trim().toLowerCase() === "compaction"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasFullAgentAndModel(message: StoredMessage): boolean {
|
function hasFullAgentAndModel(message: StoredMessage): boolean {
|
||||||
@@ -35,6 +40,10 @@ function hasPartialAgentOrModel(message: StoredMessage): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
|
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
|
||||||
|
if (isCompactionMessage(message)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const info = message.info
|
const info = message.info
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return null
|
return null
|
||||||
@@ -138,7 +147,11 @@ export function findNearestMessageExcludingCompaction(
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||||
messages.push(JSON.parse(content) as StoredMessage)
|
const parsed = JSON.parse(content) as StoredMessage & { id?: string }
|
||||||
|
if (hasCompactionPartInStorage(parsed.id) || isCompactionAgent(parsed.agent)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messages.push(parsed)
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
generatePartId,
|
generatePartId,
|
||||||
injectHookMessage,
|
injectHookMessage,
|
||||||
} from "./injector"
|
} from "./injector"
|
||||||
|
import { PART_STORAGE } from "../../shared"
|
||||||
import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
|
import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection"
|
||||||
|
|
||||||
//#region Mocks
|
//#region Mocks
|
||||||
@@ -53,6 +54,7 @@ function createMockClient(messages: Array<{
|
|||||||
tools?: Record<string, boolean>
|
tools?: Record<string, boolean>
|
||||||
time?: { created?: number }
|
time?: { created?: number }
|
||||||
}
|
}
|
||||||
|
parts?: Array<{ type?: string }>
|
||||||
}>): {
|
}>): {
|
||||||
session: {
|
session: {
|
||||||
messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }>
|
messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }>
|
||||||
@@ -176,6 +178,24 @@ describe("findNearestMessageWithFieldsFromSDK", () => {
|
|||||||
|
|
||||||
expect(result?.agent).toBe("newest-by-time")
|
expect(result?.agent).toBe("newest-by-time")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("skips compaction marker user messages when resolving nearest message", async () => {
|
||||||
|
const mockClient = createMockClient([
|
||||||
|
{
|
||||||
|
id: "msg_compaction",
|
||||||
|
info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 200 } },
|
||||||
|
parts: [{ type: "compaction" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "msg_real",
|
||||||
|
info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" }, time: { created: 100 } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123")
|
||||||
|
|
||||||
|
expect(result?.agent).toBe("sisyphus")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("findNearestMessageWithFields JSON backend ordering", () => {
|
describe("findNearestMessageWithFields JSON backend ordering", () => {
|
||||||
@@ -197,6 +217,34 @@ describe("findNearestMessageWithFields JSON backend ordering", () => {
|
|||||||
|
|
||||||
expect(result?.agent).toBe("newest-by-time")
|
expect(result?.agent).toBe("newest-by-time")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("skips JSON messages whose parts contain a compaction marker", () => {
|
||||||
|
mockIsSqliteBackend.mockReturnValue(false)
|
||||||
|
const messageDir = createMessageDir()
|
||||||
|
const compactionMessageID = "msg_test_injector_compaction_marker"
|
||||||
|
const partDir = join(PART_STORAGE, compactionMessageID)
|
||||||
|
tempDirs.push(partDir)
|
||||||
|
|
||||||
|
writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({
|
||||||
|
id: compactionMessageID,
|
||||||
|
agent: "atlas",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
time: { created: 200 },
|
||||||
|
}))
|
||||||
|
mkdirSync(partDir, { recursive: true })
|
||||||
|
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" }))
|
||||||
|
|
||||||
|
writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({
|
||||||
|
id: "msg_0002",
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4" },
|
||||||
|
time: { created: 100 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = findNearestMessageWithFields(messageDir)
|
||||||
|
|
||||||
|
expect(result?.agent).toBe("sisyphus")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("findFirstMessageWithAgentFromSDK", () => {
|
describe("findFirstMessageWithAgentFromSDK", () => {
|
||||||
@@ -222,6 +270,17 @@ describe("findFirstMessageWithAgentFromSDK", () => {
|
|||||||
expect(result).toBe("earliest-agent")
|
expect(result).toBe("earliest-agent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("skips compaction marker user messages when resolving first agent", async () => {
|
||||||
|
const mockClient = createMockClient([
|
||||||
|
{ id: "msg_compaction", info: { agent: "atlas", time: { created: 10 } }, parts: [{ type: "compaction" }] },
|
||||||
|
{ id: "msg_real", info: { agent: "sisyphus", time: { created: 20 } } },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123")
|
||||||
|
|
||||||
|
expect(result).toBe("sisyphus")
|
||||||
|
})
|
||||||
|
|
||||||
it("skips messages without agent field", async () => {
|
it("skips messages without agent field", async () => {
|
||||||
const mockClient = createMockClient([
|
const mockClient = createMockClient([
|
||||||
{ info: {} },
|
{ info: {} },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } fr
|
|||||||
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 { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared"
|
import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared"
|
||||||
|
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
export interface StoredMessage {
|
export interface StoredMessage {
|
||||||
agent?: string
|
agent?: string
|
||||||
@@ -32,6 +33,7 @@ interface SDKMessage {
|
|||||||
created?: number
|
created?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
parts?: Array<{ type?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const processPrefix = randomBytes(4).toString("hex")
|
const processPrefix = randomBytes(4).toString("hex")
|
||||||
@@ -39,6 +41,10 @@ let messageCounter = 0
|
|||||||
let partCounter = 0
|
let partCounter = 0
|
||||||
|
|
||||||
function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
|
function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null {
|
||||||
|
if (isCompactionMessage(msg)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const info = msg.info
|
const info = msg.info
|
||||||
if (!info) return null
|
if (!info) return null
|
||||||
|
|
||||||
@@ -164,22 +170,38 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage
|
|||||||
return {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
msg,
|
msg,
|
||||||
|
hasCompactionMarker: hasCompactionPartInStorage(
|
||||||
|
typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined,
|
||||||
|
),
|
||||||
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.NEGATIVE_INFINITY,
|
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.NEGATIVE_INFINITY,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null)
|
.filter((entry): entry is {
|
||||||
|
fileName: string
|
||||||
|
msg: StoredMessage & { time?: { created?: number } }
|
||||||
|
hasCompactionMarker: boolean
|
||||||
|
createdAt: number
|
||||||
|
} => entry !== null)
|
||||||
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
||||||
|
|
||||||
for (const entry of messages) {
|
for (const entry of messages) {
|
||||||
|
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.msg.agent && entry.msg.model?.providerID && entry.msg.model?.modelID) {
|
if (entry.msg.agent && entry.msg.model?.providerID && entry.msg.model?.modelID) {
|
||||||
return entry.msg
|
return entry.msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of messages) {
|
for (const entry of messages) {
|
||||||
|
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.msg.agent || (entry.msg.model?.providerID && entry.msg.model?.modelID)) {
|
if (entry.msg.agent || (entry.msg.model?.providerID && entry.msg.model?.modelID)) {
|
||||||
return entry.msg
|
return entry.msg
|
||||||
}
|
}
|
||||||
@@ -216,16 +238,28 @@ export function findFirstMessageWithAgent(messageDir: string): string | null {
|
|||||||
return {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
msg,
|
msg,
|
||||||
|
hasCompactionMarker: hasCompactionPartInStorage(
|
||||||
|
typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined,
|
||||||
|
),
|
||||||
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.POSITIVE_INFINITY,
|
createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.POSITIVE_INFINITY,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null)
|
.filter((entry): entry is {
|
||||||
|
fileName: string
|
||||||
|
msg: StoredMessage & { time?: { created?: number } }
|
||||||
|
hasCompactionMarker: boolean
|
||||||
|
createdAt: number
|
||||||
|
} => entry !== null)
|
||||||
.sort((left, right) => left.createdAt - right.createdAt || left.fileName.localeCompare(right.fileName))
|
.sort((left, right) => left.createdAt - right.createdAt || left.fileName.localeCompare(right.fileName))
|
||||||
|
|
||||||
for (const entry of messages) {
|
for (const entry of messages) {
|
||||||
|
if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.msg.agent) {
|
if (entry.msg.agent) {
|
||||||
return entry.msg.agent
|
return entry.msg.agent
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { afterEach, describe, expect, mock, test, afterAll } = require("bun:test"
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
|
import { PART_STORAGE } from "../../shared"
|
||||||
|
|
||||||
const testDirs: string[] = []
|
const testDirs: string[] = []
|
||||||
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
|
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`)
|
||||||
@@ -64,4 +65,36 @@ describe("getLastAgentFromSession JSON backend", () => {
|
|||||||
// then
|
// then
|
||||||
expect(result).toBe("atlas")
|
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")
|
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 () => {
|
test("returns null instead of throwing when SQLite message lookup fails", async () => {
|
||||||
// given
|
// given
|
||||||
const client = {
|
const client = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
|
import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared"
|
||||||
|
import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
type SessionMessagesClient = {
|
type SessionMessagesClient = {
|
||||||
session: {
|
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 {
|
function getLastAgentFromMessageDir(messageDir: string): string | null {
|
||||||
try {
|
try {
|
||||||
const messages = readdirSync(messageDir)
|
const messages = readdirSync(messageDir)
|
||||||
@@ -20,9 +17,10 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
|
|||||||
.map((fileName) => {
|
.map((fileName) => {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(join(messageDir, fileName), "utf-8")
|
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 {
|
return {
|
||||||
fileName,
|
fileName,
|
||||||
|
id: parsed.id,
|
||||||
agent: parsed.agent,
|
agent: parsed.agent,
|
||||||
createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY,
|
createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY,
|
||||||
}
|
}
|
||||||
@@ -30,11 +28,16 @@ function getLastAgentFromMessageDir(messageDir: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((message): message is { fileName: string; agent: unknown; createdAt: number } => message !== null)
|
.filter((message): message is { fileName: string; id: string | undefined; agent: unknown; createdAt: number } => message !== null)
|
||||||
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
|
.sort((left, right) => (right?.createdAt ?? 0) - (left?.createdAt ?? 0) || (right?.fileName ?? "").localeCompare(left?.fileName ?? ""))
|
||||||
|
|
||||||
for (const message of messages) {
|
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()
|
return message.agent.toLowerCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +55,11 @@ export async function getLastAgentFromSession(
|
|||||||
if (isSqliteBackend() && client) {
|
if (isSqliteBackend() && client) {
|
||||||
try {
|
try {
|
||||||
const response = await client.session.messages({ path: { id: sessionID } })
|
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,
|
preferResponseOnMissingData: true,
|
||||||
}).sort((left, right) => {
|
}).sort((left, right) => {
|
||||||
const leftTime = (left as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY
|
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) {
|
for (const message of messages) {
|
||||||
|
if (isCompactionMessage(message)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const agent = message.info?.agent
|
const agent = message.info?.agent
|
||||||
if (typeof agent === "string" && !isCompactionAgent(agent)) {
|
if (typeof agent === "string") {
|
||||||
return agent.toLowerCase()
|
return agent.toLowerCase()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,14 +150,21 @@ export async function handleSessionIdle(args: {
|
|||||||
|
|
||||||
let resolvedInfo: ResolvedMessageInfo | undefined
|
let resolvedInfo: ResolvedMessageInfo | undefined
|
||||||
let encounteredCompaction = false
|
let encounteredCompaction = false
|
||||||
|
let latestMessageWasCompaction = false
|
||||||
try {
|
try {
|
||||||
const messageInfoResult = await resolveLatestMessageInfo(ctx, sessionID, prefetchedMessages)
|
const messageInfoResult = await resolveLatestMessageInfo(ctx, sessionID, prefetchedMessages)
|
||||||
resolvedInfo = messageInfoResult.resolvedInfo
|
resolvedInfo = messageInfoResult.resolvedInfo
|
||||||
encounteredCompaction = messageInfoResult.encounteredCompaction
|
encounteredCompaction = messageInfoResult.encounteredCompaction
|
||||||
|
latestMessageWasCompaction = messageInfoResult.latestMessageWasCompaction
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(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)
|
const sessionAgent = getSessionAgent(sessionID)
|
||||||
if (!resolvedInfo?.agent && sessionAgent) {
|
if (!resolvedInfo?.agent && sessionAgent) {
|
||||||
resolvedInfo = { ...resolvedInfo, agent: sessionAgent }
|
resolvedInfo = { ...resolvedInfo, agent: sessionAgent }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { log } from "../../shared/logger"
|
|||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
|
|
||||||
interface MessagePart {
|
interface MessagePart {
|
||||||
type: string
|
type?: string
|
||||||
name?: string
|
name?: string
|
||||||
toolName?: string
|
toolName?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
|
import { isCompactionMessage } from "../../shared/compaction-marker"
|
||||||
|
|
||||||
import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types"
|
import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types"
|
||||||
|
|
||||||
@@ -16,10 +17,17 @@ export async function resolveLatestMessageInfo(
|
|||||||
[] as MessageWithInfo[],
|
[] as MessageWithInfo[],
|
||||||
)
|
)
|
||||||
let encounteredCompaction = false
|
let encounteredCompaction = false
|
||||||
|
let latestMessageWasCompaction = false
|
||||||
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const info = messages[i].info
|
const message = messages[i]
|
||||||
if (info?.agent === "compaction") {
|
const info = message.info
|
||||||
|
const isCompaction = isCompactionMessage(message)
|
||||||
|
if (i === messages.length - 1) {
|
||||||
|
latestMessageWasCompaction = isCompaction
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompaction) {
|
||||||
encounteredCompaction = true
|
encounteredCompaction = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -31,9 +39,10 @@ export async function resolveLatestMessageInfo(
|
|||||||
tools: info.tools,
|
tools: info.tools,
|
||||||
},
|
},
|
||||||
encounteredCompaction,
|
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
|
// when resolving agent info, preventing infinite continuation loops
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
test("should skip compaction agent messages when resolving agent info", async () => {
|
test("should skip injection while the latest message is from the compaction agent", async () => {
|
||||||
// given - session where last message is from compaction agent but previous was Sisyphus
|
// given - session where the latest activity is still the compaction assistant turn
|
||||||
const sessionID = "main-compaction-filter"
|
const sessionID = "main-compaction-filter"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
|
|
||||||
@@ -1644,9 +1644,8 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||||
await fakeTimers.advanceBy(2500)
|
await fakeTimers.advanceBy(2500)
|
||||||
|
|
||||||
// then - continuation uses Sisyphus (skipped compaction agent)
|
// then - no continuation while compaction is still the latest event
|
||||||
expect(promptCalls.length).toBe(1)
|
expect(promptCalls).toHaveLength(0)
|
||||||
expect(promptCalls[0].agent).toBe("sisyphus")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should skip injection when only compaction agent messages exist", async () => {
|
test("should skip injection when only compaction agent messages exist", async () => {
|
||||||
@@ -1702,6 +1701,62 @@ describe("todo-continuation-enforcer", () => {
|
|||||||
expect(promptCalls).toHaveLength(0)
|
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 () => {
|
test("should skip injection when prometheus agent is after compaction", async () => {
|
||||||
// given - prometheus session that was compacted
|
// given - prometheus session that was compacted
|
||||||
const sessionID = "main-prometheus-compacted"
|
const sessionID = "main-prometheus-compacted"
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface MessageInfo {
|
|||||||
|
|
||||||
export interface MessageWithInfo {
|
export interface MessageWithInfo {
|
||||||
info?: MessageInfo
|
info?: MessageInfo
|
||||||
|
parts?: Array<{ type?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedMessageInfo {
|
export interface ResolvedMessageInfo {
|
||||||
@@ -65,6 +66,7 @@ export interface ResolvedMessageInfo {
|
|||||||
export interface ResolveLatestMessageInfoResult {
|
export interface ResolveLatestMessageInfoResult {
|
||||||
resolvedInfo?: ResolvedMessageInfo
|
resolvedInfo?: ResolvedMessageInfo
|
||||||
encounteredCompaction: boolean
|
encounteredCompaction: boolean
|
||||||
|
latestMessageWasCompaction: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContinuationProgressOptions {
|
export interface ContinuationProgressOptions {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function createChatParamsHandler(args: {
|
|||||||
output.topP = storedPromptParams.topP
|
output.topP = storedPromptParams.topP
|
||||||
}
|
}
|
||||||
if (storedPromptParams.maxOutputTokens !== undefined) {
|
if (storedPromptParams.maxOutputTokens !== undefined) {
|
||||||
output.maxOutputTokens = storedPromptParams.maxOutputTokens
|
(output as Record<string, unknown>).maxOutputTokens = storedPromptParams.maxOutputTokens
|
||||||
}
|
}
|
||||||
if (storedPromptParams.options) {
|
if (storedPromptParams.options) {
|
||||||
output.options = {
|
output.options = {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { PART_STORAGE } from "./opencode-storage-paths"
|
||||||
|
|
||||||
|
type CompactionPartLike = {
|
||||||
|
type?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompactionMessageLike = {
|
||||||
|
agent?: unknown
|
||||||
|
info?: {
|
||||||
|
agent?: unknown
|
||||||
|
}
|
||||||
|
parts?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompactionPart(part: unknown): boolean {
|
||||||
|
return typeof part === "object" && part !== null && (part as CompactionPartLike).type === "compaction"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCompactionAgent(agent: unknown): boolean {
|
||||||
|
return typeof agent === "string" && agent.trim().toLowerCase() === "compaction"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCompactionPart(parts: unknown): boolean {
|
||||||
|
return Array.isArray(parts) && parts.some((part) => isCompactionPart(part))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCompactionMessage(message: CompactionMessageLike): boolean {
|
||||||
|
return isCompactionAgent(message.info?.agent ?? message.agent) || hasCompactionPart(message.parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCompactionPartInStorage(messageID: string | undefined): boolean {
|
||||||
|
if (!messageID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const partDir = join(PART_STORAGE, messageID)
|
||||||
|
if (!existsSync(partDir)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return readdirSync(partDir)
|
||||||
|
.filter((fileName) => fileName.endsWith(".json"))
|
||||||
|
.some((fileName) => {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(join(partDir, fileName), "utf-8")
|
||||||
|
return isCompactionPart(JSON.parse(content))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ export * from "./project-discovery-dirs"
|
|||||||
export * from "./normalize-sdk-response"
|
export * from "./normalize-sdk-response"
|
||||||
export * from "./session-directory-resolver"
|
export * from "./session-directory-resolver"
|
||||||
export * from "./prompt-tools"
|
export * from "./prompt-tools"
|
||||||
|
export * from "./compaction-marker"
|
||||||
export * from "./internal-initiator-marker"
|
export * from "./internal-initiator-marker"
|
||||||
export * from "./plugin-command-discovery"
|
export * from "./plugin-command-discovery"
|
||||||
export { SessionCategoryRegistry } from "./session-category-registry"
|
export { SessionCategoryRegistry } from "./session-category-registry"
|
||||||
|
|||||||
Reference in New Issue
Block a user