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
@@ -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({
+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
+26
View File
@@ -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,
+10 -5
View File
@@ -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
}