fix full-suite isolation regressions

This commit is contained in:
YeonGyu-Kim
2026-05-07 17:47:53 +09:00
parent 102b5f96e7
commit ee938aa097
62 changed files with 1238 additions and 899 deletions
+14 -2
View File
@@ -21,7 +21,19 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
return {
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: options?.isCallerOrchestrator,
}),
"tool.execute.after": createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit,
getState,
isCallerOrchestrator: options?.isCallerOrchestrator,
}),
}
}
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, test, afterAll } from "bun:test"
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
@@ -7,32 +7,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
import type { BoulderState } from "../../features/boulder-state"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-final-wave-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directoryPath = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(directoryPath) ? directoryPath : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
afterAll(() => { mock.restore() })
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
import { createAtlasHook } from "./index"
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
type PromptMock = ReturnType<typeof mock>
@@ -89,28 +64,6 @@ describe("Atlas final verification approval gate", () => {
}
}
function setupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDirectory)) {
mkdirSync(messageDirectory, { recursive: true })
}
writeFileSync(
join(messageDirectory, "msg_test001.json"),
JSON.stringify({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}),
)
}
function cleanupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (existsSync(messageDirectory)) {
rmSync(messageDirectory, { recursive: true, force: true })
}
}
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-final-wave-test-${randomUUID()}`)
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
@@ -127,7 +80,6 @@ describe("Atlas final verification approval gate", () => {
test("waits for explicit user approval after the last final-wave approval arrives", async () => {
// given
const sessionID = "atlas-final-wave-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "final-wave-plan.md")
writeFileSync(
@@ -155,7 +107,7 @@ describe("Atlas final verification approval gate", () => {
writeBoulderState(testDirectory, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createAtlasHook(mockInput, { directory: testDirectory, isCallerOrchestrator: async () => true })
const toolOutput = {
title: "Sisyphus Task",
output: `Tasks [4/4 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
@@ -176,13 +128,11 @@ session_id: ses_final_wave_review
expect(toolOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
expect(mockInput._promptMock).not.toHaveBeenCalled()
cleanupMessageStorage(sessionID)
})
test("keeps normal auto-continue instructions for non-final tasks", async () => {
// given
const sessionID = "atlas-non-final-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "implementation-plan.md")
writeFileSync(
@@ -210,7 +160,10 @@ session_id: ses_final_wave_review
}
writeBoulderState(testDirectory, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const toolOutput = {
title: "Sisyphus Task",
output: `Implementation finished successfully
@@ -229,6 +182,5 @@ session_id: ses_feature_task
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
cleanupMessageStorage(sessionID)
})
})
+108 -119
View File
@@ -1,8 +1,9 @@
import { describe, expect, test, beforeEach, afterEach, mock, afterAll } from "bun:test"
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { createOpencodeClient } from "@opencode-ai/sdk"
import {
writeBoulderState,
clearBoulderState,
@@ -10,35 +11,16 @@ import {
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
import type { PendingTaskRef } from "./types"
import type { AtlasHookOptions, PendingTaskRef } from "./types"
import { createAtlasHook } from "./index"
import { createToolExecuteAfterHandler } from "./tool-execute-after"
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const dir = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(dir) ? dir : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
afterAll(() => { mock.restore() })
const { createAtlasHook } = await import("./index")
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
const { createToolExecuteBeforeHandler } = await import("./tool-execute-before")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
const callerAgentBySession = new Map<string, string>()
type MockAtlasInput = Parameters<typeof createAtlasHook>[0] & {
_promptMock: ReturnType<typeof mock>
_sessionGetMock: ReturnType<typeof mock>
}
describe("atlas hook", () => {
let TEST_DIR: string
@@ -47,7 +29,7 @@ describe("atlas hook", () => {
function createMockPluginInput(overrides?: {
promptMock?: ReturnType<typeof mock>
sessionGetMock?: ReturnType<typeof mock>
}) {
}): MockAtlasInput {
const promptMock = overrides?.promptMock ?? mock(() => Promise.resolve())
const sessionGetMock = overrides?.sessionGetMock ?? mock(async ({ path }: { path: { id: string } }) => ({
data: {
@@ -55,40 +37,41 @@ describe("atlas hook", () => {
parentID: path.id.startsWith("ses_") ? "session-1" : "main-session-123",
},
}))
const client = createOpencodeClient({ baseUrl: "http://localhost" })
Reflect.set(client.session, "get", sessionGetMock)
Reflect.set(client.session, "prompt", promptMock)
Reflect.set(client.session, "promptAsync", promptMock)
return {
directory: TEST_DIR,
client: {
session: {
get: sessionGetMock,
prompt: promptMock,
promptAsync: promptMock,
},
},
project: {} as Parameters<typeof createAtlasHook>[0]["project"],
worktree: TEST_DIR,
serverUrl: new URL("http://localhost"),
$: {} as Parameters<typeof createAtlasHook>[0]["$"],
client,
_promptMock: promptMock,
_sessionGetMock: sessionGetMock,
} as Parameters<typeof createAtlasHook>[0] & {
_promptMock: ReturnType<typeof mock>
_sessionGetMock: ReturnType<typeof mock>
}
}
function setupMessageStorage(sessionID: string, agent: string): void {
const messageDir = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDir)) {
mkdirSync(messageDir, { recursive: true })
}
const messageData = {
agent,
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}
writeFileSync(join(messageDir, "msg_test001.json"), JSON.stringify(messageData))
callerAgentBySession.set(sessionID, agent)
}
function cleanupMessageStorage(sessionID: string): void {
const messageDir = join(MESSAGE_STORAGE, sessionID)
if (existsSync(messageDir)) {
rmSync(messageDir, { recursive: true, force: true })
callerAgentBySession.delete(sessionID)
}
function createTestAtlasHook(
input = createMockPluginInput(),
options: Partial<AtlasHookOptions> = {},
): ReturnType<typeof createAtlasHook> {
const resolvedOptions: AtlasHookOptions = {
directory: TEST_DIR,
isCallerOrchestrator: async (sessionID) => callerAgentBySession.get(sessionID ?? "") === "atlas",
...options,
}
return createAtlasHook(input, resolvedOptions)
}
beforeEach(() => {
@@ -104,10 +87,12 @@ describe("atlas hook", () => {
mkdirSync(SISYPHUS_DIR, { recursive: true })
}
clearBoulderState(TEST_DIR)
callerAgentBySession.clear()
})
afterEach(() => {
_resetForTesting()
callerAgentBySession.clear()
clearBoulderState(TEST_DIR)
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
@@ -117,7 +102,7 @@ describe("atlas hook", () => {
describe("tool.execute.after handler", () => {
test("should handle undefined output gracefully (issue #1035)", async () => {
// given - hook and undefined output (e.g., from /review command)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
// when - calling with undefined output
const result = await hook["tool.execute.after"](
@@ -131,7 +116,7 @@ describe("atlas hook", () => {
test("should ignore non-task tools", async () => {
// given - hook and non-task tool
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Test Tool",
output: "Original output",
@@ -164,7 +149,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -188,7 +173,7 @@ describe("atlas hook", () => {
const sessionID = "session-no-boulder-test"
setupMessageStorage(sessionID, "atlas")
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -225,7 +210,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -264,7 +249,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed
@@ -301,7 +286,7 @@ session_id: ses_subagent_abc
const sessionID = "session-standalone-metadata-test"
setupMessageStorage(sessionID, "atlas")
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed
@@ -349,7 +334,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Original output",
@@ -386,7 +371,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task output",
@@ -422,7 +407,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput({
const hook = createTestAtlasHook(createMockPluginInput({
sessionGetMock: mock(async () => {
throw new Error("session lookup failed")
}),
@@ -462,7 +447,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task output",
@@ -499,7 +484,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed",
@@ -536,7 +521,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed",
@@ -581,6 +566,7 @@ session_id: ses_standalone_def
ctx: createMockPluginInput(),
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
const afterHandler = createToolExecuteAfterHandler({
ctx: createMockPluginInput(),
@@ -588,6 +574,7 @@ session_id: ses_standalone_def
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
// when - the task is captured before execution
@@ -634,7 +621,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed successfully
@@ -684,7 +671,7 @@ session_id: ses_auth_flow_123
plan_name: "stable-task-key-plan",
})
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
// when - Atlas delegates task 1
await hook["tool.execute.before"](
@@ -744,7 +731,7 @@ session_id: ses_auth_flow_123
plan_name: "cross-task-resume-plan",
})
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
// when - Atlas resumes an explicit prior session
await hook["tool.execute.before"](
@@ -806,7 +793,7 @@ session_id: ses_old_task_111
},
})
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task continued successfully
@@ -860,6 +847,7 @@ session_id: ses_old_task_111
ctx: createMockPluginInput(),
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
const afterHandler = createToolExecuteAfterHandler({
ctx: createMockPluginInput(),
@@ -867,6 +855,7 @@ session_id: ses_old_task_111
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
// when - two task() calls start before either one completes
@@ -929,7 +918,7 @@ session_id: ses_parallel_collision_222
plan_name: "untrusted-session-id-plan",
})
const hook = createAtlasHook(createMockPluginInput({
const hook = createTestAtlasHook(createMockPluginInput({
sessionGetMock: mock(async ({ path }: { path: { id: string } }) => ({
data: {
id: path.id,
@@ -987,7 +976,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -1022,7 +1011,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -1061,7 +1050,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -1093,7 +1082,7 @@ session_id: ses_untrusted_999
test("should append delegation reminder when orchestrator writes outside .sisyphus/", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Write",
output: "File written successfully",
@@ -1114,7 +1103,7 @@ session_id: ses_untrusted_999
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Edit",
output: "File edited successfully",
@@ -1133,7 +1122,7 @@ session_id: ses_untrusted_999
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1157,7 +1146,7 @@ session_id: ses_untrusted_999
const nonOrchestratorSession = "non-orchestrator-session"
setupMessageStorage(nonOrchestratorSession, "sisyphus-junior")
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1180,7 +1169,7 @@ session_id: ses_untrusted_999
test("should NOT append reminder for read-only tools", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File content"
const output = {
title: "Read",
@@ -1200,7 +1189,7 @@ session_id: ses_untrusted_999
test("should handle missing filePath gracefully", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1221,7 +1210,7 @@ session_id: ses_untrusted_999
describe("cross-platform path validation (Windows support)", () => {
test("should NOT append reminder when orchestrator writes inside .sisyphus\\ (Windows backslash)", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1242,7 +1231,7 @@ session_id: ses_untrusted_999
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1263,7 +1252,7 @@ session_id: ses_untrusted_999
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1284,7 +1273,7 @@ session_id: ses_untrusted_999
test("should append reminder for Windows path outside .sisyphus\\", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Write",
output: "File written successfully",
@@ -1339,7 +1328,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1360,7 +1349,7 @@ session_id: ses_untrusted_999
test("should not inject when no boulder state exists", async () => {
// given - no boulder state
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1388,7 +1377,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - main session fires idle but is NOT in boulder's session_ids
await hook.handler({
@@ -1419,7 +1408,7 @@ session_id: ses_untrusted_999
updateSessionAgent(subagentSessionID, "atlas")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - subagent session goes idle before explicit tracking appends it
await hook.handler({
@@ -1451,7 +1440,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: {
@@ -1480,7 +1469,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1513,7 +1502,7 @@ session_id: ses_untrusted_999
})
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
try {
// when
@@ -1545,7 +1534,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - send abort error then idle
await hook.handler({
@@ -1582,7 +1571,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - a recoverable runtime error fires without waiting for idle
await hook.handler({
@@ -1618,14 +1607,14 @@ session_id: ses_untrusted_999
const originalSetTimeout = globalThis.setTimeout
const scheduledDelays: number[] = []
globalThis.setTimeout = ((_handler: TimerHandler, timeout?: number, ..._args: unknown[]) => {
globalThis.setTimeout = ((_handler: Parameters<typeof setTimeout>[0], timeout?: number, ..._args: unknown[]) => {
scheduledDelays.push(timeout ?? 0)
return originalSetTimeout(() => undefined, 0)
}) as typeof setTimeout
try {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - runtime error resumes immediately and OpenCode later emits stale idle
await hook.handler({
@@ -1671,7 +1660,7 @@ session_id: ses_untrusted_999
try {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - runtime error resumes immediately and then the retry run emits assistant activity
await hook.handler({
@@ -1722,7 +1711,7 @@ session_id: ses_untrusted_999
}
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput, {
const hook = createTestAtlasHook(mockInput, {
directory: TEST_DIR,
backgroundManager: mockBackgroundManager,
})
@@ -1753,7 +1742,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput, {
const hook = createTestAtlasHook(mockInput, {
directory: TEST_DIR,
isContinuationStopped: (sessionID: string) => sessionID === MAIN_SESSION_ID,
})
@@ -1784,7 +1773,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - abort error, then message update, then idle
await hook.handler({
@@ -1827,7 +1816,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1869,7 +1858,7 @@ session_id: ses_untrusted_999
})
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1904,7 +1893,7 @@ session_id: ses_untrusted_999
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1935,7 +1924,7 @@ session_id: ses_untrusted_999
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: {
@@ -1965,7 +1954,7 @@ session_id: ses_untrusted_999
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1997,7 +1986,7 @@ session_id: ses_untrusted_999
registerAgentName("Atlas - Plan Executor")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -2028,7 +2017,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - fire multiple idle events in rapid succession (simulating infinite loop bug)
await hook.handler({
@@ -2069,7 +2058,7 @@ session_id: ses_untrusted_999
const promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2111,7 +2100,7 @@ session_id: ses_untrusted_999
promptMock.mockImplementationOnce(() => Promise.resolve())
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2147,7 +2136,7 @@ session_id: ses_untrusted_999
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2188,7 +2177,7 @@ session_id: ses_untrusted_999
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2233,7 +2222,7 @@ session_id: ses_untrusted_999
}
promptMock.mockImplementationOnce(() => Promise.resolve(undefined))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2284,7 +2273,7 @@ session_id: ses_untrusted_999
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2328,7 +2317,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - create abort state then delete
await hook.handler({
@@ -2381,7 +2370,7 @@ session_id: ses_untrusted_999
updateSessionAgent(MAIN_SESSION_ID, "atlas")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -2407,7 +2396,7 @@ session_id: ses_untrusted_999
fakeNow = 10000
Date.now = () => fakeNow
globalThis.setTimeout = ((callback: TimerHandler, delay?: number, ...args: unknown[]) => {
globalThis.setTimeout = ((callback: Parameters<typeof setTimeout>[0], delay?: number, ...args: unknown[]) => {
const normalized = typeof delay === "number" ? delay : 0
if (normalized >= 5000) {
const timerID = originalSetTimeout(() => undefined, 0)
@@ -2463,7 +2452,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - first idle injects, second idle within cooldown schedules retry timer
await hook.handler({
@@ -2492,7 +2481,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - first idle injects, then 3 rapid idles within cooldown
await hook.handler({
@@ -2527,7 +2516,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - first idle injects, second schedules retry, then plan completes before timer fires
await hook.handler({
@@ -2558,7 +2547,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
@@ -2591,7 +2580,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
@@ -1,26 +1,30 @@
declare const require: (name: string) => any
const { describe, expect, mock, test, afterAll } = require("bun:test")
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { afterAll, describe, expect, test } from "bun:test"
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import type { ModelInfo } from "./types"
const testDirs: string[] = []
const TEST_STORAGE_ROOT = join(tmpdir(), `recent-model-fallback-${Date.now()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
function findNearestTestMessage(messageDir: string): { model?: ModelInfo; tools?: Record<string, boolean> } | null {
const [message] = readdirSync(messageDir)
.filter((fileName) => fileName.endsWith(".json"))
.map((fileName) => {
const content = readFileSync(join(messageDir, fileName), "utf-8")
const parsed = JSON.parse(content) as { model?: ModelInfo; tools?: Record<string, boolean>; time?: { created?: number } }
return {
message: parsed,
createdAt: parsed.time?.created ?? Number.NEGATIVE_INFINITY,
fileName,
}
})
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directPath = join(TEST_MESSAGE_STORAGE, sessionID)
return require("node:fs").existsSync(directPath) ? directPath : null
},
}))
return message?.message ?? null
}
afterAll(() => {
mock.restore()
while (testDirs.length > 0) {
const directory = testDirs.pop()
if (directory) {
@@ -34,8 +38,10 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
// given
const sessionID = "ses_recent_model_fallback"
const directory = mkdtempSync(join(tmpdir(), "recent-model-fallback-dir-"))
const storageRoot = mkdtempSync(join(tmpdir(), "recent-model-fallback-storage-"))
testDirs.push(directory)
const messageDir = join(TEST_MESSAGE_STORAGE, sessionID)
testDirs.push(storageRoot)
const messageDir = join(storageRoot, sessionID)
mkdirSync(messageDir, { recursive: true })
writeFileSync(join(messageDir, "msg_ffff0000_000001.json"), JSON.stringify({
agent: "atlas",
@@ -50,8 +56,6 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
time: { created: 100 },
}), "utf-8")
const { resolveRecentPromptContextForSession } = await import("./recent-model-resolver")
const ctx = {
client: {
session: {
@@ -63,7 +67,12 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
}
// when
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID)
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID, {
isSqliteBackend: () => false,
getMessageDir: () => messageDir,
findNearestMessageWithFields: findNearestTestMessage,
findNearestMessageWithFieldsFromSDK: async () => null,
})
// then
expect(result.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
+20 -5
View File
@@ -11,9 +11,24 @@ type PromptContext = {
tools?: Record<string, boolean>
}
type RecentPromptContextDeps = {
isSqliteBackend: typeof isSqliteBackend
getMessageDir: typeof getMessageDir
findNearestMessageWithFields: typeof findNearestMessageWithFields
findNearestMessageWithFieldsFromSDK: typeof findNearestMessageWithFieldsFromSDK
}
const defaultDeps: RecentPromptContextDeps = {
isSqliteBackend,
getMessageDir,
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
}
export async function resolveRecentPromptContextForSession(
ctx: PluginInput,
sessionID: string
sessionID: string,
deps: RecentPromptContextDeps = defaultDeps,
): Promise<PromptContext> {
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
@@ -59,11 +74,11 @@ export async function resolveRecentPromptContextForSession(
}
let currentMessage = null
if (isSqliteBackend()) {
currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
if (deps.isSqliteBackend()) {
currentMessage = await deps.findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
} else {
const messageDir = getMessageDir(sessionID)
currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const messageDir = deps.getMessageDir(sessionID)
currentMessage = messageDir ? deps.findNearestMessageWithFields(messageDir) : null
}
const model = currentMessage?.model
const tools = normalizePromptTools(currentMessage?.tools)
+3 -2
View File
@@ -1,6 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
appendSessionId,
getPlanProgress,
getTaskSessionState,
readBoulderState,
@@ -33,15 +32,17 @@ export function createToolExecuteAfterHandler(input: {
pendingTaskRefs: Map<string, PendingTaskRef>
autoCommit: boolean
getState: (sessionID: string) => SessionState
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
return async (toolInput, toolOutput): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!toolOutput) {
return
}
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
+3 -1
View File
@@ -13,18 +13,20 @@ export function createToolExecuteBeforeHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
pendingTaskRefs.set(callID, { kind: "track", task })
}
return async (toolInput, toolOutput): Promise<void> => {
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
+1
View File
@@ -11,6 +11,7 @@ export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundTaskStatusProvider
isContinuationStopped?: (sessionID: string) => boolean
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
agentOverrides?: AgentOverrides
/** Enable auto-commit after each atomic task completion (default: true) */
autoCommit?: boolean