fix full-suite isolation regressions
This commit is contained in:
+4
-18
@@ -35,19 +35,7 @@ mock.module("../../features/hook-message-injector", () => ({
|
||||
findNearestMessageWithFields: findNearestMessageWithFieldsMock,
|
||||
}))
|
||||
|
||||
const sessionAgentMap = new Map<string, string>()
|
||||
const resolveRegisteredAgentNameMock = mock((name: string | undefined) => name)
|
||||
|
||||
mock.module("../../features/claude-code-session-state/state", () => ({
|
||||
_resetForTesting: () => { sessionAgentMap.clear() },
|
||||
setSessionAgent: (sessionID: string, agent: string) => { sessionAgentMap.set(sessionID, agent) },
|
||||
getSessionAgent: (sessionID: string) => sessionAgentMap.get(sessionID),
|
||||
resolveRegisteredAgentName: resolveRegisteredAgentNameMock,
|
||||
registerAgentName: () => {},
|
||||
isAgentRegistered: () => false,
|
||||
resolveInheritedPromptTools: () => undefined,
|
||||
}))
|
||||
|
||||
import { _resetForTesting as resetSessionState, updateSessionAgent } from "../../features/claude-code-session-state/state"
|
||||
import { runAggressiveTruncationStrategy } from "./aggressive-truncation-strategy"
|
||||
|
||||
type FakeClient = {
|
||||
@@ -89,25 +77,23 @@ async function flushDeferredPrompt(): Promise<void> {
|
||||
|
||||
describe("runAggressiveTruncationStrategy - pins agent/model/variant on recovered promptAsync", () => {
|
||||
beforeEach(() => {
|
||||
sessionAgentMap.clear()
|
||||
resetSessionState()
|
||||
truncateUntilTargetTokensMock.mockClear()
|
||||
findNearestMessageWithFieldsFromSDKMock.mockClear()
|
||||
findNearestMessageWithFieldsMock.mockClear()
|
||||
resolveRegisteredAgentNameMock.mockClear()
|
||||
findNearestMessageWithFieldsFromSDKMock.mockResolvedValue(null)
|
||||
findNearestMessageWithFieldsMock.mockReturnValue(null)
|
||||
resolveRegisteredAgentNameMock.mockImplementation((name: string | undefined) => name)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionAgentMap.clear()
|
||||
resetSessionState()
|
||||
})
|
||||
|
||||
test("includes the session's resolved agent on promptAsync when agent is known", async () => {
|
||||
// given
|
||||
const { client, calls } = createRecordingClient()
|
||||
const sessionID = "session-truncation-agent"
|
||||
sessionAgentMap.set(sessionID, "sisyphus-junior")
|
||||
updateSessionAgent(sessionID, "sisyphus-junior")
|
||||
|
||||
// when
|
||||
await runAggressiveTruncationStrategy({
|
||||
|
||||
@@ -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
@@ -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" })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -304,6 +304,32 @@ describe("ralph-loop", () => {
|
||||
expect(state?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("#given hanging toast #when session idles #then continuation still injects", async () => {
|
||||
// given - TUI toast never settles
|
||||
const ctx = createMockPluginInput()
|
||||
ctx.client.tui = {
|
||||
showToast: () => new Promise(() => {}),
|
||||
} as never
|
||||
const hook = createRalphLoopHook(ctx)
|
||||
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||
|
||||
// when - session goes idle
|
||||
const result = await Promise.race([
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
}).then(() => "resolved" as const),
|
||||
new Promise<"timed-out">((resolvePromise) => setTimeout(() => resolvePromise("timed-out"), 50)),
|
||||
])
|
||||
|
||||
// then - continuation is not blocked by toast delivery
|
||||
expect(result).toBe("resolved")
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-123")
|
||||
})
|
||||
|
||||
test("should skip continuation when background task is running", async () => {
|
||||
// given - active loop state with a running background task
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
|
||||
@@ -70,27 +70,38 @@ function isAbortError(error: unknown): boolean {
|
||||
&& (error as { name?: unknown }).name === "MessageAbortedError"
|
||||
}
|
||||
|
||||
async function showMaxIterationsToast(
|
||||
function showToastBestEffort(
|
||||
ctx: PluginInput,
|
||||
state: RalphLoopState,
|
||||
): Promise<void> {
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
||||
}).catch(() => {})
|
||||
body: { title: string; message: string; variant: "warning" | "info"; duration: number },
|
||||
): void {
|
||||
try {
|
||||
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function showIterationToast(
|
||||
function showMaxIterationsToast(
|
||||
ctx: PluginInput,
|
||||
state: RalphLoopState,
|
||||
): Promise<void> {
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${state.iteration}/${typeof state.max_iterations === "number" ? state.max_iterations : "unbounded"}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
): void {
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop Stopped",
|
||||
message: `Max iterations (${state.max_iterations}) reached without completion`,
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
function showIterationToast(
|
||||
ctx: PluginInput,
|
||||
state: RalphLoopState,
|
||||
): void {
|
||||
showToastBestEffort(ctx, {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${state.iteration}/${typeof state.max_iterations === "number" ? state.max_iterations : "unbounded"}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
export function createRalphLoopEventHandler(
|
||||
@@ -253,7 +264,7 @@ export function createRalphLoopEventHandler(
|
||||
})
|
||||
options.loopState.clear()
|
||||
|
||||
await showMaxIterationsToast(ctx, state)
|
||||
showMaxIterationsToast(ctx, state)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -269,7 +280,7 @@ export function createRalphLoopEventHandler(
|
||||
max: newState.max_iterations,
|
||||
})
|
||||
|
||||
await showIterationToast(ctx, newState)
|
||||
showIterationToast(ctx, newState)
|
||||
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
@@ -361,7 +372,7 @@ export function createRalphLoopEventHandler(
|
||||
max: state.max_iterations,
|
||||
})
|
||||
options.loopState.clear()
|
||||
await showMaxIterationsToast(ctx, state)
|
||||
showMaxIterationsToast(ctx, state)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -371,7 +382,7 @@ export function createRalphLoopEventHandler(
|
||||
return
|
||||
}
|
||||
|
||||
await showIterationToast(ctx, newState)
|
||||
showIterationToast(ctx, newState)
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
previousSessionID: sessionID,
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, realpathSync } from "fs"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||
|
||||
import { log } from "../../shared"
|
||||
import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler"
|
||||
import { evictLeastRecentlyUsedSession, touchSession, trimSessionReadSet } from "./session-read-permissions"
|
||||
|
||||
export type GuardArgs = {
|
||||
filePath?: string
|
||||
@@ -16,7 +14,11 @@ export type GuardArgs = {
|
||||
|
||||
const MAX_TRACKED_SESSIONS = 256
|
||||
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
|
||||
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||
|
||||
type WriteExistingFileGuardOptions = {
|
||||
maxTrackedSessions?: number
|
||||
maxTrackedPathsPerSession?: number
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -73,9 +75,11 @@ export function isOverwriteEnabled(value: boolean | string | undefined): boolean
|
||||
return false
|
||||
}
|
||||
|
||||
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: WriteExistingFileGuardOptions): Hooks {
|
||||
const readPermissionsBySession = new Map<string, Set<string>>()
|
||||
const sessionLastAccess = new Map<string, number>()
|
||||
const maxTrackedSessions = options?.maxTrackedSessions ?? MAX_TRACKED_SESSIONS
|
||||
const maxTrackedPathsPerSession = options?.maxTrackedPathsPerSession ?? MAX_TRACKED_PATHS_PER_SESSION
|
||||
let canonicalSessionRoot: string | undefined
|
||||
|
||||
function getCanonicalSessionRoot(): string {
|
||||
@@ -95,7 +99,8 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
readPermissionsBySession,
|
||||
sessionLastAccess,
|
||||
getCanonicalSessionRoot,
|
||||
maxTrackedSessions: MAX_TRACKED_SESSIONS,
|
||||
maxTrackedSessions,
|
||||
maxTrackedPathsPerSession,
|
||||
})
|
||||
},
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
|
||||
import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
|
||||
import { createWriteExistingFileGuardHook } from "./index"
|
||||
|
||||
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||
@@ -56,7 +55,7 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
}
|
||||
|
||||
const emitSessionDeleted = async (sessionID: string): Promise<void> => {
|
||||
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
|
||||
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } } as never)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -432,6 +431,11 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
|
||||
test("#given session reads beyond path cap #when writing oldest and newest #then only newest is authorized", async () => {
|
||||
const sessionID = "ses_path_cap"
|
||||
const maxTrackedPathsPerSession = 4
|
||||
hook = createWriteExistingFileGuardHook(
|
||||
{ directory: tempDir } as never,
|
||||
{ maxTrackedPathsPerSession },
|
||||
)
|
||||
const oldestFile = createFile("path-cap/0.txt")
|
||||
let newestFile = oldestFile
|
||||
|
||||
@@ -441,7 +445,7 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
outputArgs: { filePath: oldestFile },
|
||||
})
|
||||
|
||||
for (let index = 1; index <= MAX_TRACKED_PATHS_PER_SESSION; index += 1) {
|
||||
for (let index = 1; index <= maxTrackedPathsPerSession; index += 1) {
|
||||
newestFile = createFile(`path-cap/${index}.txt`)
|
||||
await invoke({
|
||||
tool: "read",
|
||||
|
||||
@@ -5,37 +5,35 @@ import { join } from "node:path"
|
||||
|
||||
const realFs = await import("node:fs")
|
||||
|
||||
const existsSyncMock = mock(realFs.existsSync)
|
||||
const realpathNativeMock = mock(realFs.realpathSync.native)
|
||||
|
||||
mock.module("fs", () => ({
|
||||
...realFs,
|
||||
existsSync: existsSyncMock,
|
||||
realpathSync: {
|
||||
...realFs.realpathSync,
|
||||
native: realpathNativeMock,
|
||||
},
|
||||
}))
|
||||
|
||||
const { createWriteExistingFileGuardHook } = await import("./index")
|
||||
|
||||
describe("createWriteExistingFileGuardHook", () => {
|
||||
let tempDir = ""
|
||||
let existsSyncMock: ReturnType<typeof mock<typeof realFs.existsSync>>
|
||||
let realpathNativeMock: ReturnType<typeof mock<typeof realFs.realpathSync.native>>
|
||||
|
||||
beforeEach(() => {
|
||||
// given
|
||||
tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-lazy-"))
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
existsSyncMock.mockClear()
|
||||
realpathNativeMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given hook factory #when created #then defers fs canonical path calls until first tool invocation", async () => {
|
||||
// given
|
||||
existsSyncMock = mock(realFs.existsSync)
|
||||
realpathNativeMock = mock(realFs.realpathSync.native)
|
||||
mock.module("fs", () => ({
|
||||
...realFs,
|
||||
existsSync: existsSyncMock,
|
||||
realpathSync: {
|
||||
...realFs.realpathSync,
|
||||
native: realpathNativeMock,
|
||||
},
|
||||
}))
|
||||
const { createWriteExistingFileGuardHook } = await import(`./hook?test=${crypto.randomUUID()}`)
|
||||
const existingFile = join(tempDir, "existing.txt")
|
||||
writeFileSync(existingFile, "content")
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ function registerReadPermission(params: {
|
||||
readPermissionsBySession: Map<string, Set<string>>
|
||||
sessionLastAccess: Map<string, number>
|
||||
maxTrackedSessions: number
|
||||
maxTrackedPathsPerSession: number
|
||||
}): void {
|
||||
const readSet = ensureSessionReadSet(params)
|
||||
if (readSet.has(params.canonicalPath)) {
|
||||
@@ -51,7 +52,7 @@ function registerReadPermission(params: {
|
||||
}
|
||||
|
||||
readSet.add(params.canonicalPath)
|
||||
trimSessionReadSet(readSet, MAX_TRACKED_PATHS_PER_SESSION)
|
||||
trimSessionReadSet(readSet, params.maxTrackedPathsPerSession)
|
||||
}
|
||||
|
||||
function consumeReadPermission(params: {
|
||||
@@ -92,8 +93,18 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
|
||||
sessionLastAccess: Map<string, number>
|
||||
getCanonicalSessionRoot: () => string
|
||||
maxTrackedSessions: number
|
||||
maxTrackedPathsPerSession?: number
|
||||
}): Promise<void> {
|
||||
const { ctx, input, output, readPermissionsBySession, sessionLastAccess, getCanonicalSessionRoot, maxTrackedSessions } = params
|
||||
const {
|
||||
ctx,
|
||||
input,
|
||||
output,
|
||||
readPermissionsBySession,
|
||||
sessionLastAccess,
|
||||
getCanonicalSessionRoot,
|
||||
maxTrackedSessions,
|
||||
maxTrackedPathsPerSession = MAX_TRACKED_PATHS_PER_SESSION,
|
||||
} = params
|
||||
const toolName = input.tool?.toLowerCase()
|
||||
if (toolName !== "write" && toolName !== "read") {
|
||||
return
|
||||
@@ -124,6 +135,7 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
|
||||
readPermissionsBySession,
|
||||
sessionLastAccess,
|
||||
maxTrackedSessions,
|
||||
maxTrackedPathsPerSession,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user