refactor: major codebase cleanup - BDD comments, file splitting, bug fixes (#1350)

* style(tests): normalize BDD comments from '// #given' to '// given'

- Replace 4,668 Python-style BDD comments across 107 test files
- Patterns changed: // #given -> // given, // #when -> // when, // #then -> // then
- Also handles no-space variants: //#given -> // given

* fix(rules-injector): prefer output.metadata.filePath over output.title

- Extract file path resolution to dedicated output-path.ts module
- Prefer metadata.filePath which contains actual file path
- Fall back to output.title only when metadata unavailable
- Fixes issue where rules weren't injected when tool output title was a label

* feat(slashcommand): add optional user_message parameter

- Add user_message optional parameter for command arguments
- Model can now call: command='publish' user_message='patch'
- Improves error messages with clearer format guidance
- Helps LLMs understand correct parameter usage

* feat(hooks): restore compaction-context-injector hook

- Restore hook deleted in cbbc7bd0 for session compaction context
- Injects 7 mandatory sections: User Requests, Final Goal, Work Completed,
  Remaining Tasks, Active Working Context, MUST NOT Do, Agent Verification State
- Re-register in hooks/index.ts and main plugin entry

* refactor(background-agent): split manager.ts into focused modules

- Extract constants.ts for TTL values and internal types (52 lines)
- Extract state.ts for TaskStateManager class (204 lines)
- Extract spawner.ts for task creation logic (244 lines)
- Extract result-handler.ts for completion handling (265 lines)
- Reduce manager.ts from 1377 to 755 lines (45% reduction)
- Maintain backward compatible exports

* refactor(agents): split prometheus-prompt.ts into subdirectory

- Move 1196-line prometheus-prompt.ts to prometheus/ subdirectory
- Organize prompt sections into separate files for maintainability
- Update agents/index.ts exports

* refactor(delegate-task): split tools.ts into focused modules

- Extract categories.ts for category definitions and routing
- Extract executor.ts for task execution logic
- Extract helpers.ts for utility functions
- Extract prompt-builder.ts for prompt construction
- Reduce tools.ts complexity with cleaner separation of concerns

* refactor(builtin-skills): split skills.ts into individual skill files

- Move each skill to dedicated file in skills/ subdirectory
- Create barrel export for backward compatibility
- Improve maintainability with focused skill modules

* chore: update import paths and lockfile

- Update prometheus import path after refactor
- Update bun.lock

* fix(tests): complete BDD comment normalization

- Fix remaining #when/#then patterns missed by initial sed
- Affected: state.test.ts, events.test.ts

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
YeonGyu-Kim
2026-02-01 16:47:50 +09:00
committed by GitHub
parent c83150d9ea
commit f146aeff0f
145 changed files with 10307 additions and 9562 deletions
@@ -83,7 +83,7 @@ describe("executeCompact lock management", () => {
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
beforeEach(() => {
// #given: Fresh state for each test
// given: Fresh state for each test
autoCompactState = {
pendingCompact: new Set<string>(),
errorDataBySession: new Map(),
@@ -113,22 +113,22 @@ describe("executeCompact lock management", () => {
})
test("clears lock on successful summarize completion", async () => {
// #given: Valid session with providerID/modelID
// given: Valid session with providerID/modelID
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction successfully
// when: Execute compaction successfully
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared
// then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when summarize throws exception", async () => {
// #given: Summarize will fail
// given: Summarize will fail
mockClient.session.summarize = mock(() =>
Promise.reject(new Error("Network timeout")),
)
@@ -138,21 +138,21 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should still be cleared despite exception
// then: Lock should still be cleared despite exception
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("shows toast when lock already held", async () => {
// #given: Lock already held
// given: Lock already held
autoCompactState.compactionInProgress.add(sessionID)
// #when: Try to execute compaction
// when: Try to execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Toast should be shown with warning message
// then: Toast should be shown with warning message
expect(mockClient.tui.showToast).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
@@ -163,12 +163,12 @@ describe("executeCompact lock management", () => {
}),
)
// #then: compactionInProgress should still have the lock
// then: compactionInProgress should still have the lock
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
})
test("clears lock when fixEmptyMessages path executes", async () => {
// #given: Empty content error scenario
// given: Empty content error scenario
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "non-empty content required",
messageIndex: 0,
@@ -176,15 +176,15 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction (fixEmptyMessages will be called)
// when: Execute compaction (fixEmptyMessages will be called)
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared
// then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when truncation is sufficient", async () => {
// #given: Aggressive truncation scenario with sufficient truncation
// given: Aggressive truncation scenario with sufficient truncation
// This test verifies the early return path in aggressive truncation
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
@@ -197,7 +197,7 @@ describe("executeCompact lock management", () => {
aggressive_truncation: true,
}
// #when: Execute compaction with experimental flag
// when: Execute compaction with experimental flag
await executeCompact(
sessionID,
msg,
@@ -207,30 +207,30 @@ describe("executeCompact lock management", () => {
experimental,
)
// #then: Lock should be cleared even on early return
// then: Lock should be cleared even on early return
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("prevents concurrent compaction attempts", async () => {
// #given: Lock already held (simpler test)
// given: Lock already held (simpler test)
autoCompactState.compactionInProgress.add(sessionID)
// #when: Try to execute compaction while lock is held
// when: Try to execute compaction while lock is held
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Toast should be shown
// then: Toast should be shown
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const blockedToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Compact In Progress",
)
expect(blockedToast).toBeDefined()
// #then: Lock should still be held (not cleared by blocked attempt)
// then: Lock should still be held (not cleared by blocked attempt)
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
})
test("clears lock after max recovery attempts exhausted", async () => {
// #given: All retry/revert attempts exhausted
// given: All retry/revert attempts exhausted
mockClient.session.messages = mock(() => Promise.resolve({ data: [] }))
// Max out all attempts
@@ -247,22 +247,22 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Should show failure toast
// then: Should show failure toast
const toastCalls = (mockClient.tui.showToast as any).mock.calls
const failureToast = toastCalls.find(
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
)
expect(failureToast).toBeDefined()
// #then: Lock should still be cleared
// then: Lock should still be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when client.tui.showToast throws", async () => {
// #given: Toast will fail (this should never happen but testing robustness)
// given: Toast will fail (this should never happen but testing robustness)
mockClient.tui.showToast = mock(() =>
Promise.reject(new Error("Toast failed")),
)
@@ -272,15 +272,15 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock should be cleared even if toast fails
// then: Lock should be cleared even if toast fails
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when prompt_async in continuation throws", async () => {
// #given: prompt_async will fail during continuation
// given: prompt_async will fail during continuation
mockClient.session.prompt_async = mock(() =>
Promise.reject(new Error("Prompt failed")),
)
@@ -290,19 +290,19 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback
await fakeTimeouts.advanceBy(600)
// #then: Lock should be cleared
// then: Lock should be cleared
// The continuation happens in setTimeout, but lock is cleared in finally before that
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("falls through to summarize when truncation is insufficient", async () => {
// #given: Over token limit with truncation returning insufficient
// given: Over token limit with truncation returning insufficient
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 250000,
@@ -322,13 +322,13 @@ describe("executeCompact lock management", () => {
],
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Truncation was attempted
// then: Truncation was attempted
expect(truncateSpy).toHaveBeenCalled()
// #then: Summarize should be called (fall through from insufficient truncation)
// then: Summarize should be called (fall through from insufficient truncation)
expect(mockClient.session.summarize).toHaveBeenCalledWith(
expect.objectContaining({
path: { id: sessionID },
@@ -336,14 +336,14 @@ describe("executeCompact lock management", () => {
}),
)
// #then: Lock should be cleared
// then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
truncateSpy.mockRestore()
})
test("does NOT call summarize when truncation is sufficient", async () => {
// #given: Over token limit with truncation returning sufficient
// given: Over token limit with truncation returning sufficient
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 250000,
@@ -362,22 +362,22 @@ describe("executeCompact lock management", () => {
],
})
// #when: Execute compaction
// when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback
await fakeTimeouts.advanceBy(600)
// #then: Truncation was attempted
// then: Truncation was attempted
expect(truncateSpy).toHaveBeenCalled()
// #then: Summarize should NOT be called (early return from sufficient truncation)
// then: Summarize should NOT be called (early return from sufficient truncation)
expect(mockClient.session.summarize).not.toHaveBeenCalled()
// #then: prompt_async should be called (Continue after successful truncation)
// then: prompt_async should be called (Continue after successful truncation)
expect(mockClient.session.prompt_async).toHaveBeenCalled()
// #then: Lock should be cleared
// then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
truncateSpy.mockRestore()
@@ -24,7 +24,7 @@ describe("truncateUntilTargetTokens", () => {
test("truncates only until target is reached", () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// #given: Two tool results, each 1000 chars. Target reduction is 500 chars.
// given: Two tool results, each 1000 chars. Target reduction is 500 chars.
const results = [
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 1000 },
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 1000 },
@@ -37,11 +37,11 @@ describe("truncateUntilTargetTokens", () => {
originalSize: 1000
}))
// #when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
// when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
// charsPerToken=1 for simplicity in test
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// #then: Should only truncate the first tool
// then: Should only truncate the first tool
expect(result.truncatedCount).toBe(1)
expect(truncateToolResult).toHaveBeenCalledTimes(1)
expect(truncateToolResult).toHaveBeenCalledWith("path1")
@@ -52,7 +52,7 @@ describe("truncateUntilTargetTokens", () => {
test("truncates all if target not reached", () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// #given: Two tool results, each 100 chars. Target reduction is 500 chars.
// given: Two tool results, each 100 chars. Target reduction is 500 chars.
const results = [
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 100 },
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 100 },
@@ -65,10 +65,10 @@ describe("truncateUntilTargetTokens", () => {
originalSize: 100
}))
// #when: reduce 500 chars
// when: reduce 500 chars
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// #then: Should truncate both
// then: Should truncate both
expect(result.truncatedCount).toBe(2)
expect(truncateToolResult).toHaveBeenCalledTimes(2)
expect(result.totalBytesRemoved).toBe(200)
+91 -91
View File
@@ -67,21 +67,21 @@ 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)
// given - hook and undefined output (e.g., from /review command)
const hook = createAtlasHook(createMockPluginInput())
// #when - calling with undefined output
// when - calling with undefined output
const result = await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID: "session-123" },
undefined as unknown as { title: string; output: string; metadata: Record<string, unknown> }
)
// #then - returns undefined without throwing
// then - returns undefined without throwing
expect(result).toBeUndefined()
})
test("should ignore non-delegate_task tools", async () => {
// #given - hook and non-delegate_task tool
// given - hook and non-delegate_task tool
const hook = createAtlasHook(createMockPluginInput())
const output = {
title: "Test Tool",
@@ -89,18 +89,18 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "other_tool", sessionID: "session-123" },
output
)
// #then - output unchanged
// then - output unchanged
expect(output.output).toBe("Original output")
})
test("should not transform when caller is not Atlas", async () => {
// #given - boulder state exists but caller agent in message storage is not Atlas
// given - boulder state exists but caller agent in message storage is not Atlas
const sessionID = "session-non-orchestrator-test"
setupMessageStorage(sessionID, "other-agent")
@@ -122,20 +122,20 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - output unchanged because caller is not orchestrator
// then - output unchanged because caller is not orchestrator
expect(output.output).toBe("Task completed successfully")
cleanupMessageStorage(sessionID)
})
test("should append standalone verification when no boulder state but caller is Atlas", async () => {
// #given - no boulder state, but caller is Atlas
// given - no boulder state, but caller is Atlas
const sessionID = "session-no-boulder-test"
setupMessageStorage(sessionID, "atlas")
@@ -146,13 +146,13 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - standalone verification reminder appended
// then - standalone verification reminder appended
expect(output.output).toContain("Task completed successfully")
expect(output.output).toContain("MANDATORY:")
expect(output.output).toContain("delegate_task(session_id=")
@@ -161,7 +161,7 @@ describe("atlas hook", () => {
})
test("should transform output when caller is Atlas with boulder state", async () => {
// #given - Atlas caller with boulder state
// given - Atlas caller with boulder state
const sessionID = "session-transform-test"
setupMessageStorage(sessionID, "atlas")
@@ -183,13 +183,13 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - output should be transformed (original output preserved for debugging)
// then - output should be transformed (original output preserved for debugging)
expect(output.output).toContain("Task completed successfully")
expect(output.output).toContain("SUBAGENT WORK COMPLETED")
expect(output.output).toContain("test-plan")
@@ -200,7 +200,7 @@ describe("atlas hook", () => {
})
test("should still transform when plan is complete (shows progress)", async () => {
// #given - boulder state with complete plan, Atlas caller
// given - boulder state with complete plan, Atlas caller
const sessionID = "session-complete-plan-test"
setupMessageStorage(sessionID, "atlas")
@@ -222,13 +222,13 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - output transformed even when complete (shows 2/2 done)
// then - output transformed even when complete (shows 2/2 done)
expect(output.output).toContain("SUBAGENT WORK COMPLETED")
expect(output.output).toContain("2/2 done")
expect(output.output).toContain("0 remaining")
@@ -237,7 +237,7 @@ describe("atlas hook", () => {
})
test("should append session ID to boulder state if not present", async () => {
// #given - boulder state without session-append-test, Atlas caller
// given - boulder state without session-append-test, Atlas caller
const sessionID = "session-append-test"
setupMessageStorage(sessionID, "atlas")
@@ -259,13 +259,13 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - sessionID should be appended
// then - sessionID should be appended
const updatedState = readBoulderState(TEST_DIR)
expect(updatedState?.session_ids).toContain(sessionID)
@@ -273,7 +273,7 @@ describe("atlas hook", () => {
})
test("should not duplicate existing session ID", async () => {
// #given - boulder state already has session-dup-test, Atlas caller
// given - boulder state already has session-dup-test, Atlas caller
const sessionID = "session-dup-test"
setupMessageStorage(sessionID, "atlas")
@@ -295,13 +295,13 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - should still have only one sessionID
// then - should still have only one sessionID
const updatedState = readBoulderState(TEST_DIR)
const count = updatedState?.session_ids.filter((id) => id === sessionID).length
expect(count).toBe(1)
@@ -310,7 +310,7 @@ describe("atlas hook", () => {
})
test("should include boulder.json path and notepad path in transformed output", async () => {
// #given - boulder state, Atlas caller
// given - boulder state, Atlas caller
const sessionID = "session-path-test"
setupMessageStorage(sessionID, "atlas")
@@ -332,13 +332,13 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - output should contain plan name and progress
// then - output should contain plan name and progress
expect(output.output).toContain("my-feature")
expect(output.output).toContain("1/3 done")
expect(output.output).toContain("2 remaining")
@@ -347,7 +347,7 @@ describe("atlas hook", () => {
})
test("should include session_id and checkbox instructions in reminder", async () => {
// #given - boulder state, Atlas caller
// given - boulder state, Atlas caller
const sessionID = "session-resume-test"
setupMessageStorage(sessionID, "atlas")
@@ -369,13 +369,13 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - should include session_id instructions and verification
// then - should include session_id instructions and verification
expect(output.output).toContain("delegate_task(session_id=")
expect(output.output).toContain("[x]")
expect(output.output).toContain("MANDATORY:")
@@ -395,7 +395,7 @@ describe("atlas hook", () => {
})
test("should append delegation reminder when orchestrator writes outside .sisyphus/", async () => {
// #given
// given
const hook = createAtlasHook(createMockPluginInput())
const output = {
title: "Write",
@@ -403,20 +403,20 @@ describe("atlas hook", () => {
metadata: { filePath: "/path/to/code.ts" },
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
// then
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).toContain("delegate_task")
expect(output.output).toContain("delegate_task")
})
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
// #given
// given
const hook = createAtlasHook(createMockPluginInput())
const output = {
title: "Edit",
@@ -424,18 +424,18 @@ describe("atlas hook", () => {
metadata: { filePath: "/src/components/button.tsx" },
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Edit", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
// then
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
// #given
// given
const hook = createAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
@@ -444,19 +444,19 @@ describe("atlas hook", () => {
metadata: { filePath: "/project/.sisyphus/plans/work-plan.md" },
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should NOT append reminder when non-orchestrator writes outside .sisyphus/", async () => {
// #given
// given
const nonOrchestratorSession = "non-orchestrator-session"
setupMessageStorage(nonOrchestratorSession, "sisyphus-junior")
@@ -468,13 +468,13 @@ describe("atlas hook", () => {
metadata: { filePath: "/path/to/code.ts" },
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: nonOrchestratorSession },
output
)
// #then
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
@@ -482,7 +482,7 @@ describe("atlas hook", () => {
})
test("should NOT append reminder for read-only tools", async () => {
// #given
// given
const hook = createAtlasHook(createMockPluginInput())
const originalOutput = "File content"
const output = {
@@ -491,18 +491,18 @@ describe("atlas hook", () => {
metadata: { filePath: "/path/to/code.ts" },
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Read", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
// then
expect(output.output).toBe(originalOutput)
})
test("should handle missing filePath gracefully", async () => {
// #given
// given
const hook = createAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
@@ -511,19 +511,19 @@ describe("atlas hook", () => {
metadata: {},
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
// then
expect(output.output).toBe(originalOutput)
})
describe("cross-platform path validation (Windows support)", () => {
test("should NOT append reminder when orchestrator writes inside .sisyphus\\ (Windows backslash)", async () => {
// #given
// given
const hook = createAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
@@ -532,19 +532,19 @@ describe("atlas hook", () => {
metadata: { filePath: ".sisyphus\\plans\\work-plan.md" },
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
// #given
// given
const hook = createAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
@@ -553,19 +553,19 @@ describe("atlas hook", () => {
metadata: { filePath: ".sisyphus\\plans/work-plan.md" },
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
// #given
// given
const hook = createAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
@@ -574,19 +574,19 @@ describe("atlas hook", () => {
metadata: { filePath: "C:\\Users\\test\\project\\.sisyphus\\plans\\x.md" },
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
// then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
test("should append reminder for Windows path outside .sisyphus\\", async () => {
// #given
// given
const hook = createAtlasHook(createMockPluginInput())
const output = {
title: "Write",
@@ -594,13 +594,13 @@ describe("atlas hook", () => {
metadata: { filePath: "C:\\Users\\test\\project\\src\\code.ts" },
}
// #when
// when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
// then
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
})
})
@@ -623,7 +623,7 @@ describe("atlas hook", () => {
})
test("should inject continuation when boulder has incomplete tasks", async () => {
// #given - boulder state with incomplete plan
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2\n- [ ] Task 3")
@@ -638,7 +638,7 @@ describe("atlas hook", () => {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// #when
// when
await hook.handler({
event: {
type: "session.idle",
@@ -646,7 +646,7 @@ describe("atlas hook", () => {
},
})
// #then - should call prompt with continuation
// then - should call prompt with continuation
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
@@ -655,11 +655,11 @@ describe("atlas hook", () => {
})
test("should not inject when no boulder state exists", async () => {
// #given - no boulder state
// given - no boulder state
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// #when
// when
await hook.handler({
event: {
type: "session.idle",
@@ -667,12 +667,12 @@ describe("atlas hook", () => {
},
})
// #then - should not call prompt
// then - should not call prompt
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should not inject when boulder plan is complete", async () => {
// #given - boulder state with complete plan
// given - boulder state with complete plan
const planPath = join(TEST_DIR, "complete-plan.md")
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2")
@@ -687,7 +687,7 @@ describe("atlas hook", () => {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// #when
// when
await hook.handler({
event: {
type: "session.idle",
@@ -695,12 +695,12 @@ describe("atlas hook", () => {
},
})
// #then - should not call prompt
// then - should not call prompt
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should skip when abort error occurred before idle", async () => {
// #given - boulder state with incomplete plan
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
@@ -715,7 +715,7 @@ describe("atlas hook", () => {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// #when - send abort error then idle
// when - send abort error then idle
await hook.handler({
event: {
type: "session.error",
@@ -732,12 +732,12 @@ describe("atlas hook", () => {
},
})
// #then - should not call prompt
// then - should not call prompt
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should skip when background tasks are running", async () => {
// #given - boulder state with incomplete plan
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
@@ -759,7 +759,7 @@ describe("atlas hook", () => {
backgroundManager: mockBackgroundManager as any,
})
// #when
// when
await hook.handler({
event: {
type: "session.idle",
@@ -767,12 +767,12 @@ describe("atlas hook", () => {
},
})
// #then - should not call prompt
// then - should not call prompt
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should clear abort state on message.updated", async () => {
// #given - boulder with incomplete plan
// given - boulder with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
@@ -787,7 +787,7 @@ describe("atlas hook", () => {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// #when - abort error, then message update, then idle
// when - abort error, then message update, then idle
await hook.handler({
event: {
type: "session.error",
@@ -810,12 +810,12 @@ describe("atlas hook", () => {
},
})
// #then - should call prompt because abort state was cleared
// then - should call prompt because abort state was cleared
expect(mockInput._promptMock).toHaveBeenCalled()
})
test("should include plan progress in continuation prompt", async () => {
// #given - boulder state with specific progress
// given - boulder state with specific progress
const planPath = join(TEST_DIR, "progress-plan.md")
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2\n- [ ] Task 3\n- [ ] Task 4")
@@ -830,7 +830,7 @@ describe("atlas hook", () => {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// #when
// when
await hook.handler({
event: {
type: "session.idle",
@@ -838,14 +838,14 @@ describe("atlas hook", () => {
},
})
// #then - should include progress
// then - should include progress
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.body.parts[0].text).toContain("2/4 completed")
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should not inject when last agent is not Atlas", async () => {
// #given - boulder state with incomplete plan, but last agent is NOT Atlas
// given - boulder state with incomplete plan, but last agent is NOT Atlas
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
@@ -857,14 +857,14 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
// #given - last agent is NOT Atlas
// given - last agent is NOT Atlas
cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// #when
// when
await hook.handler({
event: {
type: "session.idle",
@@ -872,12 +872,12 @@ describe("atlas hook", () => {
},
})
// #then - should NOT call prompt because agent is not Atlas
// then - should NOT call prompt because agent is not Atlas
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should debounce rapid continuation injections (prevent infinite loop)", async () => {
// #given - boulder state with incomplete plan
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
@@ -892,7 +892,7 @@ describe("atlas hook", () => {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// #when - fire multiple idle events in rapid succession (simulating infinite loop bug)
// when - fire multiple idle events in rapid succession (simulating infinite loop bug)
await hook.handler({
event: {
type: "session.idle",
@@ -912,12 +912,12 @@ describe("atlas hook", () => {
},
})
// #then - should only call prompt ONCE due to debouncing
// then - should only call prompt ONCE due to debouncing
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
})
test("should cleanup on session.deleted", async () => {
// #given - boulder state
// given - boulder state
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
@@ -932,7 +932,7 @@ describe("atlas hook", () => {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// #when - create abort state then delete
// when - create abort state then delete
await hook.handler({
event: {
type: "session.error",
@@ -960,7 +960,7 @@ describe("atlas hook", () => {
},
})
// #then - should call prompt because session state was cleaned
// then - should call prompt because session state was cleaned
expect(mockInput._promptMock).toHaveBeenCalled()
})
})
+72 -72
View File
@@ -10,150 +10,150 @@ import {
describe("auto-slash-command detector", () => {
describe("removeCodeBlocks", () => {
it("should remove markdown code blocks", () => {
// #given text with code blocks
// given text with code blocks
const text = "Hello ```code here``` world"
// #when removing code blocks
// when removing code blocks
const result = removeCodeBlocks(text)
// #then code blocks should be removed
// then code blocks should be removed
expect(result).toBe("Hello world")
})
it("should remove multiline code blocks", () => {
// #given text with multiline code blocks
// given text with multiline code blocks
const text = `Before
\`\`\`javascript
/command-inside-code
\`\`\`
After`
// #when removing code blocks
// when removing code blocks
const result = removeCodeBlocks(text)
// #then code blocks should be removed
// then code blocks should be removed
expect(result).toContain("Before")
expect(result).toContain("After")
expect(result).not.toContain("/command-inside-code")
})
it("should handle text without code blocks", () => {
// #given text without code blocks
// given text without code blocks
const text = "Just regular text"
// #when removing code blocks
// when removing code blocks
const result = removeCodeBlocks(text)
// #then text should remain unchanged
// then text should remain unchanged
expect(result).toBe("Just regular text")
})
})
describe("parseSlashCommand", () => {
it("should parse simple command without args", () => {
// #given a simple slash command
// given a simple slash command
const text = "/commit"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should extract command correctly
// then should extract command correctly
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
expect(result?.args).toBe("")
})
it("should parse command with arguments", () => {
// #given a slash command with arguments
// given a slash command with arguments
const text = "/plan create a new feature for auth"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should extract command and args
// then should extract command and args
expect(result).not.toBeNull()
expect(result?.command).toBe("plan")
expect(result?.args).toBe("create a new feature for auth")
})
it("should parse command with quoted arguments", () => {
// #given a slash command with quoted arguments
// given a slash command with quoted arguments
const text = '/execute "build the API"'
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should extract command and args
// then should extract command and args
expect(result).not.toBeNull()
expect(result?.command).toBe("execute")
expect(result?.args).toBe('"build the API"')
})
it("should parse command with hyphen in name", () => {
// #given a slash command with hyphen
// given a slash command with hyphen
const text = "/frontend-template-creator project"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should extract full command name
// then should extract full command name
expect(result).not.toBeNull()
expect(result?.command).toBe("frontend-template-creator")
expect(result?.args).toBe("project")
})
it("should return null for non-slash text", () => {
// #given text without slash
// given text without slash
const text = "regular text"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should return null
// then should return null
expect(result).toBeNull()
})
it("should return null for slash not at start", () => {
// #given text with slash in middle
// given text with slash in middle
const text = "some text /command"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should return null (slash not at start)
// then should return null (slash not at start)
expect(result).toBeNull()
})
it("should return null for just a slash", () => {
// #given just a slash
// given just a slash
const text = "/"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should return null
// then should return null
expect(result).toBeNull()
})
it("should return null for slash followed by number", () => {
// #given slash followed by number
// given slash followed by number
const text = "/123"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should return null (command must start with letter)
// then should return null (command must start with letter)
expect(result).toBeNull()
})
it("should handle whitespace before slash", () => {
// #given command with leading whitespace
// given command with leading whitespace
const text = " /commit"
// #when parsing
// when parsing
const result = parseSlashCommand(text)
// #then should parse after trimming
// then should parse after trimming
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
})
@@ -161,31 +161,31 @@ After`
describe("isExcludedCommand", () => {
it("should exclude ralph-loop", () => {
// #given ralph-loop command
// #when checking exclusion
// #then should be excluded
// given ralph-loop command
// when checking exclusion
// then should be excluded
expect(isExcludedCommand("ralph-loop")).toBe(true)
})
it("should exclude cancel-ralph", () => {
// #given cancel-ralph command
// #when checking exclusion
// #then should be excluded
// given cancel-ralph command
// when checking exclusion
// then should be excluded
expect(isExcludedCommand("cancel-ralph")).toBe(true)
})
it("should be case-insensitive for exclusion", () => {
// #given uppercase variants
// #when checking exclusion
// #then should still be excluded
// given uppercase variants
// when checking exclusion
// then should still be excluded
expect(isExcludedCommand("RALPH-LOOP")).toBe(true)
expect(isExcludedCommand("Cancel-Ralph")).toBe(true)
})
it("should not exclude regular commands", () => {
// #given regular commands
// #when checking exclusion
// #then should not be excluded
// given regular commands
// when checking exclusion
// then should not be excluded
expect(isExcludedCommand("commit")).toBe(false)
expect(isExcludedCommand("plan")).toBe(false)
expect(isExcludedCommand("execute")).toBe(false)
@@ -194,102 +194,102 @@ After`
describe("detectSlashCommand", () => {
it("should detect slash command in plain text", () => {
// #given plain text with slash command
// given plain text with slash command
const text = "/commit fix typo"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should detect
// then should detect
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
expect(result?.args).toBe("fix typo")
})
it("should NOT detect slash command inside code block", () => {
// #given slash command inside code block
// given slash command inside code block
const text = "```bash\n/command\n```"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should not detect (only code block content)
// then should not detect (only code block content)
expect(result).toBeNull()
})
it("should detect command when text has code blocks elsewhere", () => {
// #given slash command before code block
// given slash command before code block
const text = "/commit fix\n```code```"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should detect the command
// then should detect the command
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
})
it("should NOT detect excluded commands", () => {
// #given excluded command
// given excluded command
const text = "/ralph-loop do something"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should not detect
// then should not detect
expect(result).toBeNull()
})
it("should return null for non-command text", () => {
// #given regular text
// given regular text
const text = "Just some regular text"
// #when detecting
// when detecting
const result = detectSlashCommand(text)
// #then should return null
// then should return null
expect(result).toBeNull()
})
})
describe("extractPromptText", () => {
it("should extract text from parts", () => {
// #given message parts
// given message parts
const parts = [
{ type: "text", text: "Hello " },
{ type: "tool_use", id: "123" },
{ type: "text", text: "world" },
]
// #when extracting
// when extracting
const result = extractPromptText(parts)
// #then should join text parts
// then should join text parts
expect(result).toBe("Hello world")
})
it("should handle empty parts", () => {
// #given empty parts
// given empty parts
const parts: Array<{ type: string; text?: string }> = []
// #when extracting
// when extracting
const result = extractPromptText(parts)
// #then should return empty string
// then should return empty string
expect(result).toBe("")
})
it("should handle parts without text", () => {
// #given parts without text content
// given parts without text content
const parts = [
{ type: "tool_use", id: "123" },
{ type: "tool_result", output: "result" },
]
// #when extracting
// when extracting
const result = extractPromptText(parts)
// #then should return empty string
// then should return empty string
expect(result).toBe("")
})
})
+39 -39
View File
@@ -42,118 +42,118 @@ describe("createAutoSlashCommandHook", () => {
describe("slash command replacement", () => {
it("should not modify message when command not found", async () => {
// #given a slash command that doesn't exist
// given a slash command that doesn't exist
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-notfound-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/nonexistent-command args")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should NOT modify the message (feature inactive when command not found)
// then should NOT modify the message (feature inactive when command not found)
expect(output.parts[0].text).toBe(originalText)
})
it("should not modify message for unknown command (feature inactive)", async () => {
// #given unknown slash command
// given unknown slash command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-tags-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/some-command")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should NOT modify (command not found = feature inactive)
// then should NOT modify (command not found = feature inactive)
expect(output.parts[0].text).toBe(originalText)
})
it("should not modify for unknown command (no prepending)", async () => {
// #given unknown slash command
// given unknown slash command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-replace-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/test-cmd some args")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify (feature inactive for unknown commands)
// then should not modify (feature inactive for unknown commands)
expect(output.parts[0].text).toBe(originalText)
})
})
describe("no slash command", () => {
it("should do nothing for regular text", async () => {
// #given regular text without slash
// given regular text without slash
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-regular-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("Just regular text")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify
// then should not modify
expect(output.parts[0].text).toBe(originalText)
})
it("should do nothing for slash in middle of text", async () => {
// #given slash in middle
// given slash in middle
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-middle-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("Please run /commit later")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not detect (not at start)
// then should not detect (not at start)
expect(output.parts[0].text).toBe(originalText)
})
})
describe("excluded commands", () => {
it("should NOT trigger for ralph-loop command", async () => {
// #given ralph-loop command
// given ralph-loop command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-ralph-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/ralph-loop do something")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify (excluded command)
// then should not modify (excluded command)
expect(output.parts[0].text).toBe(originalText)
})
it("should NOT trigger for cancel-ralph command", async () => {
// #given cancel-ralph command
// given cancel-ralph command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-cancel-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/cancel-ralph")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify
// then should not modify
expect(output.parts[0].text).toBe(originalText)
})
})
describe("already processed", () => {
it("should skip if auto-slash-command tags already present", async () => {
// #given text with existing tags
// given text with existing tags
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-existing-${Date.now()}`
const input = createMockInput(sessionID)
@@ -162,76 +162,76 @@ describe("createAutoSlashCommandHook", () => {
)
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify
// then should not modify
expect(output.parts[0].text).toBe(originalText)
})
})
describe("code blocks", () => {
it("should NOT detect command inside code block", async () => {
// #given command inside code block
// given command inside code block
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-codeblock-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("```\n/commit\n```")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not detect
// then should not detect
expect(output.parts[0].text).toBe(originalText)
})
})
describe("edge cases", () => {
it("should handle empty text", async () => {
// #given empty text
// given empty text
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-empty-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("")
// #when hook is called
// #then should not throw
// when hook is called
// then should not throw
await expect(hook["chat.message"](input, output)).resolves.toBeUndefined()
})
it("should handle just slash", async () => {
// #given just slash
// given just slash
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-slash-only-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/")
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify
// then should not modify
expect(output.parts[0].text).toBe(originalText)
})
it("should handle command with special characters in args (not found = no modification)", async () => {
// #given command with special characters that doesn't exist
// given command with special characters that doesn't exist
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-special-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput('/execute "test & stuff <tag>"')
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify (command not found = feature inactive)
// then should not modify (command not found = feature inactive)
expect(output.parts[0].text).toBe(originalText)
})
it("should handle multiple text parts (unknown command = no modification)", async () => {
// #given multiple text parts with unknown command
// given multiple text parts with unknown command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-multi-${Date.now()}`
const input = createMockInput(sessionID)
@@ -244,10 +244,10 @@ describe("createAutoSlashCommandHook", () => {
}
const originalText = output.parts[0].text
// #when hook is called
// when hook is called
await hook["chat.message"](input, output)
// #then should not modify (command not found = feature inactive)
// then should not modify (command not found = feature inactive)
expect(output.parts[0].text).toBe(originalText)
})
})
+66 -66
View File
@@ -4,250 +4,250 @@ import { isPrereleaseVersion, isDistTag, isPrereleaseOrDistTag, extractChannel }
describe("auto-update-checker", () => {
describe("isPrereleaseVersion", () => {
test("returns true for beta versions", () => {
// #given a beta version
// given a beta version
const version = "3.0.0-beta.1"
// #when checking if prerelease
// when checking if prerelease
const result = isPrereleaseVersion(version)
// #then returns true
// then returns true
expect(result).toBe(true)
})
test("returns true for alpha versions", () => {
// #given an alpha version
// given an alpha version
const version = "1.0.0-alpha"
// #when checking if prerelease
// when checking if prerelease
const result = isPrereleaseVersion(version)
// #then returns true
// then returns true
expect(result).toBe(true)
})
test("returns true for rc versions", () => {
// #given an rc version
// given an rc version
const version = "2.0.0-rc.1"
// #when checking if prerelease
// when checking if prerelease
const result = isPrereleaseVersion(version)
// #then returns true
// then returns true
expect(result).toBe(true)
})
test("returns false for stable versions", () => {
// #given a stable version
// given a stable version
const version = "2.14.0"
// #when checking if prerelease
// when checking if prerelease
const result = isPrereleaseVersion(version)
// #then returns false
// then returns false
expect(result).toBe(false)
})
})
describe("isDistTag", () => {
test("returns true for beta dist-tag", () => {
// #given beta dist-tag
// given beta dist-tag
const version = "beta"
// #when checking if dist-tag
// when checking if dist-tag
const result = isDistTag(version)
// #then returns true
// then returns true
expect(result).toBe(true)
})
test("returns true for next dist-tag", () => {
// #given next dist-tag
// given next dist-tag
const version = "next"
// #when checking if dist-tag
// when checking if dist-tag
const result = isDistTag(version)
// #then returns true
// then returns true
expect(result).toBe(true)
})
test("returns true for canary dist-tag", () => {
// #given canary dist-tag
// given canary dist-tag
const version = "canary"
// #when checking if dist-tag
// when checking if dist-tag
const result = isDistTag(version)
// #then returns true
// then returns true
expect(result).toBe(true)
})
test("returns false for semver versions", () => {
// #given a semver version
// given a semver version
const version = "2.14.0"
// #when checking if dist-tag
// when checking if dist-tag
const result = isDistTag(version)
// #then returns false
// then returns false
expect(result).toBe(false)
})
test("returns false for latest (handled separately)", () => {
// #given latest tag
// given latest tag
const version = "latest"
// #when checking if dist-tag
// when checking if dist-tag
const result = isDistTag(version)
// #then returns true (but latest is filtered before this check)
// then returns true (but latest is filtered before this check)
expect(result).toBe(true)
})
})
describe("isPrereleaseOrDistTag", () => {
test("returns false for null", () => {
// #given null version
// given null version
const version = null
// #when checking
// when checking
const result = isPrereleaseOrDistTag(version)
// #then returns false
// then returns false
expect(result).toBe(false)
})
test("returns true for prerelease version", () => {
// #given prerelease version
// given prerelease version
const version = "3.0.0-beta.1"
// #when checking
// when checking
const result = isPrereleaseOrDistTag(version)
// #then returns true
// then returns true
expect(result).toBe(true)
})
test("returns true for dist-tag", () => {
// #given dist-tag
// given dist-tag
const version = "beta"
// #when checking
// when checking
const result = isPrereleaseOrDistTag(version)
// #then returns true
// then returns true
expect(result).toBe(true)
})
test("returns false for stable version", () => {
// #given stable version
// given stable version
const version = "2.14.0"
// #when checking
// when checking
const result = isPrereleaseOrDistTag(version)
// #then returns false
// then returns false
expect(result).toBe(false)
})
})
describe("extractChannel", () => {
test("extracts beta from dist-tag", () => {
// #given beta dist-tag
// given beta dist-tag
const version = "beta"
// #when extracting channel
// when extracting channel
const result = extractChannel(version)
// #then returns beta
// then returns beta
expect(result).toBe("beta")
})
test("extracts next from dist-tag", () => {
// #given next dist-tag
// given next dist-tag
const version = "next"
// #when extracting channel
// when extracting channel
const result = extractChannel(version)
// #then returns next
// then returns next
expect(result).toBe("next")
})
test("extracts canary from dist-tag", () => {
// #given canary dist-tag
// given canary dist-tag
const version = "canary"
// #when extracting channel
// when extracting channel
const result = extractChannel(version)
// #then returns canary
// then returns canary
expect(result).toBe("canary")
})
test("extracts beta from prerelease version", () => {
// #given beta prerelease version
// given beta prerelease version
const version = "3.0.0-beta.1"
// #when extracting channel
// when extracting channel
const result = extractChannel(version)
// #then returns beta
// then returns beta
expect(result).toBe("beta")
})
test("extracts alpha from prerelease version", () => {
// #given alpha prerelease version
// given alpha prerelease version
const version = "1.0.0-alpha"
// #when extracting channel
// when extracting channel
const result = extractChannel(version)
// #then returns alpha
// then returns alpha
expect(result).toBe("alpha")
})
test("extracts rc from prerelease version", () => {
// #given rc prerelease version
// given rc prerelease version
const version = "2.0.0-rc.1"
// #when extracting channel
// when extracting channel
const result = extractChannel(version)
// #then returns rc
// then returns rc
expect(result).toBe("rc")
})
test("returns latest for stable version", () => {
// #given stable version
// given stable version
const version = "2.14.0"
// #when extracting channel
// when extracting channel
const result = extractChannel(version)
// #then returns latest
// then returns latest
expect(result).toBe("latest")
})
test("returns latest for null", () => {
// #given null version
// given null version
const version = null
// #when extracting channel
// when extracting channel
const result = extractChannel(version)
// #then returns latest
// then returns latest
expect(result).toBe("latest")
})
test("handles complex prerelease identifiers", () => {
// #given complex prerelease
// given complex prerelease
const version = "3.0.0-beta.1.experimental"
// #when extracting channel
// when extracting channel
const result = extractChannel(version)
// #then returns beta
// then returns beta
expect(result).toBe("beta")
})
})
+45 -45
View File
@@ -31,19 +31,19 @@ describe("category-skill-reminder hook", () => {
describe("target agent detection", () => {
test("should inject reminder for sisyphus agent after 3 tool calls", async () => {
// #given - sisyphus agent session with multiple tool calls
// given - sisyphus agent session with multiple tool calls
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "sisyphus-session"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "file content", metadata: {} }
// #when - 3 edit tool calls are made
// when - 3 edit tool calls are made
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
// #then - reminder should be injected
// then - reminder should be injected
expect(output.output).toContain("[Category+Skill Reminder]")
expect(output.output).toContain("delegate_task")
@@ -51,135 +51,135 @@ describe("category-skill-reminder hook", () => {
})
test("should inject reminder for atlas agent", async () => {
// #given - atlas agent session
// given - atlas agent session
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "atlas-session"
updateSessionAgent(sessionID, "Atlas")
const output = { title: "", output: "result", metadata: {} }
// #when - 3 tool calls are made
// when - 3 tool calls are made
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "3" }, output)
// #then - reminder should be injected
// then - reminder should be injected
expect(output.output).toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
test("should inject reminder for sisyphus-junior agent", async () => {
// #given - sisyphus-junior agent session
// given - sisyphus-junior agent session
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "junior-session"
updateSessionAgent(sessionID, "sisyphus-junior")
const output = { title: "", output: "result", metadata: {} }
// #when - 3 tool calls are made
// when - 3 tool calls are made
await hook["tool.execute.after"]({ tool: "write", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "write", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "write", sessionID, callID: "3" }, output)
// #then - reminder should be injected
// then - reminder should be injected
expect(output.output).toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
test("should NOT inject reminder for non-target agents", async () => {
// #given - librarian agent session (not a target)
// given - librarian agent session (not a target)
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "librarian-session"
updateSessionAgent(sessionID, "librarian")
const output = { title: "", output: "result", metadata: {} }
// #when - 3 tool calls are made
// when - 3 tool calls are made
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
// #then - reminder should NOT be injected
// then - reminder should NOT be injected
expect(output.output).not.toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
test("should detect agent from input.agent when session state is empty", async () => {
// #given - no session state, agent provided in input
// given - no session state, agent provided in input
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "input-agent-session"
const output = { title: "", output: "result", metadata: {} }
// #when - 3 tool calls with agent in input
// when - 3 tool calls with agent in input
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1", agent: "Sisyphus" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2", agent: "Sisyphus" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3", agent: "Sisyphus" }, output)
// #then - reminder should be injected
// then - reminder should be injected
expect(output.output).toContain("[Category+Skill Reminder]")
})
})
describe("delegation tool tracking", () => {
test("should NOT inject reminder if delegate_task is used", async () => {
// #given - sisyphus agent that uses delegate_task
// given - sisyphus agent that uses delegate_task
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "delegation-session"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// #when - delegate_task is used, then more tool calls
// when - delegate_task is used, then more tool calls
await hook["tool.execute.after"]({ tool: "delegate_task", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output)
// #then - reminder should NOT be injected (delegation was used)
// then - reminder should NOT be injected (delegation was used)
expect(output.output).not.toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
test("should NOT inject reminder if call_omo_agent is used", async () => {
// #given - sisyphus agent that uses call_omo_agent
// given - sisyphus agent that uses call_omo_agent
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "omo-agent-session"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// #when - call_omo_agent is used first
// when - call_omo_agent is used first
await hook["tool.execute.after"]({ tool: "call_omo_agent", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output)
// #then - reminder should NOT be injected
// then - reminder should NOT be injected
expect(output.output).not.toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
test("should NOT inject reminder if task tool is used", async () => {
// #given - sisyphus agent that uses task tool
// given - sisyphus agent that uses task tool
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "task-session"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// #when - task tool is used
// when - task tool is used
await hook["tool.execute.after"]({ tool: "task", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output)
// #then - reminder should NOT be injected
// then - reminder should NOT be injected
expect(output.output).not.toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
@@ -188,25 +188,25 @@ describe("category-skill-reminder hook", () => {
describe("tool call counting", () => {
test("should NOT inject reminder before 3 tool calls", async () => {
// #given - sisyphus agent with only 2 tool calls
// given - sisyphus agent with only 2 tool calls
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "few-calls-session"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// #when - only 2 tool calls are made
// when - only 2 tool calls are made
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
// #then - reminder should NOT be injected yet
// then - reminder should NOT be injected yet
expect(output.output).not.toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
test("should only inject reminder once per session", async () => {
// #given - sisyphus agent session
// given - sisyphus agent session
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "once-session"
updateSessionAgent(sessionID, "Sisyphus")
@@ -214,7 +214,7 @@ describe("category-skill-reminder hook", () => {
const output1 = { title: "", output: "result1", metadata: {} }
const output2 = { title: "", output: "result2", metadata: {} }
// #when - 6 tool calls are made (should trigger at 3, not again at 6)
// when - 6 tool calls are made (should trigger at 3, not again at 6)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output1)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output1)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output1)
@@ -222,7 +222,7 @@ describe("category-skill-reminder hook", () => {
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2)
// #then - reminder should be in output1 but not output2
// then - reminder should be in output1 but not output2
expect(output1.output).toContain("[Category+Skill Reminder]")
expect(output2.output).not.toContain("[Category+Skill Reminder]")
@@ -230,19 +230,19 @@ describe("category-skill-reminder hook", () => {
})
test("should only count delegatable work tools", async () => {
// #given - sisyphus agent with mixed tool calls
// given - sisyphus agent with mixed tool calls
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "mixed-tools-session"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// #when - non-delegatable tools are called (should not count)
// when - non-delegatable tools are called (should not count)
await hook["tool.execute.after"]({ tool: "lsp_goto_definition", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "lsp_find_references", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "lsp_symbols", sessionID, callID: "3" }, output)
// #then - reminder should NOT be injected (LSP tools don't count)
// then - reminder should NOT be injected (LSP tools don't count)
expect(output.output).not.toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
@@ -251,7 +251,7 @@ describe("category-skill-reminder hook", () => {
describe("event handling", () => {
test("should reset state on session.deleted event", async () => {
// #given - sisyphus agent with reminder already shown
// given - sisyphus agent with reminder already shown
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "delete-session"
updateSessionAgent(sessionID, "Sisyphus")
@@ -262,7 +262,7 @@ describe("category-skill-reminder hook", () => {
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output1)
expect(output1.output).toContain("[Category+Skill Reminder]")
// #when - session is deleted and new session starts
// when - session is deleted and new session starts
await hook.event({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
const output2 = { title: "", output: "result2", metadata: {} }
@@ -270,14 +270,14 @@ describe("category-skill-reminder hook", () => {
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2)
// #then - reminder should be shown again (state was reset)
// then - reminder should be shown again (state was reset)
expect(output2.output).toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
test("should reset state on session.compacted event", async () => {
// #given - sisyphus agent with reminder already shown
// given - sisyphus agent with reminder already shown
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "compact-session"
updateSessionAgent(sessionID, "Sisyphus")
@@ -288,7 +288,7 @@ describe("category-skill-reminder hook", () => {
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output1)
expect(output1.output).toContain("[Category+Skill Reminder]")
// #when - session is compacted
// when - session is compacted
await hook.event({ event: { type: "session.compacted", properties: { sessionID } } })
const output2 = { title: "", output: "result2", metadata: {} }
@@ -296,7 +296,7 @@ describe("category-skill-reminder hook", () => {
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2)
// #then - reminder should be shown again (state was reset)
// then - reminder should be shown again (state was reset)
expect(output2.output).toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
@@ -305,39 +305,39 @@ describe("category-skill-reminder hook", () => {
describe("case insensitivity", () => {
test("should handle tool names case-insensitively", async () => {
// #given - sisyphus agent with mixed case tool names
// given - sisyphus agent with mixed case tool names
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "case-session"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// #when - tool calls with different cases
// when - tool calls with different cases
await hook["tool.execute.after"]({ tool: "EDIT", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "Edit", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
// #then - reminder should be injected (all counted)
// then - reminder should be injected (all counted)
expect(output.output).toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
})
test("should handle delegation tool names case-insensitively", async () => {
// #given - sisyphus agent using DELEGATE_TASK in uppercase
// given - sisyphus agent using DELEGATE_TASK in uppercase
const hook = createCategorySkillReminderHook(createMockPluginInput())
const sessionID = "case-delegate-session"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// #when - DELEGATE_TASK in uppercase is used
// when - DELEGATE_TASK in uppercase is used
await hook["tool.execute.after"]({ tool: "DELEGATE_TASK", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output)
// #then - reminder should NOT be injected (delegation was detected)
// then - reminder should NOT be injected (delegation was detected)
expect(output.output).not.toContain("[Category+Skill Reminder]")
clearSessionAgent(sessionID)
+15 -15
View File
@@ -2,18 +2,18 @@ import { describe, test, expect, beforeEach, mock } from "bun:test"
describe("comment-checker CLI path resolution", () => {
describe("lazy initialization", () => {
// #given module is imported
// #when COMMENT_CHECKER_CLI_PATH is accessed
// #then findCommentCheckerPathSync should NOT have been called during import
// given module is imported
// when COMMENT_CHECKER_CLI_PATH is accessed
// then findCommentCheckerPathSync should NOT have been called during import
test("getCommentCheckerPathSync should be lazy - not called on module import", async () => {
// #given a fresh module import
// given a fresh module import
// We need to verify that importing the module doesn't immediately call findCommentCheckerPathSync
// #when we import the module
// when we import the module
const cliModule = await import("./cli")
// #then getCommentCheckerPathSync should exist and be callable
// then getCommentCheckerPathSync should exist and be callable
expect(typeof cliModule.getCommentCheckerPathSync).toBe("function")
// The key test: calling getCommentCheckerPathSync should work
@@ -24,33 +24,33 @@ describe("comment-checker CLI path resolution", () => {
})
test("getCommentCheckerPathSync should cache result after first call", async () => {
// #given getCommentCheckerPathSync is called once
// given getCommentCheckerPathSync is called once
const cliModule = await import("./cli")
const firstResult = cliModule.getCommentCheckerPathSync()
// #when called again
// when called again
const secondResult = cliModule.getCommentCheckerPathSync()
// #then should return same cached result
// then should return same cached result
expect(secondResult).toBe(firstResult)
})
test("COMMENT_CHECKER_CLI_PATH export should not exist (removed for lazy loading)", async () => {
// #given the cli module
// given the cli module
const cliModule = await import("./cli")
// #when checking for COMMENT_CHECKER_CLI_PATH
// #then it should not exist (replaced with lazy getter)
// when checking for COMMENT_CHECKER_CLI_PATH
// then it should not exist (replaced with lazy getter)
expect("COMMENT_CHECKER_CLI_PATH" in cliModule).toBe(false)
})
})
describe("runCommentChecker", () => {
test("should use getCommentCheckerPathSync for fallback path resolution", async () => {
// #given runCommentChecker is called without explicit path
// given runCommentChecker is called without explicit path
const { runCommentChecker } = await import("./cli")
// #when called with input containing no comments
// when called with input containing no comments
const result = await runCommentChecker({
session_id: "test",
tool_name: "Write",
@@ -60,7 +60,7 @@ describe("comment-checker CLI path resolution", () => {
tool_input: { file_path: "/tmp/test.ts", content: "const x = 1" },
})
// #then should return CheckResult type (binary may or may not exist)
// then should return CheckResult type (binary may or may not exist)
expect(typeof result.hasComments).toBe("boolean")
expect(typeof result.message).toBe("string")
})
@@ -0,0 +1,102 @@
import { describe, expect, it, mock, beforeEach } from "bun:test"
// Mock dependencies before importing
const mockInjectHookMessage = mock(() => true)
mock.module("../../features/hook-message-injector", () => ({
injectHookMessage: mockInjectHookMessage,
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
mock.module("../../shared/system-directive", () => ({
createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`,
SystemDirectiveTypes: {
TODO_CONTINUATION: "TODO CONTINUATION",
RALPH_LOOP: "RALPH LOOP",
BOULDER_CONTINUATION: "BOULDER CONTINUATION",
DELEGATION_REQUIRED: "DELEGATION REQUIRED",
SINGLE_TASK_ONLY: "SINGLE TASK ONLY",
COMPACTION_CONTEXT: "COMPACTION CONTEXT",
CONTEXT_WINDOW_MONITOR: "CONTEXT WINDOW MONITOR",
PROMETHEUS_READ_ONLY: "PROMETHEUS READ-ONLY",
},
}))
import { createCompactionContextInjector } from "./index"
import type { SummarizeContext } from "./index"
describe("createCompactionContextInjector", () => {
beforeEach(() => {
mockInjectHookMessage.mockClear()
})
describe("Agent Verification State preservation", () => {
it("includes Agent Verification State section in compaction prompt", async () => {
// given
const injector = createCompactionContextInjector()
const context: SummarizeContext = {
sessionID: "test-session",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
usageRatio: 0.85,
directory: "/test/dir",
}
// when
await injector(context)
// then
expect(mockInjectHookMessage).toHaveBeenCalledTimes(1)
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
const injectedPrompt = calls[0]?.[1] ?? ""
expect(injectedPrompt).toContain("Agent Verification State")
expect(injectedPrompt).toContain("Current Agent")
expect(injectedPrompt).toContain("Verification Progress")
})
it("includes Momus-specific context for reviewer agents", async () => {
// given
const injector = createCompactionContextInjector()
const context: SummarizeContext = {
sessionID: "test-session",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
usageRatio: 0.9,
directory: "/test/dir",
}
// when
await injector(context)
// then
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
const injectedPrompt = calls[0]?.[1] ?? ""
expect(injectedPrompt).toContain("Previous Rejections")
expect(injectedPrompt).toContain("Acceptance Status")
expect(injectedPrompt).toContain("reviewer agents")
})
it("preserves file verification progress in compaction prompt", async () => {
// given
const injector = createCompactionContextInjector()
const context: SummarizeContext = {
sessionID: "test-session",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
usageRatio: 0.95,
directory: "/test/dir",
}
// when
await injector(context)
// then
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
const injectedPrompt = calls[0]?.[1] ?? ""
expect(injectedPrompt).toContain("Pending Verifications")
expect(injectedPrompt).toContain("Files already verified")
})
})
})
@@ -0,0 +1,76 @@
import { injectHookMessage } from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
export interface SummarizeContext {
sessionID: string
providerID: string
modelID: string
usageRatio: number
directory: string
}
const SUMMARIZE_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
When summarizing this session, you MUST include the following sections in your summary:
## 1. User Requests (As-Is)
- List all original user requests exactly as they were stated
- Preserve the user's exact wording and intent
## 2. Final Goal
- What the user ultimately wanted to achieve
- The end result or deliverable expected
## 3. Work Completed
- What has been done so far
- Files created/modified
- Features implemented
- Problems solved
## 4. Remaining Tasks
- What still needs to be done
- Pending items from the original request
- Follow-up tasks identified during the work
## 5. Active Working Context (For Seamless Continuation)
- **Files**: Paths of files currently being edited or frequently referenced
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
- **External References**: Documentation URLs, library APIs, or external resources being consulted
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
## 6. MUST NOT Do (Critical Constraints)
- Things that were explicitly forbidden
- Approaches that failed and should not be retried
- User's explicit restrictions or preferences
- Anti-patterns identified during the session
## 7. Agent Verification State (Critical for Reviewers)
- **Current Agent**: What agent is running (momus, oracle, etc.)
- **Verification Progress**: Files already verified/validated
- **Pending Verifications**: Files still needing verification
- **Previous Rejections**: If reviewer agent, what was rejected and why
- **Acceptance Status**: Current state of review process
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
This context is critical for maintaining continuity after compaction.
`
export function createCompactionContextInjector() {
return async (ctx: SummarizeContext): Promise<void> => {
log("[compaction-context-injector] injecting context", { sessionID: ctx.sessionID })
const success = injectHookMessage(ctx.sessionID, SUMMARIZE_CONTEXT_PROMPT, {
agent: "general",
model: { providerID: ctx.providerID, modelID: ctx.modelID },
path: { cwd: ctx.directory },
})
if (success) {
log("[compaction-context-injector] context injected", { sessionID: ctx.sessionID })
} else {
log("[compaction-context-injector] injection failed", { sessionID: ctx.sessionID })
}
}
}
+8 -8
View File
@@ -7,8 +7,8 @@ import {
describe("sisyphus-task-retry", () => {
describe("DELEGATE_TASK_ERROR_PATTERNS", () => {
// #given error patterns are defined
// #then should include all known delegate_task error types
// given error patterns are defined
// then should include all known delegate_task error types
it("should contain all known error patterns", () => {
expect(DELEGATE_TASK_ERROR_PATTERNS.length).toBeGreaterThan(5)
@@ -22,9 +22,9 @@ describe("sisyphus-task-retry", () => {
})
describe("detectDelegateTaskError", () => {
// #given tool output with run_in_background error
// #when detecting error
// #then should return matching error info
// given tool output with run_in_background error
// when detecting error
// then should return matching error info
it("should detect run_in_background missing error", () => {
const output = "[ERROR] Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation."
@@ -80,9 +80,9 @@ describe("sisyphus-task-retry", () => {
})
describe("buildRetryGuidance", () => {
// #given detected error
// #when building retry guidance
// #then should return actionable fix instructions
// given detected error
// when building retry guidance
// then should return actionable fix instructions
it("should provide fix for missing run_in_background", () => {
const errorInfo = { errorType: "missing_run_in_background", originalOutput: "" }
+1
View File
@@ -34,3 +34,4 @@ export { createDelegateTaskRetryHook } from "./delegate-task-retry";
export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker";
export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard";
export { createCompactionContextInjector, type SummarizeContext } from "./compaction-context-injector";
+71 -71
View File
@@ -35,7 +35,7 @@ describe("keyword-detector message transform", () => {
}
test("should prepend ultrawork message to text part", async () => {
// #given - a fresh ContextCollector and keyword-detector hook
// given - a fresh ContextCollector and keyword-detector hook
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session-123"
@@ -44,10 +44,10 @@ describe("keyword-detector message transform", () => {
parts: [{ type: "text", text: "ultrawork do something" }],
}
// #when - keyword detection runs
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// #then - message should be prepended to text part with separator and original text
// then - message should be prepended to text part with separator and original text
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("---")
@@ -56,7 +56,7 @@ describe("keyword-detector message transform", () => {
})
test("should prepend search message to text part", async () => {
// #given - mock getMainSessionID to return our session (isolate from global state)
// given - mock getMainSessionID to return our session (isolate from global state)
const collector = new ContextCollector()
const sessionID = "search-test-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
@@ -66,10 +66,10 @@ describe("keyword-detector message transform", () => {
parts: [{ type: "text", text: "search for the bug" }],
}
// #when - keyword detection runs
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// #then - search message should be prepended to text part
// then - search message should be prepended to text part
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("---")
@@ -78,7 +78,7 @@ describe("keyword-detector message transform", () => {
})
test("should NOT transform when no keywords detected", async () => {
// #given - no keywords in message
// given - no keywords in message
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
@@ -87,10 +87,10 @@ describe("keyword-detector message transform", () => {
parts: [{ type: "text", text: "just a normal message" }],
}
// #when - keyword detection runs
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// #then - text should remain unchanged
// then - text should remain unchanged
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("just a normal message")
@@ -128,7 +128,7 @@ describe("keyword-detector session filtering", () => {
}
test("should skip non-ultrawork keywords in non-main session (using mainSessionID check)", async () => {
// #given - main session is set, different session submits search keyword
// given - main session is set, different session submits search keyword
const mainSessionID = "main-123"
const subagentSessionID = "subagent-456"
setMainSession(mainSessionID)
@@ -139,19 +139,19 @@ describe("keyword-detector session filtering", () => {
parts: [{ type: "text", text: "search mode 찾아줘" }],
}
// #when - non-main session triggers keyword detection
// when - non-main session triggers keyword detection
await hook["chat.message"](
{ sessionID: subagentSessionID },
output
)
// #then - search keyword should be filtered out based on mainSessionID comparison
// then - search keyword should be filtered out based on mainSessionID comparison
const skipLog = logCalls.find(c => c.msg.includes("Skipping non-ultrawork keywords in non-main session"))
expect(skipLog).toBeDefined()
})
test("should allow ultrawork keywords in non-main session", async () => {
// #given - main session is set, different session submits ultrawork keyword
// given - main session is set, different session submits ultrawork keyword
const mainSessionID = "main-123"
const subagentSessionID = "subagent-456"
setMainSession(mainSessionID)
@@ -163,19 +163,19 @@ describe("keyword-detector session filtering", () => {
parts: [{ type: "text", text: "ultrawork mode" }],
}
// #when - non-main session triggers ultrawork keyword
// when - non-main session triggers ultrawork keyword
await hook["chat.message"](
{ sessionID: subagentSessionID },
output
)
// #then - ultrawork should still work (variant set to max)
// then - ultrawork should still work (variant set to max)
expect(output.message.variant).toBe("max")
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
test("should allow all keywords in main session", async () => {
// #given - main session submits search keyword
// given - main session submits search keyword
const mainSessionID = "main-123"
setMainSession(mainSessionID)
@@ -185,20 +185,20 @@ describe("keyword-detector session filtering", () => {
parts: [{ type: "text", text: "search mode 찾아줘" }],
}
// #when - main session triggers keyword detection
// when - main session triggers keyword detection
await hook["chat.message"](
{ sessionID: mainSessionID },
output
)
// #then - search keyword should be detected (output unchanged but detection happens)
// then - search keyword should be detected (output unchanged but detection happens)
// Note: search keywords don't set variant, they inject messages via context-injector
// This test verifies the detection logic runs without filtering
expect(output.message.variant).toBeUndefined() // search doesn't set variant
})
test("should allow all keywords when mainSessionID is not set", async () => {
// #given - no main session set (early startup or standalone mode)
// given - no main session set (early startup or standalone mode)
setMainSession(undefined)
const toastCalls: string[] = []
@@ -208,19 +208,19 @@ describe("keyword-detector session filtering", () => {
parts: [{ type: "text", text: "ultrawork search" }],
}
// #when - any session triggers keyword detection
// when - any session triggers keyword detection
await hook["chat.message"](
{ sessionID: "any-session" },
output
)
// #then - all keywords should work
// then - all keywords should work
expect(output.message.variant).toBe("max")
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
test("should not override existing variant", async () => {
// #given - main session set with pre-existing variant
// given - main session set with pre-existing variant
setMainSession("main-123")
const toastCalls: string[] = []
@@ -230,13 +230,13 @@ describe("keyword-detector session filtering", () => {
parts: [{ type: "text", text: "ultrawork mode" }],
}
// #when - ultrawork keyword triggers
// when - ultrawork keyword triggers
await hook["chat.message"](
{ sessionID: "main-123" },
output
)
// #then - existing variant should remain
// then - existing variant should remain
expect(output.message.variant).toBe("low")
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
@@ -273,7 +273,7 @@ describe("keyword-detector word boundary", () => {
}
test("should NOT trigger ultrawork on partial matches like 'StatefulWidget' containing 'ulw'", async () => {
// #given - text contains 'ulw' as part of another word (StatefulWidget)
// given - text contains 'ulw' as part of another word (StatefulWidget)
setMainSession(undefined)
const toastCalls: string[] = []
@@ -283,19 +283,19 @@ describe("keyword-detector word boundary", () => {
parts: [{ type: "text", text: "refactor the StatefulWidget component" }],
}
// #when - message with partial 'ulw' match is processed
// when - message with partial 'ulw' match is processed
await hook["chat.message"](
{ sessionID: "any-session" },
output
)
// #then - ultrawork should NOT be triggered
// then - ultrawork should NOT be triggered
expect(output.message.variant).toBeUndefined()
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
})
test("should trigger ultrawork on standalone 'ulw' keyword", async () => {
// #given - text contains standalone 'ulw'
// given - text contains standalone 'ulw'
setMainSession(undefined)
const toastCalls: string[] = []
@@ -305,19 +305,19 @@ describe("keyword-detector word boundary", () => {
parts: [{ type: "text", text: "ulw do this task" }],
}
// #when - message with standalone 'ulw' is processed
// when - message with standalone 'ulw' is processed
await hook["chat.message"](
{ sessionID: "any-session" },
output
)
// #then - ultrawork should be triggered
// then - ultrawork should be triggered
expect(output.message.variant).toBe("max")
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
test("should NOT trigger ultrawork on file references containing 'ulw' substring", async () => {
// #given - file reference contains 'ulw' as substring
// given - file reference contains 'ulw' as substring
setMainSession(undefined)
const toastCalls: string[] = []
@@ -327,13 +327,13 @@ describe("keyword-detector word boundary", () => {
parts: [{ type: "text", text: "@StatefulWidget.tsx please review this file" }],
}
// #when - message referencing file with 'ulw' substring is processed
// when - message referencing file with 'ulw' substring is processed
await hook["chat.message"](
{ sessionID: "any-session" },
output
)
// #then - ultrawork should NOT be triggered
// then - ultrawork should NOT be triggered
expect(output.message.variant).toBeUndefined()
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
})
@@ -367,7 +367,7 @@ describe("keyword-detector system-reminder filtering", () => {
}
test("should NOT trigger search mode from keywords inside <system-reminder> tags", async () => {
// #given - message contains search keywords only inside system-reminder tags
// given - message contains search keywords only inside system-reminder tags
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
@@ -382,10 +382,10 @@ Please locate and scan the directory.
}],
}
// #when - keyword detection runs on system-reminder content
// when - keyword detection runs on system-reminder content
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger search mode (text should remain unchanged)
// then - should NOT trigger search mode (text should remain unchanged)
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[search-mode]")
@@ -393,7 +393,7 @@ Please locate and scan the directory.
})
test("should NOT trigger analyze mode from keywords inside <system-reminder> tags", async () => {
// #given - message contains analyze keywords only inside system-reminder tags
// given - message contains analyze keywords only inside system-reminder tags
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
@@ -408,10 +408,10 @@ Research the implementation details.
}],
}
// #when - keyword detection runs on system-reminder content
// when - keyword detection runs on system-reminder content
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger analyze mode
// then - should NOT trigger analyze mode
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[analyze-mode]")
@@ -419,7 +419,7 @@ Research the implementation details.
})
test("should detect keywords in user text even when system-reminder is present", async () => {
// #given - message contains both system-reminder and user search keyword
// given - message contains both system-reminder and user search keyword
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
@@ -435,10 +435,10 @@ Please search for the bug in the code.`
}],
}
// #when - keyword detection runs on mixed content
// when - keyword detection runs on mixed content
await hook["chat.message"]({ sessionID }, output)
// #then - should trigger search mode from user text only
// then - should trigger search mode from user text only
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[search-mode]")
@@ -446,7 +446,7 @@ Please search for the bug in the code.`
})
test("should handle multiple system-reminder tags in message", async () => {
// #given - message contains multiple system-reminder blocks with keywords
// given - message contains multiple system-reminder blocks with keywords
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
@@ -466,10 +466,10 @@ Second reminder with investigate and examine keywords.
}],
}
// #when - keyword detection runs on message with multiple system-reminders
// when - keyword detection runs on message with multiple system-reminders
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger any mode (only user text exists, no keywords)
// then - should NOT trigger any mode (only user text exists, no keywords)
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[search-mode]")
@@ -477,7 +477,7 @@ Second reminder with investigate and examine keywords.
})
test("should handle case-insensitive system-reminder tags", async () => {
// #given - message contains system-reminder with different casing
// given - message contains system-reminder with different casing
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
@@ -491,17 +491,17 @@ System will search and find files.
}],
}
// #when - keyword detection runs on uppercase system-reminder
// when - keyword detection runs on uppercase system-reminder
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger search mode
// then - should NOT trigger search mode
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[search-mode]")
})
test("should handle multiline system-reminder content with search keywords", async () => {
// #given - system-reminder with multiline content containing various search keywords
// given - system-reminder with multiline content containing various search keywords
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "test-session"
@@ -520,10 +520,10 @@ Please explore the codebase and discover patterns.
}],
}
// #when - keyword detection runs on multiline system-reminder
// when - keyword detection runs on multiline system-reminder
await hook["chat.message"]({ sessionID }, output)
// #then - should NOT trigger search mode
// then - should NOT trigger search mode
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[search-mode]")
@@ -558,7 +558,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
}
test("should skip ultrawork injection when agent is prometheus", async () => {
// #given - collector and prometheus agent
// given - collector and prometheus agent
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "prometheus-session"
@@ -567,10 +567,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
parts: [{ type: "text", text: "ultrawork plan this feature" }],
}
// #when - ultrawork keyword detected with prometheus agent
// when - ultrawork keyword detected with prometheus agent
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// #then - ultrawork should be skipped for planner agents, text unchanged
// then - ultrawork should be skipped for planner agents, text unchanged
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("ultrawork plan this feature")
@@ -579,7 +579,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
})
test("should skip ultrawork injection when agent name contains 'planner'", async () => {
// #given - collector and agent with 'planner' in name
// given - collector and agent with 'planner' in name
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "planner-session"
@@ -588,10 +588,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
parts: [{ type: "text", text: "ulw create a work plan" }],
}
// #when - ultrawork keyword detected with planner agent
// when - ultrawork keyword detected with planner agent
await hook["chat.message"]({ sessionID, agent: "Prometheus (Planner)" }, output)
// #then - ultrawork should be skipped, text unchanged
// then - ultrawork should be skipped, text unchanged
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("ulw create a work plan")
@@ -599,7 +599,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
})
test("should use normal ultrawork message when agent is Sisyphus", async () => {
// #given - collector and Sisyphus agent
// given - collector and Sisyphus agent
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "sisyphus-session"
@@ -608,10 +608,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
parts: [{ type: "text", text: "ultrawork implement this feature" }],
}
// #when - ultrawork keyword detected with Sisyphus agent
// when - ultrawork keyword detected with Sisyphus agent
await hook["chat.message"]({ sessionID, agent: "sisyphus" }, output)
// #then - should use normal ultrawork message with agent utilization instructions
// then - should use normal ultrawork message with agent utilization instructions
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
@@ -621,7 +621,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
})
test("should use normal ultrawork message when agent is undefined", async () => {
// #given - collector with no agent specified
// given - collector with no agent specified
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "no-agent-session"
@@ -630,10 +630,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
parts: [{ type: "text", text: "ultrawork do something" }],
}
// #when - ultrawork keyword detected without agent
// when - ultrawork keyword detected without agent
await hook["chat.message"]({ sessionID }, output)
// #then - should use normal ultrawork message (default behavior)
// then - should use normal ultrawork message (default behavior)
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
@@ -643,7 +643,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
})
test("should skip ultrawork for prometheus but inject for sisyphus", async () => {
// #given - two sessions, one with prometheus, one with sisyphus
// given - two sessions, one with prometheus, one with sisyphus
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
@@ -663,7 +663,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
}
await hook["chat.message"]({ sessionID: sisyphusSessionID, agent: "sisyphus" }, sisyphusOutput)
// #then - prometheus should have no injection, sisyphus should have normal ultrawork
// then - prometheus should have no injection, sisyphus should have normal ultrawork
const prometheusTextPart = prometheusOutput.parts.find(p => p.type === "text")
expect(prometheusTextPart!.text).toBe("ultrawork plan")
@@ -674,7 +674,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
})
test("should use session state agent over stale input.agent (bug fix)", async () => {
// #given - same session, agent switched from prometheus to sisyphus in session state
// given - same session, agent switched from prometheus to sisyphus in session state
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "same-session-agent-switch"
@@ -687,10 +687,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
parts: [{ type: "text", text: "ultrawork implement this" }],
}
// #when - hook receives stale input.agent="prometheus" but session state says "Sisyphus"
// when - hook receives stale input.agent="prometheus" but session state says "Sisyphus"
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// #then - should use Sisyphus from session state, NOT prometheus from stale input
// then - should use Sisyphus from session state, NOT prometheus from stale input
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
@@ -703,7 +703,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
})
test("should fall back to input.agent when session state is empty and skip ultrawork for prometheus", async () => {
// #given - no session state, only input.agent available
// given - no session state, only input.agent available
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const sessionID = "no-session-state"
@@ -716,10 +716,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
parts: [{ type: "text", text: "ultrawork plan this" }],
}
// #when - hook receives input.agent="prometheus" with no session state
// when - hook receives input.agent="prometheus" with no session state
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// #then - prometheus fallback from input.agent, ultrawork skipped
// then - prometheus fallback from input.agent, ultrawork skipped
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("ultrawork plan this")
+1 -1
View File
@@ -15,7 +15,7 @@ describe("non-interactive-env hook", () => {
CI: process.env.CI,
OPENCODE_NON_INTERACTIVE: process.env.OPENCODE_NON_INTERACTIVE,
}
// #given clean Unix-like environment for all tests
// given clean Unix-like environment for all tests
// This prevents CI environments (which may have PSModulePath set) from
// triggering PowerShell detection in tests that expect Unix behavior
delete process.env.PSModulePath
+57 -57
View File
@@ -47,7 +47,7 @@ describe("prometheus-md-only", () => {
})
test("should block Prometheus from writing non-.md files", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
@@ -58,14 +58,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "/path/to/file.ts" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
test("should allow Prometheus to write .md files inside .sisyphus/", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
@@ -76,14 +76,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "/tmp/test/.sisyphus/plans/work-plan.md" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should inject workflow reminder when Prometheus writes to .sisyphus/plans/", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
@@ -94,10 +94,10 @@ describe("prometheus-md-only", () => {
args: { filePath: "/tmp/test/.sisyphus/plans/work-plan.md" },
}
// #when
// when
await hook["tool.execute.before"](input, output)
// #then
// then
expect(output.message).toContain("PROMETHEUS MANDATORY WORKFLOW REMINDER")
expect(output.message).toContain("INTERVIEW")
expect(output.message).toContain("METIS CONSULTATION")
@@ -105,7 +105,7 @@ describe("prometheus-md-only", () => {
})
test("should NOT inject workflow reminder for .sisyphus/drafts/", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
@@ -116,15 +116,15 @@ describe("prometheus-md-only", () => {
args: { filePath: "/tmp/test/.sisyphus/drafts/notes.md" },
}
// #when
// when
await hook["tool.execute.before"](input, output)
// #then
// then
expect(output.message).toBeUndefined()
})
test("should block Prometheus from writing .md files outside .sisyphus/", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
@@ -135,14 +135,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "/path/to/README.md" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
})
test("should block Edit tool for non-.md files", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Edit",
@@ -153,14 +153,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "/path/to/code.py" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
test("should not affect non-Write/Edit tools", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Read",
@@ -171,14 +171,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "/path/to/file.ts" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should handle missing filePath gracefully", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
@@ -189,14 +189,14 @@ describe("prometheus-md-only", () => {
args: {},
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should inject read-only warning when Prometheus calls delegate_task", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "delegate_task",
@@ -207,16 +207,16 @@ describe("prometheus-md-only", () => {
args: { prompt: "Analyze this codebase" },
}
// #when
// when
await hook["tool.execute.before"](input, output)
// #then
// then
expect(output.args.prompt).toContain(SYSTEM_DIRECTIVE_PREFIX)
expect(output.args.prompt).toContain("DO NOT modify any files")
})
test("should inject read-only warning when Prometheus calls task", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "task",
@@ -227,15 +227,15 @@ describe("prometheus-md-only", () => {
args: { prompt: "Research this library" },
}
// #when
// when
await hook["tool.execute.before"](input, output)
// #then
// then
expect(output.args.prompt).toContain(SYSTEM_DIRECTIVE_PREFIX)
})
test("should inject read-only warning when Prometheus calls call_omo_agent", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "call_omo_agent",
@@ -246,15 +246,15 @@ describe("prometheus-md-only", () => {
args: { prompt: "Find implementation examples" },
}
// #when
// when
await hook["tool.execute.before"](input, output)
// #then
// then
expect(output.args.prompt).toContain(SYSTEM_DIRECTIVE_PREFIX)
})
test("should not double-inject warning if already present", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "delegate_task",
@@ -266,10 +266,10 @@ describe("prometheus-md-only", () => {
args: { prompt: promptWithWarning },
}
// #when
// when
await hook["tool.execute.before"](input, output)
// #then
// then
const occurrences = (output.args.prompt as string).split(SYSTEM_DIRECTIVE_PREFIX).length - 1
expect(occurrences).toBe(1)
})
@@ -281,7 +281,7 @@ describe("prometheus-md-only", () => {
})
test("should not affect non-Prometheus agents", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
@@ -292,14 +292,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "/path/to/file.ts" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should not inject warning for non-Prometheus agents calling delegate_task", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "delegate_task",
@@ -311,10 +311,10 @@ describe("prometheus-md-only", () => {
args: { prompt: originalPrompt },
}
// #when
// when
await hook["tool.execute.before"](input, output)
// #then
// then
expect(output.args.prompt).toBe(originalPrompt)
expect(output.args.prompt).not.toContain(SYSTEM_DIRECTIVE_PREFIX)
})
@@ -322,7 +322,7 @@ describe("prometheus-md-only", () => {
describe("without message storage", () => {
test("should handle missing session gracefully (no agent found)", async () => {
// #given
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
@@ -333,7 +333,7 @@ describe("prometheus-md-only", () => {
args: { filePath: "/path/to/file.ts" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
@@ -346,7 +346,7 @@ describe("prometheus-md-only", () => {
})
test("should allow Windows-style backslash paths under .sisyphus/", async () => {
// #given
// given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
@@ -358,14 +358,14 @@ describe("prometheus-md-only", () => {
args: { filePath: ".sisyphus\\plans\\work-plan.md" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should allow mixed separator paths under .sisyphus/", async () => {
// #given
// given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
@@ -377,14 +377,14 @@ describe("prometheus-md-only", () => {
args: { filePath: ".sisyphus\\plans/work-plan.MD" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should allow uppercase .MD extension", async () => {
// #given
// given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
@@ -396,14 +396,14 @@ describe("prometheus-md-only", () => {
args: { filePath: ".sisyphus/plans/work-plan.MD" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should block paths outside workspace root even if containing .sisyphus", async () => {
// #given
// given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
@@ -415,14 +415,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "/other/project/.sisyphus/plans/x.md" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
})
test("should allow nested .sisyphus directories (ctx.directory may be parent)", async () => {
// #given - when ctx.directory is parent of actual project, path includes project name
// given - when ctx.directory is parent of actual project, path includes project name
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
@@ -434,14 +434,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "src/.sisyphus/plans/x.md" },
}
// #when / #then - should allow because .sisyphus is in path
// when / #then - should allow because .sisyphus is in path
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should block path traversal attempts", async () => {
// #given
// given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
@@ -453,14 +453,14 @@ describe("prometheus-md-only", () => {
args: { filePath: ".sisyphus/../secrets.md" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
})
test("should allow case-insensitive .SISYPHUS directory", async () => {
// #given
// given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
@@ -472,14 +472,14 @@ describe("prometheus-md-only", () => {
args: { filePath: ".SISYPHUS/plans/work-plan.md" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should allow nested project path with .sisyphus (Windows real-world case)", async () => {
// #given - simulates when ctx.directory is parent of actual project
// given - simulates when ctx.directory is parent of actual project
// User reported: xauusd-dxy-plan\.sisyphus\drafts\supabase-email-templates.md
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
@@ -492,14 +492,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "xauusd-dxy-plan\\.sisyphus\\drafts\\supabase-email-templates.md" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should allow nested project path with mixed separators", async () => {
// #given
// given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
@@ -511,14 +511,14 @@ describe("prometheus-md-only", () => {
args: { filePath: "my-project/.sisyphus\\plans/task.md" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should block nested project path without .sisyphus", async () => {
// #given
// given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
@@ -530,7 +530,7 @@ describe("prometheus-md-only", () => {
args: { filePath: "my-project\\src\\code.ts" },
}
// #when / #then
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
@@ -6,7 +6,7 @@ describe("createQuestionLabelTruncatorHook", () => {
describe("tool.execute.before", () => {
it("truncates labels exceeding 30 characters with ellipsis", async () => {
// #given
// given
const longLabel = "This is a very long label that exceeds thirty characters";
const input = { tool: "AskUserQuestion" };
const output = {
@@ -22,10 +22,10 @@ describe("createQuestionLabelTruncatorHook", () => {
},
};
// #when
// when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
// then
const truncatedLabel = (output.args as any).questions[0].options[0].label;
expect(truncatedLabel.length).toBeLessThanOrEqual(30);
expect(truncatedLabel).toBe("This is a very long label t...");
@@ -33,7 +33,7 @@ describe("createQuestionLabelTruncatorHook", () => {
});
it("preserves labels within 30 characters", async () => {
// #given
// given
const shortLabel = "Short label";
const input = { tool: "AskUserQuestion" };
const output = {
@@ -49,16 +49,16 @@ describe("createQuestionLabelTruncatorHook", () => {
},
};
// #when
// when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
// then
const resultLabel = (output.args as any).questions[0].options[0].label;
expect(resultLabel).toBe(shortLabel);
});
it("handles exactly 30 character labels without truncation", async () => {
// #given
// given
const exactLabel = "Exactly thirty chars here!!!!!"; // 30 chars
expect(exactLabel.length).toBe(30);
const input = { tool: "ask_user_question" };
@@ -73,31 +73,31 @@ describe("createQuestionLabelTruncatorHook", () => {
},
};
// #when
// when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
// then
const resultLabel = (output.args as any).questions[0].options[0].label;
expect(resultLabel).toBe(exactLabel);
});
it("ignores non-AskUserQuestion tools", async () => {
// #given
// given
const input = { tool: "Bash" };
const output = {
args: { command: "echo hello" },
};
const originalArgs = { ...output.args };
// #when
// when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
// then
expect(output.args).toEqual(originalArgs);
});
it("handles multiple questions with multiple options", async () => {
// #given
// given
const input = { tool: "AskUserQuestion" };
const output = {
args: {
@@ -119,10 +119,10 @@ describe("createQuestionLabelTruncatorHook", () => {
},
};
// #when
// when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
// then
const q1opts = (output.args as any).questions[0].options;
const q2opts = (output.args as any).questions[1].options;
+127 -127
View File
@@ -66,7 +66,7 @@ describe("ralph-loop", () => {
describe("storage", () => {
test("should write and read state correctly", () => {
// #given - a state object
// given - a state object
const state: RalphLoopState = {
active: true,
iteration: 1,
@@ -77,11 +77,11 @@ describe("ralph-loop", () => {
session_id: "test-session-123",
}
// #when - write and read state
// when - write and read state
const writeSuccess = writeState(TEST_DIR, state)
const readResult = readState(TEST_DIR)
// #then - state should match
// then - state should match
expect(writeSuccess).toBe(true)
expect(readResult).not.toBeNull()
expect(readResult?.active).toBe(true)
@@ -93,7 +93,7 @@ describe("ralph-loop", () => {
})
test("should handle ultrawork field", () => {
// #given - a state object with ultrawork enabled
// given - a state object with ultrawork enabled
const state: RalphLoopState = {
active: true,
iteration: 1,
@@ -105,25 +105,25 @@ describe("ralph-loop", () => {
ultrawork: true,
}
// #when - write and read state
// when - write and read state
writeState(TEST_DIR, state)
const readResult = readState(TEST_DIR)
// #then - ultrawork field should be preserved
// then - ultrawork field should be preserved
expect(readResult?.ultrawork).toBe(true)
})
test("should return null for non-existent state", () => {
// #given - no state file exists
// #when - read state
// given - no state file exists
// when - read state
const result = readState(TEST_DIR)
// #then - should return null
// then - should return null
expect(result).toBeNull()
})
test("should clear state correctly", () => {
// #given - existing state
// given - existing state
const state: RalphLoopState = {
active: true,
iteration: 1,
@@ -134,17 +134,17 @@ describe("ralph-loop", () => {
}
writeState(TEST_DIR, state)
// #when - clear state
// when - clear state
const clearSuccess = clearState(TEST_DIR)
const readResult = readState(TEST_DIR)
// #then - state should be cleared
// then - state should be cleared
expect(clearSuccess).toBe(true)
expect(readResult).toBeNull()
})
test("should handle multiline prompts", () => {
// #given - state with multiline prompt
// given - state with multiline prompt
const state: RalphLoopState = {
active: true,
iteration: 1,
@@ -154,27 +154,27 @@ describe("ralph-loop", () => {
prompt: "Build a feature\nwith multiple lines\nand requirements",
}
// #when - write and read
// when - write and read
writeState(TEST_DIR, state)
const readResult = readState(TEST_DIR)
// #then - multiline prompt preserved
// then - multiline prompt preserved
expect(readResult?.prompt).toBe("Build a feature\nwith multiple lines\nand requirements")
})
})
describe("hook", () => {
test("should start loop and write state", () => {
// #given - hook instance
// given - hook instance
const hook = createRalphLoopHook(createMockPluginInput())
// #when - start loop
// when - start loop
const success = hook.startLoop("session-123", "Build something", {
maxIterations: 25,
completionPromise: "FINISHED",
})
// #then - state should be written
// then - state should be written
expect(success).toBe(true)
const state = hook.getState()
expect(state?.active).toBe(true)
@@ -186,35 +186,35 @@ describe("ralph-loop", () => {
})
test("should accept ultrawork option in startLoop", () => {
// #given - hook instance
// given - hook instance
const hook = createRalphLoopHook(createMockPluginInput())
// #when - start loop with ultrawork
// when - start loop with ultrawork
hook.startLoop("session-123", "Build something", { ultrawork: true })
// #then - state should have ultrawork=true
// then - state should have ultrawork=true
const state = hook.getState()
expect(state?.ultrawork).toBe(true)
})
test("should handle missing ultrawork option in startLoop", () => {
// #given - hook instance
// given - hook instance
const hook = createRalphLoopHook(createMockPluginInput())
// #when - start loop without ultrawork
// when - start loop without ultrawork
hook.startLoop("session-123", "Build something")
// #then - state should have ultrawork=undefined
// then - state should have ultrawork=undefined
const state = hook.getState()
expect(state?.ultrawork).toBeUndefined()
})
test("should inject continuation when loop active and no completion detected", async () => {
// #given - active loop state
// given - active loop state
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
// #when - session goes idle
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -222,20 +222,20 @@ describe("ralph-loop", () => {
},
})
// #then - continuation should be injected
// then - continuation should be injected
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("session-123")
expect(promptCalls[0].text).toContain("RALPH LOOP")
expect(promptCalls[0].text).toContain("Build a feature")
expect(promptCalls[0].text).toContain("2/10")
// #then - iteration should be incremented
// then - iteration should be incremented
const state = hook.getState()
expect(state?.iteration).toBe(2)
})
test("should stop loop when max iterations reached", async () => {
// #given - loop at max iteration
// given - loop at max iteration
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something", { maxIterations: 2 })
@@ -243,7 +243,7 @@ describe("ralph-loop", () => {
state.iteration = 2
writeState(TEST_DIR, state)
// #when - session goes idle
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -251,46 +251,46 @@ describe("ralph-loop", () => {
},
})
// #then - no continuation injected
// then - no continuation injected
expect(promptCalls.length).toBe(0)
// #then - warning toast shown
// then - warning toast shown
expect(toastCalls.length).toBe(1)
expect(toastCalls[0].title).toBe("Ralph Loop Stopped")
expect(toastCalls[0].variant).toBe("warning")
// #then - state should be cleared
// then - state should be cleared
expect(hook.getState()).toBeNull()
})
test("should cancel loop via cancelLoop", () => {
// #given - active loop
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// #when - cancel loop
// when - cancel loop
const success = hook.cancelLoop("session-123")
// #then - loop cancelled
// then - loop cancelled
expect(success).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should not cancel loop for different session", () => {
// #given - active loop for session-123
// given - active loop for session-123
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// #when - try to cancel for different session
// when - try to cancel for different session
const success = hook.cancelLoop("session-456")
// #then - cancel should fail
// then - cancel should fail
expect(success).toBe(false)
expect(hook.getState()).not.toBeNull()
})
test("should skip injection during recovery", async () => {
// #given - active loop and session in recovery
// given - active loop and session in recovery
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
@@ -301,7 +301,7 @@ describe("ralph-loop", () => {
},
})
// #when - session goes idle immediately
// when - session goes idle immediately
await hook.event({
event: {
type: "session.idle",
@@ -309,16 +309,16 @@ describe("ralph-loop", () => {
},
})
// #then - no continuation injected
// then - no continuation injected
expect(promptCalls.length).toBe(0)
})
test("should clear state on session deletion", async () => {
// #given - active loop
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// #when - session deleted
// when - session deleted
await hook.event({
event: {
type: "session.deleted",
@@ -326,16 +326,16 @@ describe("ralph-loop", () => {
},
})
// #then - state should be cleared
// then - state should be cleared
expect(hook.getState()).toBeNull()
})
test("should not inject for different session than loop owner", async () => {
// #given - loop owned by session-123
// given - loop owned by session-123
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// #when - different session goes idle
// when - different session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -343,12 +343,12 @@ describe("ralph-loop", () => {
},
})
// #then - no continuation injected
// then - no continuation injected
expect(promptCalls.length).toBe(0)
})
test("should clear orphaned state when original session no longer exists", async () => {
// #given - state file exists from a previous session that no longer exists
// given - state file exists from a previous session that no longer exists
const state: RalphLoopState = {
active: true,
iteration: 3,
@@ -368,7 +368,7 @@ describe("ralph-loop", () => {
},
})
// #when - a new session goes idle (different from the orphaned session in state)
// when - a new session goes idle (different from the orphaned session in state)
await hook.event({
event: {
type: "session.idle",
@@ -376,14 +376,14 @@ describe("ralph-loop", () => {
},
})
// #then - orphaned state should be cleared
// then - orphaned state should be cleared
expect(hook.getState()).toBeNull()
// #then - no continuation injected (state was cleared, not resumed)
// then - no continuation injected (state was cleared, not resumed)
expect(promptCalls.length).toBe(0)
})
test("should NOT clear state when original session still exists (different active session)", async () => {
// #given - state file exists from a session that still exists
// given - state file exists from a session that still exists
const state: RalphLoopState = {
active: true,
iteration: 2,
@@ -403,7 +403,7 @@ describe("ralph-loop", () => {
},
})
// #when - a different session goes idle
// when - a different session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -411,15 +411,15 @@ describe("ralph-loop", () => {
},
})
// #then - state should NOT be cleared (original session still active)
// then - state should NOT be cleared (original session still active)
expect(hook.getState()).not.toBeNull()
expect(hook.getState()?.session_id).toBe("active-session-123")
// #then - no continuation injected (it's a different session's loop)
// then - no continuation injected (it's a different session's loop)
expect(promptCalls.length).toBe(0)
})
test("should use default config values", () => {
// #given - hook with config
// given - hook with config
const hook = createRalphLoopHook(createMockPluginInput(), {
config: {
enabled: true,
@@ -427,19 +427,19 @@ describe("ralph-loop", () => {
},
})
// #when - start loop without options
// when - start loop without options
hook.startLoop("session-123", "Test task")
// #then - should use config defaults
// then - should use config defaults
const state = hook.getState()
expect(state?.max_iterations).toBe(200)
})
test("should not inject when no loop is active", async () => {
// #given - no active loop
// given - no active loop
const hook = createRalphLoopHook(createMockPluginInput())
// #when - session goes idle
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -447,12 +447,12 @@ describe("ralph-loop", () => {
},
})
// #then - no continuation injected
// then - no continuation injected
expect(promptCalls.length).toBe(0)
})
test("should detect completion promise and stop loop", async () => {
// #given - active loop with transcript containing completion
// given - active loop with transcript containing completion
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
@@ -461,7 +461,7 @@ describe("ralph-loop", () => {
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "Task done <promise>COMPLETE</promise>" } }) + "\n")
// #when - session goes idle (transcriptPath now derived from sessionID via getTranscriptPath)
// when - session goes idle (transcriptPath now derived from sessionID via getTranscriptPath)
await hook.event({
event: {
type: "session.idle",
@@ -469,14 +469,14 @@ describe("ralph-loop", () => {
},
})
// #then - loop completed, no continuation
// then - loop completed, no continuation
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should detect completion promise via session messages API", async () => {
// #given - active loop with assistant message containing completion promise
// given - active loop with assistant message containing completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I have completed the task. <promise>API_DONE</promise>" }] },
@@ -486,7 +486,7 @@ describe("ralph-loop", () => {
})
hook.startLoop("session-123", "Build something", { completionPromise: "API_DONE" })
// #when - session goes idle
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -494,22 +494,22 @@ describe("ralph-loop", () => {
},
})
// #then - loop completed via API detection, no continuation
// then - loop completed via API detection, no continuation
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
// #then - messages API was called with correct session ID
// then - messages API was called with correct session ID
expect(messagesCalls.length).toBe(1)
expect(messagesCalls[0].sessionID).toBe("session-123")
})
test("should handle multiple iterations correctly", async () => {
// #given - active loop
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build feature", { maxIterations: 5 })
// #when - multiple idle events
// when - multiple idle events
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
@@ -517,36 +517,36 @@ describe("ralph-loop", () => {
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - iteration incremented correctly
// then - iteration incremented correctly
expect(hook.getState()?.iteration).toBe(3)
expect(promptCalls.length).toBe(2)
})
test("should include prompt and promise in continuation message", async () => {
// #given - loop with specific prompt and promise
// given - loop with specific prompt and promise
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Create a calculator app", {
completionPromise: "CALCULATOR_DONE",
maxIterations: 10,
})
// #when - session goes idle
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - continuation includes original task and promise
// then - continuation includes original task and promise
expect(promptCalls[0].text).toContain("Create a calculator app")
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
})
test("should clear loop state on user abort (MessageAbortedError)", async () => {
// #given - active loop
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
expect(hook.getState()).not.toBeNull()
// #when - user aborts (Ctrl+C)
// when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
@@ -557,16 +557,16 @@ describe("ralph-loop", () => {
},
})
// #then - loop state should be cleared immediately
// then - loop state should be cleared immediately
expect(hook.getState()).toBeNull()
})
test("should NOT set recovery mode on user abort", async () => {
// #given - active loop
// given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
// #when - user aborts (Ctrl+C)
// when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
@@ -580,17 +580,17 @@ describe("ralph-loop", () => {
// Start a new loop
hook.startLoop("session-123", "New task")
// #when - session goes idle immediately (should work, no recovery mode)
// when - session goes idle immediately (should work, no recovery mode)
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - continuation should be injected (not blocked by recovery)
// then - continuation should be injected (not blocked by recovery)
expect(promptCalls.length).toBe(1)
})
test("should only check LAST assistant message for completion", async () => {
// #given - multiple assistant messages, only first has completion promise
// given - multiple assistant messages, only first has completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. <promise>DONE</promise>" }] },
@@ -602,18 +602,18 @@ describe("ralph-loop", () => {
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - loop should continue (last message has no completion promise)
// then - loop should continue (last message has no completion promise)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should detect completion only in LAST assistant message", async () => {
// #given - last assistant message has completion promise
// given - last assistant message has completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] },
@@ -625,50 +625,50 @@ describe("ralph-loop", () => {
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
// when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - loop should complete (last message has completion promise)
// then - loop should complete (last message has completion promise)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should allow starting new loop while previous loop is active (different session)", async () => {
// #given - active loop in session A
// given - active loop in session A
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-A", "First task", { maxIterations: 10 })
expect(hook.getState()?.session_id).toBe("session-A")
expect(hook.getState()?.prompt).toBe("First task")
// #when - start new loop in session B (without completing A)
// when - start new loop in session B (without completing A)
hook.startLoop("session-B", "Second task", { maxIterations: 20 })
// #then - state should be overwritten with session B's loop
// then - state should be overwritten with session B's loop
expect(hook.getState()?.session_id).toBe("session-B")
expect(hook.getState()?.prompt).toBe("Second task")
expect(hook.getState()?.max_iterations).toBe(20)
expect(hook.getState()?.iteration).toBe(1)
// #when - session B goes idle
// when - session B goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-B" } },
})
// #then - continuation should be injected for session B
// then - continuation should be injected for session B
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("session-B")
expect(promptCalls[0].text).toContain("Second task")
expect(promptCalls[0].text).toContain("2/20")
// #then - iteration incremented
// then - iteration incremented
expect(hook.getState()?.iteration).toBe(2)
})
test("should allow starting new loop in same session (restart)", async () => {
// #given - active loop in session A at iteration 5
// given - active loop in session A at iteration 5
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-A", "First task", { maxIterations: 10 })
@@ -682,29 +682,29 @@ describe("ralph-loop", () => {
expect(hook.getState()?.iteration).toBe(3)
expect(promptCalls.length).toBe(2)
// #when - start NEW loop in same session (restart)
// when - start NEW loop in same session (restart)
hook.startLoop("session-A", "Restarted task", { maxIterations: 50 })
// #then - state should be reset to iteration 1 with new prompt
// then - state should be reset to iteration 1 with new prompt
expect(hook.getState()?.session_id).toBe("session-A")
expect(hook.getState()?.prompt).toBe("Restarted task")
expect(hook.getState()?.max_iterations).toBe(50)
expect(hook.getState()?.iteration).toBe(1)
// #when - session goes idle
// when - session goes idle
promptCalls = [] // Reset to check new continuation
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
// #then - continuation should use new task
// then - continuation should use new task
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("Restarted task")
expect(promptCalls[0].text).toContain("2/50")
})
test("should NOT detect completion from user message in transcript (issue #622)", async () => {
// #given - transcript contains user message with template text that includes completion promise
// given - transcript contains user message with template text that includes completion promise
// This reproduces the bug where the RALPH_LOOP_TEMPLATE instructional text
// containing `<promise>DONE</promise>` is recorded as a user message and
// falsely triggers completion detection
@@ -723,7 +723,7 @@ Output <promise>DONE</promise> when fully complete`
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -731,13 +731,13 @@ Output <promise>DONE</promise> when fully complete`
},
})
// #then - loop should CONTINUE (user message completion promise is instructional, not actual)
// then - loop should CONTINUE (user message completion promise is instructional, not actual)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should NOT detect completion from continuation prompt in transcript (issue #622)", async () => {
// #given - transcript contains continuation prompt (also a user message) with completion promise
// given - transcript contains continuation prompt (also a user message) with completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const continuationText = `RALPH LOOP 2/100
When FULLY complete, output: <promise>DONE</promise>
@@ -754,7 +754,7 @@ Original task: Build something`
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -762,13 +762,13 @@ Original task: Build something`
},
})
// #then - loop should CONTINUE (continuation prompt text is not actual completion)
// then - loop should CONTINUE (continuation prompt text is not actual completion)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should detect completion from tool_result entry in transcript", async () => {
// #given - transcript contains a tool_result with completion promise
// given - transcript contains a tool_result with completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const toolResultEntry = JSON.stringify({
type: "tool_result",
@@ -784,7 +784,7 @@ Original task: Build something`
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -792,14 +792,14 @@ Original task: Build something`
},
})
// #then - loop should complete (tool_result contains actual completion output)
// then - loop should complete (tool_result contains actual completion output)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should check transcript BEFORE API to optimize performance", async () => {
// #given - transcript has completion promise
// given - transcript has completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "<promise>DONE</promise>" } }) + "\n")
mockSessionMessages = [
@@ -810,7 +810,7 @@ Original task: Build something`
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
// when - session goes idle
await hook.event({
event: {
type: "session.idle",
@@ -818,7 +818,7 @@ Original task: Build something`
},
})
// #then - should complete via transcript (API not called when transcript succeeds)
// then - should complete via transcript (API not called when transcript succeeds)
expect(promptCalls.length).toBe(0)
expect(hook.getState()).toBeNull()
// API should NOT be called since transcript found completion
@@ -826,7 +826,7 @@ Original task: Build something`
})
test("should show ultrawork completion toast", async () => {
// #given - hook with ultrawork mode and completion in transcript
// given - hook with ultrawork mode and completion in transcript
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
@@ -834,17 +834,17 @@ Original task: Build something`
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "<promise>DONE</promise>" } }) + "\n")
hook.startLoop("test-id", "Build API", { ultrawork: true })
// #when - idle event triggered
// when - idle event triggered
await hook.event({ event: { type: "session.idle", properties: { sessionID: "test-id" } } })
// #then - ultrawork toast shown
// then - ultrawork toast shown
const completionToast = toastCalls.find(t => t.title === "ULTRAWORK LOOP COMPLETE!")
expect(completionToast).toBeDefined()
expect(completionToast!.message).toMatch(/JUST ULW ULW!/)
})
test("should show regular completion toast when ultrawork disabled", async () => {
// #given - hook without ultrawork
// given - hook without ultrawork
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
@@ -852,39 +852,39 @@ Original task: Build something`
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "<promise>DONE</promise>" } }) + "\n")
hook.startLoop("test-id", "Build API")
// #when - idle event triggered
// when - idle event triggered
await hook.event({ event: { type: "session.idle", properties: { sessionID: "test-id" } } })
// #then - regular toast shown
// then - regular toast shown
expect(toastCalls.some(t => t.title === "Ralph Loop Complete!")).toBe(true)
})
test("should prepend ultrawork to continuation prompt when ultrawork=true", async () => {
// #given - hook with ultrawork mode enabled
// given - hook with ultrawork mode enabled
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build API", { ultrawork: true })
// #when - session goes idle (continuation triggered)
// when - session goes idle (continuation triggered)
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - prompt should start with "ultrawork "
// then - prompt should start with "ultrawork "
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toMatch(/^ultrawork /)
})
test("should NOT prepend ultrawork to continuation prompt when ultrawork=false", async () => {
// #given - hook without ultrawork mode
// given - hook without ultrawork mode
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build API")
// #when - session goes idle (continuation triggered)
// when - session goes idle (continuation triggered)
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - prompt should NOT start with "ultrawork "
// then - prompt should NOT start with "ultrawork "
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).not.toMatch(/^ultrawork /)
})
@@ -892,7 +892,7 @@ Original task: Build something`
describe("API timeout protection", () => {
test("should not hang when session.messages() throws", async () => {
// #given - API that throws (simulates timeout error)
// given - API that throws (simulates timeout error)
let apiCallCount = 0
const errorMock = {
...createMockPluginInput(),
@@ -913,16 +913,16 @@ Original task: Build something`
})
hook.startLoop("session-123", "Build something")
// #when - session goes idle (API will throw)
// when - session goes idle (API will throw)
const startTime = Date.now()
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
const elapsed = Date.now() - startTime
// #then - should complete quickly (not hang for 10s)
// then - should complete quickly (not hang for 10s)
expect(elapsed).toBeLessThan(2000)
// #then - loop should continue (API error = no completion detected)
// then - loop should continue (API error = no completion detected)
expect(promptCalls.length).toBe(1)
expect(apiCallCount).toBeGreaterThan(0)
})
+48 -48
View File
@@ -22,7 +22,7 @@ describe("findRuleFiles", () => {
describe(".github/instructions/ discovery", () => {
it("should discover .github/instructions/*.instructions.md files", () => {
// #given .github/instructions/ with valid files
// given .github/instructions/ with valid files
const instructionsDir = join(TEST_DIR, ".github", "instructions");
mkdirSync(instructionsDir, { recursive: true });
writeFileSync(
@@ -39,10 +39,10 @@ describe("findRuleFiles", () => {
const currentFile = join(srcDir, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules for a file
// when finding rules for a file
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find both instruction files
// then should find both instruction files
const paths = candidates.map((c) => c.path);
expect(
paths.some((p) => p.includes("typescript.instructions.md"))
@@ -53,7 +53,7 @@ describe("findRuleFiles", () => {
});
it("should ignore non-.instructions.md files in .github/instructions/", () => {
// #given .github/instructions/ with invalid files
// given .github/instructions/ with invalid files
const instructionsDir = join(TEST_DIR, ".github", "instructions");
mkdirSync(instructionsDir, { recursive: true });
writeFileSync(
@@ -66,10 +66,10 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should only find .instructions.md file
// then should only find .instructions.md file
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes("valid.instructions.md"))).toBe(
true
@@ -79,7 +79,7 @@ describe("findRuleFiles", () => {
});
it("should discover nested .instructions.md files in subdirectories", () => {
// #given nested .github/instructions/ structure
// given nested .github/instructions/ structure
const instructionsDir = join(TEST_DIR, ".github", "instructions");
const frontendDir = join(instructionsDir, "frontend");
mkdirSync(frontendDir, { recursive: true });
@@ -91,10 +91,10 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "app.tsx");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find nested instruction file
// then should find nested instruction file
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes("react.instructions.md"))).toBe(
true
@@ -104,7 +104,7 @@ describe("findRuleFiles", () => {
describe(".github/copilot-instructions.md (single file)", () => {
it("should discover copilot-instructions.md at project root", () => {
// #given .github/copilot-instructions.md at root
// given .github/copilot-instructions.md at root
const githubDir = join(TEST_DIR, ".github");
mkdirSync(githubDir, { recursive: true });
writeFileSync(
@@ -115,10 +115,10 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find the single file rule
// then should find the single file rule
const singleFile = candidates.find((c) =>
c.path.includes("copilot-instructions.md")
);
@@ -127,7 +127,7 @@ describe("findRuleFiles", () => {
});
it("should mark single file rules with isSingleFile: true", () => {
// #given copilot-instructions.md
// given copilot-instructions.md
const githubDir = join(TEST_DIR, ".github");
mkdirSync(githubDir, { recursive: true });
writeFileSync(
@@ -138,17 +138,17 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "file.ts");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then isSingleFile should be true
// then isSingleFile should be true
const copilotFile = candidates.find((c) => c.isSingleFile);
expect(copilotFile).toBeDefined();
expect(copilotFile?.path).toContain("copilot-instructions.md");
});
it("should set distance to 0 for single file rules", () => {
// #given copilot-instructions.md at project root
// given copilot-instructions.md at project root
const githubDir = join(TEST_DIR, ".github");
mkdirSync(githubDir, { recursive: true });
writeFileSync(
@@ -161,10 +161,10 @@ describe("findRuleFiles", () => {
const currentFile = join(srcDir, "file.ts");
writeFileSync(currentFile, "code");
// #when finding rules from deeply nested file
// when finding rules from deeply nested file
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then single file should have distance 0
// then single file should have distance 0
const copilotFile = candidates.find((c) => c.isSingleFile);
expect(copilotFile?.distance).toBe(0);
});
@@ -172,7 +172,7 @@ describe("findRuleFiles", () => {
describe("backward compatibility", () => {
it("should still discover .claude/rules/ files", () => {
// #given .claude/rules/ directory
// given .claude/rules/ directory
const rulesDir = join(TEST_DIR, ".claude", "rules");
mkdirSync(rulesDir, { recursive: true });
writeFileSync(join(rulesDir, "typescript.md"), "TS rules");
@@ -180,16 +180,16 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find claude rules
// then should find claude rules
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true);
});
it("should still discover .cursor/rules/ files", () => {
// #given .cursor/rules/ directory
// given .cursor/rules/ directory
const rulesDir = join(TEST_DIR, ".cursor", "rules");
mkdirSync(rulesDir, { recursive: true });
writeFileSync(join(rulesDir, "python.md"), "PY rules");
@@ -197,16 +197,16 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "main.py");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find cursor rules
// then should find cursor rules
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes(".cursor/rules/"))).toBe(true);
});
it("should discover .mdc files in rule directories", () => {
// #given .mdc file in .claude/rules/
// given .mdc file in .claude/rules/
const rulesDir = join(TEST_DIR, ".claude", "rules");
mkdirSync(rulesDir, { recursive: true });
writeFileSync(join(rulesDir, "advanced.mdc"), "MDC rules");
@@ -214,10 +214,10 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "app.ts");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find .mdc file
// then should find .mdc file
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.endsWith("advanced.mdc"))).toBe(true);
});
@@ -225,7 +225,7 @@ describe("findRuleFiles", () => {
describe("mixed sources", () => {
it("should discover rules from all sources", () => {
// #given rules in multiple directories
// given rules in multiple directories
const claudeRules = join(TEST_DIR, ".claude", "rules");
const cursorRules = join(TEST_DIR, ".cursor", "rules");
const githubInstructions = join(TEST_DIR, ".github", "instructions");
@@ -246,10 +246,10 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find all rules
// then should find all rules
expect(candidates.length).toBeGreaterThanOrEqual(4);
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true);
@@ -263,7 +263,7 @@ describe("findRuleFiles", () => {
});
it("should not duplicate single file rules", () => {
// #given copilot-instructions.md
// given copilot-instructions.md
const githubDir = join(TEST_DIR, ".github");
mkdirSync(githubDir, { recursive: true });
writeFileSync(
@@ -274,10 +274,10 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "file.ts");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should only have one copilot-instructions.md entry
// then should only have one copilot-instructions.md entry
const copilotFiles = candidates.filter((c) =>
c.path.includes("copilot-instructions.md")
);
@@ -287,7 +287,7 @@ describe("findRuleFiles", () => {
describe("user-level rules", () => {
it("should discover user-level .claude/rules/ files", () => {
// #given user-level rules
// given user-level rules
const userRulesDir = join(homeDir, ".claude", "rules");
mkdirSync(userRulesDir, { recursive: true });
writeFileSync(join(userRulesDir, "global.md"), "Global user rules");
@@ -295,17 +295,17 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "app.ts");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find user-level rules
// then should find user-level rules
const userRule = candidates.find((c) => c.isGlobal);
expect(userRule).toBeDefined();
expect(userRule?.path).toContain("global.md");
});
it("should mark user-level rules as isGlobal: true", () => {
// #given user-level rules
// given user-level rules
const userRulesDir = join(homeDir, ".claude", "rules");
mkdirSync(userRulesDir, { recursive: true });
writeFileSync(join(userRulesDir, "user.md"), "User rules");
@@ -313,10 +313,10 @@ describe("findRuleFiles", () => {
const currentFile = join(TEST_DIR, "app.ts");
writeFileSync(currentFile, "code");
// #when finding rules
// when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then isGlobal should be true
// then isGlobal should be true
const userRule = candidates.find((c) => c.path.includes("user.md"));
expect(userRule?.isGlobal).toBe(true);
expect(userRule?.distance).toBe(9999);
@@ -338,44 +338,44 @@ describe("findProjectRoot", () => {
});
it("should find project root with .git directory", () => {
// #given directory with .git
// given directory with .git
mkdirSync(join(TEST_DIR, ".git"), { recursive: true });
const nestedFile = join(TEST_DIR, "src", "components", "Button.tsx");
mkdirSync(join(TEST_DIR, "src", "components"), { recursive: true });
writeFileSync(nestedFile, "code");
// #when finding project root from nested file
// when finding project root from nested file
const root = findProjectRoot(nestedFile);
// #then should return the directory with .git
// then should return the directory with .git
expect(root).toBe(TEST_DIR);
});
it("should find project root with package.json", () => {
// #given directory with package.json
// given directory with package.json
writeFileSync(join(TEST_DIR, "package.json"), "{}");
const nestedFile = join(TEST_DIR, "lib", "index.js");
mkdirSync(join(TEST_DIR, "lib"), { recursive: true });
writeFileSync(nestedFile, "code");
// #when finding project root
// when finding project root
const root = findProjectRoot(nestedFile);
// #then should find the package.json directory
// then should find the package.json directory
expect(root).toBe(TEST_DIR);
});
it("should return null when no project markers found", () => {
// #given directory without any project markers
// given directory without any project markers
const isolatedDir = join(TEST_DIR, "isolated");
mkdirSync(isolatedDir, { recursive: true });
const file = join(isolatedDir, "file.txt");
writeFileSync(file, "content");
// #when finding project root
// when finding project root
const root = findProjectRoot(file);
// #then should return null
// then should return null
expect(root).toBeNull();
});
});
+5 -1
View File
@@ -16,6 +16,7 @@ import {
saveInjectedRules,
} from "./storage";
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
import { getRuleInjectionFilePath } from "./output-path";
interface ToolExecuteInput {
tool: string;
@@ -72,6 +73,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
return resolve(ctx.directory, path);
}
async function processFilePathForInjection(
filePath: string,
sessionID: string,
@@ -144,7 +146,9 @@ export function createRulesInjectorHook(ctx: PluginInput) {
const toolName = input.tool.toLowerCase();
if (TRACKED_TOOLS.includes(toolName)) {
await processFilePathForInjection(output.title, input.sessionID, output);
const filePath = getRuleInjectionFilePath(output);
if (!filePath) return;
await processFilePathForInjection(filePath, input.sessionID, output);
return;
}
};
@@ -0,0 +1,46 @@
import { describe, expect, it } from "bun:test";
import { getRuleInjectionFilePath } from "./output-path";
describe("getRuleInjectionFilePath", () => {
it("prefers metadata filePath when available", () => {
// given
const output = {
title: "read file",
metadata: { filePath: "/project/src/app.ts" },
};
// when
const result = getRuleInjectionFilePath(output);
// then
expect(result).toBe("/project/src/app.ts");
});
it("falls back to title when metadata filePath is missing", () => {
// given
const output = {
title: "src/app.ts",
metadata: {},
};
// when
const result = getRuleInjectionFilePath(output);
// then
expect(result).toBe("src/app.ts");
});
it("returns null when both title and metadata are empty", () => {
// given
const output = {
title: "",
metadata: null,
};
// when
const result = getRuleInjectionFilePath(output);
// then
expect(result).toBeNull();
});
});
+22
View File
@@ -0,0 +1,22 @@
export interface ToolExecuteOutputShape {
title: string;
metadata: unknown;
}
export function getRuleInjectionFilePath(
output: ToolExecuteOutputShape
): string | null {
const metadata = output.metadata as Record<string, unknown> | null;
const metadataFilePath =
metadata && typeof metadata === "object" ? metadata.filePath : undefined;
if (typeof metadataFilePath === "string" && metadataFilePath.length > 0) {
return metadataFilePath;
}
if (typeof output.title === "string" && output.title.length > 0) {
return output.title;
}
return null;
}
+45 -45
View File
@@ -4,36 +4,36 @@ import { parseRuleFrontmatter } from "./parser";
describe("parseRuleFrontmatter", () => {
describe("applyTo field (GitHub Copilot format)", () => {
it("should parse applyTo as single string", () => {
// #given frontmatter with applyTo as single string
// given frontmatter with applyTo as single string
const content = `---
applyTo: "*.ts"
---
Rule content here`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then globs should contain the pattern
// then globs should contain the pattern
expect(result.metadata.globs).toBe("*.ts");
expect(result.body).toBe("Rule content here");
});
it("should parse applyTo as inline array", () => {
// #given frontmatter with applyTo as inline array
// given frontmatter with applyTo as inline array
const content = `---
applyTo: ["*.ts", "*.tsx"]
---
Rule content`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then globs should be array
// then globs should be array
expect(result.metadata.globs).toEqual(["*.ts", "*.tsx"]);
});
it("should parse applyTo as multi-line array", () => {
// #given frontmatter with applyTo as multi-line array
// given frontmatter with applyTo as multi-line array
const content = `---
applyTo:
- "*.ts"
@@ -41,68 +41,68 @@ applyTo:
---
Content`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then globs should be array
// then globs should be array
expect(result.metadata.globs).toEqual(["*.ts", "src/**/*.js"]);
});
it("should parse applyTo as comma-separated string", () => {
// #given frontmatter with comma-separated applyTo
// given frontmatter with comma-separated applyTo
const content = `---
applyTo: "*.ts, *.js"
---
Content`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then globs should be array
// then globs should be array
expect(result.metadata.globs).toEqual(["*.ts", "*.js"]);
});
it("should merge applyTo and globs when both present", () => {
// #given frontmatter with both applyTo and globs
// given frontmatter with both applyTo and globs
const content = `---
globs: "*.md"
applyTo: "*.ts"
---
Content`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should merge both into globs array
// then should merge both into globs array
expect(result.metadata.globs).toEqual(["*.md", "*.ts"]);
});
it("should parse applyTo without quotes", () => {
// #given frontmatter with unquoted applyTo
// given frontmatter with unquoted applyTo
const content = `---
applyTo: **/*.py
---
Python rules`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should parse correctly
// then should parse correctly
expect(result.metadata.globs).toBe("**/*.py");
});
it("should parse applyTo with description", () => {
// #given frontmatter with applyTo and description (GitHub Copilot style)
// given frontmatter with applyTo and description (GitHub Copilot style)
const content = `---
applyTo: "**/*.ts,**/*.tsx"
description: "TypeScript coding standards"
---
# TypeScript Guidelines`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should parse both fields
// then should parse both fields
expect(result.metadata.globs).toEqual(["**/*.ts", "**/*.tsx"]);
expect(result.metadata.description).toBe("TypeScript coding standards");
});
@@ -110,70 +110,70 @@ description: "TypeScript coding standards"
describe("existing globs/paths parsing (backward compatibility)", () => {
it("should still parse globs field correctly", () => {
// #given existing globs format
// given existing globs format
const content = `---
globs: ["*.py", "**/*.ts"]
---
Python/TypeScript rules`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should work as before
// then should work as before
expect(result.metadata.globs).toEqual(["*.py", "**/*.ts"]);
});
it("should still parse paths field as alias", () => {
// #given paths field (Claude Code style)
// given paths field (Claude Code style)
const content = `---
paths: ["src/**"]
---
Source rules`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should map to globs
// then should map to globs
expect(result.metadata.globs).toEqual(["src/**"]);
});
it("should parse alwaysApply correctly", () => {
// #given frontmatter with alwaysApply
// given frontmatter with alwaysApply
const content = `---
alwaysApply: true
---
Always apply this rule`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should recognize alwaysApply
// then should recognize alwaysApply
expect(result.metadata.alwaysApply).toBe(true);
});
});
describe("no frontmatter", () => {
it("should return empty metadata and full body for plain markdown", () => {
// #given markdown without frontmatter
// given markdown without frontmatter
const content = `# Instructions
This is a plain rule file without frontmatter.`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should have empty metadata
// then should have empty metadata
expect(result.metadata).toEqual({});
expect(result.body).toBe(content);
});
it("should handle empty content", () => {
// #given empty content
// given empty content
const content = "";
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should return empty metadata and body
// then should return empty metadata and body
expect(result.metadata).toEqual({});
expect(result.body).toBe("");
});
@@ -181,22 +181,22 @@ This is a plain rule file without frontmatter.`;
describe("edge cases", () => {
it("should handle frontmatter with only applyTo", () => {
// #given minimal GitHub Copilot format
// given minimal GitHub Copilot format
const content = `---
applyTo: "**"
---
Apply to all files`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should parse correctly
// then should parse correctly
expect(result.metadata.globs).toBe("**");
expect(result.body).toBe("Apply to all files");
});
it("should handle mixed array formats", () => {
// #given globs as multi-line and applyTo as inline
// given globs as multi-line and applyTo as inline
const content = `---
globs:
- "*.md"
@@ -204,21 +204,21 @@ applyTo: ["*.ts", "*.js"]
---
Mixed format`;
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should merge both
// then should merge both
expect(result.metadata.globs).toEqual(["*.md", "*.ts", "*.js"]);
});
it("should handle Windows-style line endings", () => {
// #given content with CRLF
// given content with CRLF
const content = "---\r\napplyTo: \"*.ts\"\r\n---\r\nWindows content";
// #when parsing
// when parsing
const result = parseRuleFrontmatter(content);
// #then should parse correctly
// then should parse correctly
expect(result.metadata.globs).toBe("*.ts");
expect(result.body).toBe("Windows content");
});
+37 -37
View File
@@ -10,7 +10,7 @@ describe("session-notification", () => {
function createMockPluginInput() {
return {
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
// #given - track notification commands (osascript, notify-send, powershell)
// given - track notification commands (osascript, notify-send, powershell)
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
@@ -43,13 +43,13 @@ describe("session-notification", () => {
})
afterEach(() => {
// #given - cleanup after each test
// given - cleanup after each test
subagentSessions.clear()
_resetForTesting()
})
test("should not trigger notification for subagent session", async () => {
// #given - a subagent session exists
// given - a subagent session exists
const subagentSessionID = "subagent-123"
subagentSessions.add(subagentSessionID)
@@ -57,7 +57,7 @@ describe("session-notification", () => {
idleConfirmationDelay: 0,
})
// #when - subagent session goes idle
// when - subagent session goes idle
await hook({
event: {
type: "session.idle",
@@ -68,12 +68,12 @@ describe("session-notification", () => {
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - notification should NOT be sent
// then - notification should NOT be sent
expect(notificationCalls).toHaveLength(0)
})
test("should not trigger notification when mainSessionID is set and session is not main", async () => {
// #given - main session is set, but a different session goes idle
// given - main session is set, but a different session goes idle
const mainSessionID = "main-123"
const otherSessionID = "other-456"
setMainSession(mainSessionID)
@@ -82,7 +82,7 @@ describe("session-notification", () => {
idleConfirmationDelay: 0,
})
// #when - non-main session goes idle
// when - non-main session goes idle
await hook({
event: {
type: "session.idle",
@@ -93,12 +93,12 @@ describe("session-notification", () => {
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - notification should NOT be sent
// then - notification should NOT be sent
expect(notificationCalls).toHaveLength(0)
})
test("should trigger notification for main session when idle", async () => {
// #given - main session is set
// given - main session is set
const mainSessionID = "main-789"
setMainSession(mainSessionID)
@@ -107,7 +107,7 @@ describe("session-notification", () => {
skipIfIncompleteTodos: false,
})
// #when - main session goes idle
// when - main session goes idle
await hook({
event: {
type: "session.idle",
@@ -118,12 +118,12 @@ describe("session-notification", () => {
// Wait for idle confirmation delay + buffer
await new Promise((resolve) => setTimeout(resolve, 100))
// #then - notification should be sent
// then - notification should be sent
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
})
test("should skip notification for subagent even when mainSessionID is set", async () => {
// #given - both mainSessionID and subagent session exist
// given - both mainSessionID and subagent session exist
const mainSessionID = "main-999"
const subagentSessionID = "subagent-888"
setMainSession(mainSessionID)
@@ -133,7 +133,7 @@ describe("session-notification", () => {
idleConfirmationDelay: 0,
})
// #when - subagent session goes idle
// when - subagent session goes idle
await hook({
event: {
type: "session.idle",
@@ -144,12 +144,12 @@ describe("session-notification", () => {
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - notification should NOT be sent (subagent check takes priority)
// then - notification should NOT be sent (subagent check takes priority)
expect(notificationCalls).toHaveLength(0)
})
test("should handle subagentSessions and mainSessionID checks in correct order", async () => {
// #given - main session and subagent session exist
// given - main session and subagent session exist
const mainSessionID = "main-111"
const subagentSessionID = "subagent-222"
const unknownSessionID = "unknown-333"
@@ -160,7 +160,7 @@ describe("session-notification", () => {
idleConfirmationDelay: 0,
})
// #when - subagent session goes idle
// when - subagent session goes idle
await hook({
event: {
type: "session.idle",
@@ -168,7 +168,7 @@ describe("session-notification", () => {
},
})
// #when - unknown session goes idle (not main, not in subagentSessions)
// when - unknown session goes idle (not main, not in subagentSessions)
await hook({
event: {
type: "session.idle",
@@ -179,12 +179,12 @@ describe("session-notification", () => {
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - no notifications (subagent blocked by subagentSessions, unknown blocked by mainSessionID check)
// then - no notifications (subagent blocked by subagentSessions, unknown blocked by mainSessionID check)
expect(notificationCalls).toHaveLength(0)
})
test("should cancel pending notification on session activity", async () => {
// #given - main session is set
// given - main session is set
const mainSessionID = "main-cancel"
setMainSession(mainSessionID)
@@ -193,7 +193,7 @@ describe("session-notification", () => {
skipIfIncompleteTodos: false,
})
// #when - session goes idle
// when - session goes idle
await hook({
event: {
type: "session.idle",
@@ -201,7 +201,7 @@ describe("session-notification", () => {
},
})
// #when - activity happens before delay completes
// when - activity happens before delay completes
await hook({
event: {
type: "tool.execute.before",
@@ -212,15 +212,15 @@ describe("session-notification", () => {
// Wait for original delay to pass
await new Promise((resolve) => setTimeout(resolve, 150))
// #then - notification should NOT be sent (cancelled by activity)
// then - notification should NOT be sent (cancelled by activity)
expect(notificationCalls).toHaveLength(0)
})
test("should handle session.created event without notification", async () => {
// #given - a new session is created
// given - a new session is created
const hook = createSessionNotification(createMockPluginInput(), {})
// #when - session.created event fires
// when - session.created event fires
await hook({
event: {
type: "session.created",
@@ -233,15 +233,15 @@ describe("session-notification", () => {
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - no notification should be triggered
// then - no notification should be triggered
expect(notificationCalls).toHaveLength(0)
})
test("should handle session.deleted event and cleanup state", async () => {
// #given - a session exists
// given - a session exists
const hook = createSessionNotification(createMockPluginInput(), {})
// #when - session.deleted event fires
// when - session.deleted event fires
await hook({
event: {
type: "session.deleted",
@@ -254,12 +254,12 @@ describe("session-notification", () => {
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - no notification should be triggered
// then - no notification should be triggered
expect(notificationCalls).toHaveLength(0)
})
test("should mark session activity on message.updated event", async () => {
// #given - main session is set
// given - main session is set
const mainSessionID = "main-message"
setMainSession(mainSessionID)
@@ -268,7 +268,7 @@ describe("session-notification", () => {
skipIfIncompleteTodos: false,
})
// #when - session goes idle, then message.updated fires
// when - session goes idle, then message.updated fires
await hook({
event: {
type: "session.idle",
@@ -288,12 +288,12 @@ describe("session-notification", () => {
// Wait for idle delay to pass
await new Promise((resolve) => setTimeout(resolve, 100))
// #then - notification should NOT be sent (activity cancelled it)
// then - notification should NOT be sent (activity cancelled it)
expect(notificationCalls).toHaveLength(0)
})
test("should mark session activity on tool.execute.before event", async () => {
// #given - main session is set
// given - main session is set
const mainSessionID = "main-tool"
setMainSession(mainSessionID)
@@ -302,7 +302,7 @@ describe("session-notification", () => {
skipIfIncompleteTodos: false,
})
// #when - session goes idle, then tool.execute.before fires
// when - session goes idle, then tool.execute.before fires
await hook({
event: {
type: "session.idle",
@@ -320,12 +320,12 @@ describe("session-notification", () => {
// Wait for idle delay to pass
await new Promise((resolve) => setTimeout(resolve, 100))
// #then - notification should NOT be sent (activity cancelled it)
// then - notification should NOT be sent (activity cancelled it)
expect(notificationCalls).toHaveLength(0)
})
test("should not send duplicate notification for same session", async () => {
// #given - main session is set
// given - main session is set
const mainSessionID = "main-dup"
setMainSession(mainSessionID)
@@ -334,7 +334,7 @@ describe("session-notification", () => {
skipIfIncompleteTodos: false,
})
// #when - session goes idle twice
// when - session goes idle twice
await hook({
event: {
type: "session.idle",
@@ -355,7 +355,7 @@ describe("session-notification", () => {
// Wait for second potential notification
await new Promise((resolve) => setTimeout(resolve, 50))
// #then - only one notification should be sent
// then - only one notification should be sent
expect(notificationCalls).toHaveLength(1)
})
})
+45 -45
View File
@@ -4,171 +4,171 @@ import { detectErrorType } from "./index"
describe("detectErrorType", () => {
describe("thinking_block_order errors", () => {
it("should detect 'first block' error pattern", () => {
// #given an error about thinking being the first block
// given an error about thinking being the first block
const error = {
message: "messages.0: thinking block must not be the first block",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
// then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'must start with' error pattern", () => {
// #given an error about message must start with something
// given an error about message must start with something
const error = {
message: "messages.5: thinking must start with text or tool_use",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
// then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'preceeding' error pattern", () => {
// #given an error about preceeding block
// given an error about preceeding block
const error = {
message: "messages.10: thinking requires preceeding text block",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
// then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'expected/found' error pattern", () => {
// #given an error about expected vs found
// given an error about expected vs found
const error = {
message: "messages.3: thinking block expected text but found tool_use",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
// then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'final block cannot be thinking' error pattern", () => {
// #given an error about final block cannot be thinking
// given an error about final block cannot be thinking
const error = {
message:
"messages.125: The final block in an assistant message cannot be thinking.",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
// then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'final block' variant error pattern", () => {
// #given an error mentioning final block with thinking
// given an error mentioning final block with thinking
const error = {
message:
"messages.17: thinking in the final block is not allowed in assistant messages",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
// then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'cannot be thinking' error pattern", () => {
// #given an error using 'cannot be thinking' phrasing
// given an error using 'cannot be thinking' phrasing
const error = {
message:
"messages.219: The last block in an assistant message cannot be thinking content",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
// then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
})
describe("tool_result_missing errors", () => {
it("should detect tool_use/tool_result mismatch", () => {
// #given an error about tool_use without tool_result
// given an error about tool_use without tool_result
const error = {
message: "tool_use block requires corresponding tool_result",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return tool_result_missing
// then should return tool_result_missing
expect(result).toBe("tool_result_missing")
})
})
describe("thinking_disabled_violation errors", () => {
it("should detect thinking disabled violation", () => {
// #given an error about thinking being disabled
// given an error about thinking being disabled
const error = {
message:
"thinking is disabled for this model and cannot contain thinking blocks",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_disabled_violation
// then should return thinking_disabled_violation
expect(result).toBe("thinking_disabled_violation")
})
})
describe("unrecognized errors", () => {
it("should return null for unrecognized error patterns", () => {
// #given an unrelated error
// given an unrelated error
const error = {
message: "Rate limit exceeded",
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return null
// then should return null
expect(result).toBeNull()
})
it("should return null for empty error", () => {
// #given an empty error
// given an empty error
const error = {}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return null
// then should return null
expect(result).toBeNull()
})
it("should return null for null error", () => {
// #given a null error
// given a null error
const error = null
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return null
// then should return null
expect(result).toBeNull()
})
})
describe("nested error objects", () => {
it("should detect error in data.error.message path", () => {
// #given an error with nested structure
// given an error with nested structure
const error = {
data: {
error: {
@@ -178,30 +178,30 @@ describe("detectErrorType", () => {
},
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
// then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect error in error.message path", () => {
// #given an error with error.message structure
// given an error with error.message structure
const error = {
error: {
message: "messages.169: final block cannot be thinking",
},
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
// then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect thinking_block_order even when error message contains tool_use/tool_result in docs URL", () => {
// #given Anthropic's extended thinking error with tool_use/tool_result in the documentation text
// given Anthropic's extended thinking error with tool_use/tool_result in the documentation text
const error = {
error: {
type: "invalid_request_error",
@@ -213,10 +213,10 @@ describe("detectErrorType", () => {
},
}
// #when detectErrorType is called
// when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order (NOT tool_result_missing)
// then should return thinking_block_order (NOT tool_result_missing)
expect(result).toBe("thinking_block_order")
})
})
+39 -39
View File
@@ -40,24 +40,24 @@ describe("start-work hook", () => {
describe("chat.message handler", () => {
test("should ignore non-start-work commands", async () => {
// #given - hook and non-start-work message
// given - hook and non-start-work message
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "Just a regular message" }],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - output should be unchanged
// then - output should be unchanged
expect(output.parts[0].text).toBe("Just a regular message")
})
test("should detect start-work command via session-context tag", async () => {
// #given - hook and start-work message
// given - hook and start-work message
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
@@ -68,18 +68,18 @@ describe("start-work hook", () => {
],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - output should be modified with context info
// then - output should be modified with context info
expect(output.parts[0].text).toContain("---")
})
test("should inject resume info when existing boulder state found", async () => {
// #given - existing boulder state with incomplete plan
// given - existing boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
@@ -96,19 +96,19 @@ describe("start-work hook", () => {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - should show resuming status
// then - should show resuming status
expect(output.parts[0].text).toContain("RESUMING")
expect(output.parts[0].text).toContain("test-plan")
})
test("should replace $SESSION_ID placeholder", async () => {
// #given - hook and message with placeholder
// given - hook and message with placeholder
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
@@ -119,19 +119,19 @@ describe("start-work hook", () => {
],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "ses-abc123" },
output
)
// #then - placeholder should be replaced
// then - placeholder should be replaced
expect(output.parts[0].text).toContain("ses-abc123")
expect(output.parts[0].text).not.toContain("$SESSION_ID")
})
test("should replace $TIMESTAMP placeholder", async () => {
// #given - hook and message with placeholder
// given - hook and message with placeholder
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [
@@ -142,19 +142,19 @@ describe("start-work hook", () => {
],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - placeholder should be replaced with ISO timestamp
// then - placeholder should be replaced with ISO timestamp
expect(output.parts[0].text).not.toContain("$TIMESTAMP")
expect(output.parts[0].text).toMatch(/\d{4}-\d{2}-\d{2}T/)
})
test("should auto-select when only one incomplete plan among multiple plans", async () => {
// #given - multiple plans but only one incomplete
// given - multiple plans but only one incomplete
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
@@ -171,20 +171,20 @@ describe("start-work hook", () => {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - should auto-select the incomplete plan, not ask user
// then - should auto-select the incomplete plan, not ask user
expect(output.parts[0].text).toContain("Auto-Selected Plan")
expect(output.parts[0].text).toContain("plan-incomplete")
expect(output.parts[0].text).not.toContain("Multiple Plans Found")
})
test("should wrap multiple plans message in system-reminder tag", async () => {
// #given - multiple incomplete plans
// given - multiple incomplete plans
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
@@ -199,20 +199,20 @@ describe("start-work hook", () => {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - should use system-reminder tag format
// then - should use system-reminder tag format
expect(output.parts[0].text).toContain("<system-reminder>")
expect(output.parts[0].text).toContain("</system-reminder>")
expect(output.parts[0].text).toContain("Multiple Plans Found")
})
test("should use 'ask user' prompt style for multiple plans", async () => {
// #given - multiple incomplete plans
// given - multiple incomplete plans
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
@@ -227,19 +227,19 @@ describe("start-work hook", () => {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - should prompt agent to ask user, not ask directly
// then - should prompt agent to ask user, not ask directly
expect(output.parts[0].text).toContain("Ask the user")
expect(output.parts[0].text).not.toContain("Which plan would you like to work on?")
})
test("should select explicitly specified plan name from user-request, ignoring existing boulder state", async () => {
// #given - existing boulder state pointing to old plan
// given - existing boulder state pointing to old plan
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
@@ -272,20 +272,20 @@ describe("start-work hook", () => {
],
}
// #when - user explicitly specifies new-plan
// when - user explicitly specifies new-plan
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - should select new-plan, NOT resume old-plan
// then - should select new-plan, NOT resume old-plan
expect(output.parts[0].text).toContain("new-plan")
expect(output.parts[0].text).not.toContain("RESUMING")
expect(output.parts[0].text).not.toContain("old-plan")
})
test("should strip ultrawork/ulw keywords from plan name argument", async () => {
// #given - plan with ultrawork keyword in user-request
// given - plan with ultrawork keyword in user-request
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
@@ -304,19 +304,19 @@ describe("start-work hook", () => {
],
}
// #when - user specifies plan with ultrawork keyword
// when - user specifies plan with ultrawork keyword
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - should find plan without ultrawork suffix
// then - should find plan without ultrawork suffix
expect(output.parts[0].text).toContain("my-feature-plan")
expect(output.parts[0].text).toContain("Auto-Selected Plan")
})
test("should strip ulw keyword from plan name argument", async () => {
// #given - plan with ulw keyword in user-request
// given - plan with ulw keyword in user-request
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
@@ -335,19 +335,19 @@ describe("start-work hook", () => {
],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - should find plan without ulw suffix
// then - should find plan without ulw suffix
expect(output.parts[0].text).toContain("api-refactor")
expect(output.parts[0].text).toContain("Auto-Selected Plan")
})
test("should match plan by partial name", async () => {
// #given - user specifies partial plan name
// given - user specifies partial plan name
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
@@ -366,13 +366,13 @@ describe("start-work hook", () => {
],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// #then - should find plan by partial match
// then - should find plan by partial match
expect(output.parts[0].text).toContain("2026-01-15-feature-implementation")
expect(output.parts[0].text).toContain("Auto-Selected Plan")
})
@@ -380,7 +380,7 @@ describe("start-work hook", () => {
describe("session agent management", () => {
test("should update session agent to Atlas when start-work command is triggered", async () => {
// #given
// given
const updateSpy = spyOn(sessionState, "updateSessionAgent")
const hook = createStartWorkHook(createMockPluginInput())
@@ -388,13 +388,13 @@ describe("start-work hook", () => {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// #when
// when
await hook["chat.message"](
{ sessionID: "ses-prometheus-to-sisyphus" },
output
)
// #then
// then
expect(updateSpy).toHaveBeenCalledWith("ses-prometheus-to-sisyphus", "atlas")
updateSpy.mockRestore()
})
+27 -27
View File
@@ -14,64 +14,64 @@ describe("stop-continuation-guard", () => {
}
test("should mark session as stopped", () => {
// #given - a guard hook with no stopped sessions
// given - a guard hook with no stopped sessions
const guard = createStopContinuationGuardHook(createMockPluginInput())
const sessionID = "test-session-1"
// #when - we stop continuation for the session
// when - we stop continuation for the session
guard.stop(sessionID)
// #then - session should be marked as stopped
// then - session should be marked as stopped
expect(guard.isStopped(sessionID)).toBe(true)
})
test("should return false for non-stopped sessions", () => {
// #given - a guard hook with no stopped sessions
// given - a guard hook with no stopped sessions
const guard = createStopContinuationGuardHook(createMockPluginInput())
// #when - we check a session that was never stopped
// when - we check a session that was never stopped
// #then - it should return false
// then - it should return false
expect(guard.isStopped("non-existent-session")).toBe(false)
})
test("should clear stopped state for a session", () => {
// #given - a session that was stopped
// given - a session that was stopped
const guard = createStopContinuationGuardHook(createMockPluginInput())
const sessionID = "test-session-2"
guard.stop(sessionID)
// #when - we clear the session
// when - we clear the session
guard.clear(sessionID)
// #then - session should no longer be stopped
// then - session should no longer be stopped
expect(guard.isStopped(sessionID)).toBe(false)
})
test("should handle multiple sessions independently", () => {
// #given - multiple sessions with different stop states
// given - multiple sessions with different stop states
const guard = createStopContinuationGuardHook(createMockPluginInput())
const session1 = "session-1"
const session2 = "session-2"
const session3 = "session-3"
// #when - we stop some sessions but not others
// when - we stop some sessions but not others
guard.stop(session1)
guard.stop(session2)
// #then - each session has its own state
// then - each session has its own state
expect(guard.isStopped(session1)).toBe(true)
expect(guard.isStopped(session2)).toBe(true)
expect(guard.isStopped(session3)).toBe(false)
})
test("should clear session on session.deleted event", async () => {
// #given - a session that was stopped
// given - a session that was stopped
const guard = createStopContinuationGuardHook(createMockPluginInput())
const sessionID = "test-session-3"
guard.stop(sessionID)
// #when - session is deleted
// when - session is deleted
await guard.event({
event: {
type: "session.deleted",
@@ -79,19 +79,19 @@ describe("stop-continuation-guard", () => {
},
})
// #then - session should no longer be stopped (cleaned up)
// then - session should no longer be stopped (cleaned up)
expect(guard.isStopped(sessionID)).toBe(false)
})
test("should not affect other sessions on session.deleted", async () => {
// #given - multiple stopped sessions
// given - multiple stopped sessions
const guard = createStopContinuationGuardHook(createMockPluginInput())
const session1 = "session-keep"
const session2 = "session-delete"
guard.stop(session1)
guard.stop(session2)
// #when - one session is deleted
// when - one session is deleted
await guard.event({
event: {
type: "session.deleted",
@@ -99,46 +99,46 @@ describe("stop-continuation-guard", () => {
},
})
// #then - other session should remain stopped
// then - other session should remain stopped
expect(guard.isStopped(session1)).toBe(true)
expect(guard.isStopped(session2)).toBe(false)
})
test("should clear stopped state on new user message (chat.message)", async () => {
// #given - a session that was stopped
// given - a session that was stopped
const guard = createStopContinuationGuardHook(createMockPluginInput())
const sessionID = "test-session-4"
guard.stop(sessionID)
expect(guard.isStopped(sessionID)).toBe(true)
// #when - user sends a new message
// when - user sends a new message
await guard["chat.message"]({ sessionID })
// #then - stop state should be cleared (one-time only)
// then - stop state should be cleared (one-time only)
expect(guard.isStopped(sessionID)).toBe(false)
})
test("should not affect non-stopped sessions on chat.message", async () => {
// #given - a session that was never stopped
// given - a session that was never stopped
const guard = createStopContinuationGuardHook(createMockPluginInput())
const sessionID = "test-session-5"
// #when - user sends a message (session was never stopped)
// when - user sends a message (session was never stopped)
await guard["chat.message"]({ sessionID })
// #then - should not throw and session remains not stopped
// then - should not throw and session remains not stopped
expect(guard.isStopped(sessionID)).toBe(false)
})
test("should handle undefined sessionID in chat.message", async () => {
// #given - a guard with a stopped session
// given - a guard with a stopped session
const guard = createStopContinuationGuardHook(createMockPluginInput())
guard.stop("some-session")
// #when - chat.message is called without sessionID
// when - chat.message is called without sessionID
await guard["chat.message"]({ sessionID: undefined })
// #then - should not throw and stopped session remains stopped
// then - should not throw and stopped session remains stopped
expect(guard.isStopped("some-session")).toBe(true)
})
})
@@ -11,71 +11,71 @@ describe("createSubagentQuestionBlockerHook", () => {
describe("tool.execute.before", () => {
test("allows question tool for non-subagent sessions", async () => {
//#given
// given
const sessionID = "ses_main"
const input = { tool: "question", sessionID, callID: "call_1" }
const output = { args: { questions: [] } }
//#when
// when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
// then
await expect(result).resolves.toBeUndefined()
})
test("blocks question tool for subagent sessions", async () => {
//#given
// given
const sessionID = "ses_subagent"
subagentSessions.add(sessionID)
const input = { tool: "question", sessionID, callID: "call_1" }
const output = { args: { questions: [] } }
//#when
// when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
// then
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
})
test("blocks Question tool (case insensitive) for subagent sessions", async () => {
//#given
// given
const sessionID = "ses_subagent"
subagentSessions.add(sessionID)
const input = { tool: "Question", sessionID, callID: "call_1" }
const output = { args: { questions: [] } }
//#when
// when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
// then
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
})
test("blocks AskUserQuestion tool for subagent sessions", async () => {
//#given
// given
const sessionID = "ses_subagent"
subagentSessions.add(sessionID)
const input = { tool: "AskUserQuestion", sessionID, callID: "call_1" }
const output = { args: { questions: [] } }
//#when
// when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
// then
await expect(result).rejects.toThrow("Question tool is disabled for subagent sessions")
})
test("ignores non-question tools for subagent sessions", async () => {
//#given
// given
const sessionID = "ses_subagent"
subagentSessions.add(sessionID)
const input = { tool: "bash", sessionID, callID: "call_1" }
const output = { args: { command: "ls" } }
//#when
// when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
// then
await expect(result).resolves.toBeUndefined()
})
})
+60 -60
View File
@@ -37,7 +37,7 @@ describe("createThinkModeHook integration", () => {
describe("GitHub Copilot provider integration", () => {
describe("Claude models", () => {
it("should activate thinking mode for github-copilot Claude with think keyword", async () => {
// #given a github-copilot Claude model and prompt with "think" keyword
// given a github-copilot Claude model and prompt with "think" keyword
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -45,10 +45,10 @@ describe("createThinkModeHook integration", () => {
"Please think deeply about this problem"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should upgrade to high variant and inject thinking config
// then should upgrade to high variant and inject thinking config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high")
expect(message.thinking).toBeDefined()
@@ -61,7 +61,7 @@ describe("createThinkModeHook integration", () => {
})
it("should handle github-copilot Claude with dots in version", async () => {
// #given a github-copilot Claude model with dot format (claude-opus-4.5)
// given a github-copilot Claude model with dot format (claude-opus-4.5)
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -69,17 +69,17 @@ describe("createThinkModeHook integration", () => {
"ultrathink mode"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should upgrade to high variant (hyphen format)
// then should upgrade to high variant (hyphen format)
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high")
expect(message.thinking).toBeDefined()
})
it("should handle github-copilot Claude Sonnet", async () => {
// #given a github-copilot Claude Sonnet model
// given a github-copilot Claude Sonnet model
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -87,10 +87,10 @@ describe("createThinkModeHook integration", () => {
"think about this"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should upgrade to high variant
// then should upgrade to high variant
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-sonnet-4-5-high")
expect(message.thinking).toBeDefined()
@@ -99,7 +99,7 @@ describe("createThinkModeHook integration", () => {
describe("Gemini models", () => {
it("should activate thinking mode for github-copilot Gemini Pro", async () => {
// #given a github-copilot Gemini Pro model
// given a github-copilot Gemini Pro model
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -107,10 +107,10 @@ describe("createThinkModeHook integration", () => {
"think about this"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should upgrade to high variant and inject google thinking config
// then should upgrade to high variant and inject google thinking config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gemini-3-pro-high")
expect(message.providerOptions).toBeDefined()
@@ -121,7 +121,7 @@ describe("createThinkModeHook integration", () => {
})
it("should activate thinking mode for github-copilot Gemini Flash", async () => {
// #given a github-copilot Gemini Flash model
// given a github-copilot Gemini Flash model
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -129,10 +129,10 @@ describe("createThinkModeHook integration", () => {
"ultrathink"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should upgrade to high variant
// then should upgrade to high variant
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gemini-3-flash-high")
expect(message.providerOptions).toBeDefined()
@@ -141,7 +141,7 @@ describe("createThinkModeHook integration", () => {
describe("GPT models", () => {
it("should activate thinking mode for github-copilot GPT-5.2", async () => {
// #given a github-copilot GPT-5.2 model
// given a github-copilot GPT-5.2 model
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -149,24 +149,24 @@ describe("createThinkModeHook integration", () => {
"please think"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should upgrade to high variant and inject openai thinking config
// then should upgrade to high variant and inject openai thinking config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gpt-5-2-high")
expect(message.reasoning_effort).toBe("high")
})
it("should activate thinking mode for github-copilot GPT-5", async () => {
// #given a github-copilot GPT-5 model
// given a github-copilot GPT-5 model
const hook = createThinkModeHook()
const input = createMockInput("github-copilot", "gpt-5", "think deeply")
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should upgrade to high variant
// then should upgrade to high variant
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gpt-5-high")
expect(message.reasoning_effort).toBe("high")
@@ -175,7 +175,7 @@ describe("createThinkModeHook integration", () => {
describe("No think keyword", () => {
it("should NOT activate for github-copilot without think keyword", async () => {
// #given a prompt without any think keyword
// given a prompt without any think keyword
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -184,10 +184,10 @@ describe("createThinkModeHook integration", () => {
)
const originalModelID = input.message.model?.modelID
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should NOT change model or inject config
// then should NOT change model or inject config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe(originalModelID)
expect(message.thinking).toBeUndefined()
@@ -197,7 +197,7 @@ describe("createThinkModeHook integration", () => {
describe("Backwards compatibility with direct providers", () => {
it("should still work for direct anthropic provider", async () => {
// #given direct anthropic provider
// given direct anthropic provider
const hook = createThinkModeHook()
const input = createMockInput(
"anthropic",
@@ -205,17 +205,17 @@ describe("createThinkModeHook integration", () => {
"think about this"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should work as before
// then should work as before
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-sonnet-4-5-high")
expect(message.thinking).toBeDefined()
})
it("should still work for direct google provider", async () => {
// #given direct google provider
// given direct google provider
const hook = createThinkModeHook()
const input = createMockInput(
"google",
@@ -223,31 +223,31 @@ describe("createThinkModeHook integration", () => {
"think about this"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should work as before
// then should work as before
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gemini-3-pro-high")
expect(message.providerOptions).toBeDefined()
})
it("should still work for direct openai provider", async () => {
// #given direct openai provider
// given direct openai provider
const hook = createThinkModeHook()
const input = createMockInput("openai", "gpt-5", "think about this")
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should work
// then should work
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gpt-5-high")
expect(message.reasoning_effort).toBe("high")
})
it("should still work for amazon-bedrock provider", async () => {
// #given amazon-bedrock provider
// given amazon-bedrock provider
const hook = createThinkModeHook()
const input = createMockInput(
"amazon-bedrock",
@@ -255,10 +255,10 @@ describe("createThinkModeHook integration", () => {
"think"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should inject bedrock thinking config
// then should inject bedrock thinking config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-sonnet-4-5-high")
expect(message.reasoningConfig).toBeDefined()
@@ -267,7 +267,7 @@ describe("createThinkModeHook integration", () => {
describe("Already-high variants", () => {
it("should NOT re-upgrade already-high variants", async () => {
// #given an already-high variant model
// given an already-high variant model
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -275,10 +275,10 @@ describe("createThinkModeHook integration", () => {
"think deeply"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should NOT modify the model (already high)
// then should NOT modify the model (already high)
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high")
// No additional thinking config should be injected
@@ -286,7 +286,7 @@ describe("createThinkModeHook integration", () => {
})
it("should NOT re-upgrade already-high GPT variants", async () => {
// #given an already-high GPT variant
// given an already-high GPT variant
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -294,10 +294,10 @@ describe("createThinkModeHook integration", () => {
"ultrathink"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should NOT modify the model
// then should NOT modify the model
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gpt-5.2-high")
expect(message.reasoning_effort).toBeUndefined()
@@ -306,7 +306,7 @@ describe("createThinkModeHook integration", () => {
describe("Unknown models", () => {
it("should not crash for unknown models via github-copilot", async () => {
// #given an unknown model type
// given an unknown model type
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
@@ -314,46 +314,46 @@ describe("createThinkModeHook integration", () => {
"think about this"
)
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should not crash and model should remain unchanged
// then should not crash and model should remain unchanged
expect(input.message.model?.modelID).toBe("llama-3-70b")
})
})
describe("Edge cases", () => {
it("should handle missing model gracefully", async () => {
// #given input without a model
// given input without a model
const hook = createThinkModeHook()
const input: ThinkModeInput = {
parts: [{ type: "text", text: "think about this" }],
message: {},
}
// #when the chat.params hook is called
// #then should not crash
// when the chat.params hook is called
// then should not crash
await expect(
hook["chat.params"](input, sessionID)
).resolves.toBeUndefined()
})
it("should handle empty prompt gracefully", async () => {
// #given empty prompt
// given empty prompt
const hook = createThinkModeHook()
const input = createMockInput("github-copilot", "claude-opus-4-5", "")
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should not upgrade (no think keyword)
// then should not upgrade (no think keyword)
expect(input.message.model?.modelID).toBe("claude-opus-4-5")
})
})
describe("Agent-level thinking configuration respect", () => {
it("should NOT inject thinking config when agent has thinking disabled", async () => {
// #given agent with thinking explicitly disabled
// given agent with thinking explicitly disabled
const hook = createThinkModeHook()
const input: ThinkModeInput = {
parts: [{ type: "text", text: "ultrathink deeply" }],
@@ -363,17 +363,17 @@ describe("createThinkModeHook integration", () => {
} as ThinkModeInput["message"],
}
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should NOT override agent's thinking disabled setting
// then should NOT override agent's thinking disabled setting
const message = input.message as MessageWithInjectedProps
expect((message.thinking as { type: string }).type).toBe("disabled")
expect(message.providerOptions).toBeUndefined()
})
it("should NOT inject thinking config when agent has custom providerOptions", async () => {
// #given agent with custom providerOptions
// given agent with custom providerOptions
const hook = createThinkModeHook()
const input: ThinkModeInput = {
parts: [{ type: "text", text: "ultrathink" }],
@@ -385,10 +385,10 @@ describe("createThinkModeHook integration", () => {
} as ThinkModeInput["message"],
}
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should NOT override agent's providerOptions
// then should NOT override agent's providerOptions
const message = input.message as MessageWithInjectedProps
const providerOpts = message.providerOptions as Record<string, unknown>
expect((providerOpts.google as Record<string, unknown>).thinkingConfig).toEqual({
@@ -397,14 +397,14 @@ describe("createThinkModeHook integration", () => {
})
it("should still inject thinking config when agent has no thinking override", async () => {
// #given agent without thinking override
// given agent without thinking override
const hook = createThinkModeHook()
const input = createMockInput("google", "gemini-3-pro", "ultrathink")
// #when the chat.params hook is called
// when the chat.params hook is called
await hook["chat.params"](input, sessionID)
// #then should inject thinking config as normal
// then should inject thinking config as normal
const message = input.message as MessageWithInjectedProps
expect(message.providerOptions).toBeDefined()
})
+84 -84
View File
@@ -10,14 +10,14 @@ describe("think-mode switcher", () => {
describe("GitHub Copilot provider support", () => {
describe("Claude models via github-copilot", () => {
it("should resolve github-copilot Claude Opus to anthropic config", () => {
// #given a github-copilot provider with Claude Opus model
// given a github-copilot provider with Claude Opus model
const providerID = "github-copilot"
const modelID = "claude-opus-4-5"
// #when getting thinking config
// when getting thinking config
const config = getThinkingConfig(providerID, modelID)
// #then should return anthropic thinking config
// then should return anthropic thinking config
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
expect((config?.thinking as Record<string, unknown>)?.type).toBe(
@@ -29,19 +29,19 @@ describe("think-mode switcher", () => {
})
it("should resolve github-copilot Claude Sonnet to anthropic config", () => {
// #given a github-copilot provider with Claude Sonnet model
// given a github-copilot provider with Claude Sonnet model
const config = getThinkingConfig("github-copilot", "claude-sonnet-4-5")
// #then should return anthropic thinking config
// then should return anthropic thinking config
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
})
it("should handle Claude with dots in version number", () => {
// #given a model ID with dots (claude-opus-4.5)
// given a model ID with dots (claude-opus-4.5)
const config = getThinkingConfig("github-copilot", "claude-opus-4.5")
// #then should still return anthropic thinking config
// then should still return anthropic thinking config
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
})
@@ -49,10 +49,10 @@ describe("think-mode switcher", () => {
describe("Gemini models via github-copilot", () => {
it("should resolve github-copilot Gemini Pro to google config", () => {
// #given a github-copilot provider with Gemini Pro model
// given a github-copilot provider with Gemini Pro model
const config = getThinkingConfig("github-copilot", "gemini-3-pro")
// #then should return google thinking config
// then should return google thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
const googleOptions = (
@@ -62,13 +62,13 @@ describe("think-mode switcher", () => {
})
it("should resolve github-copilot Gemini Flash to google config", () => {
// #given a github-copilot provider with Gemini Flash model
// given a github-copilot provider with Gemini Flash model
const config = getThinkingConfig(
"github-copilot",
"gemini-3-flash"
)
// #then should return google thinking config
// then should return google thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
})
@@ -76,37 +76,37 @@ describe("think-mode switcher", () => {
describe("GPT models via github-copilot", () => {
it("should resolve github-copilot GPT-5.2 to openai config", () => {
// #given a github-copilot provider with GPT-5.2 model
// given a github-copilot provider with GPT-5.2 model
const config = getThinkingConfig("github-copilot", "gpt-5.2")
// #then should return openai thinking config
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
it("should resolve github-copilot GPT-5 to openai config", () => {
// #given a github-copilot provider with GPT-5 model
// given a github-copilot provider with GPT-5 model
const config = getThinkingConfig("github-copilot", "gpt-5")
// #then should return openai thinking config
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
it("should resolve github-copilot o1 to openai config", () => {
// #given a github-copilot provider with o1 model
// given a github-copilot provider with o1 model
const config = getThinkingConfig("github-copilot", "o1-preview")
// #then should return openai thinking config
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
it("should resolve github-copilot o3 to openai config", () => {
// #given a github-copilot provider with o3 model
// given a github-copilot provider with o3 model
const config = getThinkingConfig("github-copilot", "o3-mini")
// #then should return openai thinking config
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
@@ -114,10 +114,10 @@ describe("think-mode switcher", () => {
describe("Unknown models via github-copilot", () => {
it("should return null for unknown model types", () => {
// #given a github-copilot provider with unknown model
// given a github-copilot provider with unknown model
const config = getThinkingConfig("github-copilot", "llama-3-70b")
// #then should return null (no matching provider)
// then should return null (no matching provider)
expect(config).toBeNull()
})
})
@@ -126,39 +126,39 @@ describe("think-mode switcher", () => {
describe("Model ID normalization", () => {
describe("getHighVariant with dots vs hyphens", () => {
it("should handle dots in Claude version numbers", () => {
// #given a Claude model ID with dot format
// given a Claude model ID with dot format
const variant = getHighVariant("claude-opus-4.5")
// #then should return high variant with hyphen format
// then should return high variant with hyphen format
expect(variant).toBe("claude-opus-4-5-high")
})
it("should handle hyphens in Claude version numbers", () => {
// #given a Claude model ID with hyphen format
// given a Claude model ID with hyphen format
const variant = getHighVariant("claude-opus-4-5")
// #then should return high variant
// then should return high variant
expect(variant).toBe("claude-opus-4-5-high")
})
it("should handle dots in GPT version numbers", () => {
// #given a GPT model ID with dot format (gpt-5.2)
// given a GPT model ID with dot format (gpt-5.2)
const variant = getHighVariant("gpt-5.2")
// #then should return high variant
// then should return high variant
expect(variant).toBe("gpt-5-2-high")
})
it("should handle dots in GPT-5.1 codex variants", () => {
// #given a GPT-5.1-codex model ID
// given a GPT-5.1-codex model ID
const variant = getHighVariant("gpt-5.1-codex")
// #then should return high variant
// then should return high variant
expect(variant).toBe("gpt-5-1-codex-high")
})
it("should handle Gemini preview variants", () => {
// #given Gemini preview model IDs
// given Gemini preview model IDs
expect(getHighVariant("gemini-3-pro")).toBe(
"gemini-3-pro-high"
)
@@ -168,14 +168,14 @@ describe("think-mode switcher", () => {
})
it("should return null for already-high variants", () => {
// #given model IDs that are already high variants
// given model IDs that are already high variants
expect(getHighVariant("claude-opus-4-5-high")).toBeNull()
expect(getHighVariant("gpt-5-2-high")).toBeNull()
expect(getHighVariant("gemini-3-pro-high")).toBeNull()
})
it("should return null for unknown models", () => {
// #given unknown model IDs
// given unknown model IDs
expect(getHighVariant("llama-3-70b")).toBeNull()
expect(getHighVariant("mistral-large")).toBeNull()
})
@@ -184,19 +184,19 @@ describe("think-mode switcher", () => {
describe("isAlreadyHighVariant", () => {
it("should detect -high suffix", () => {
// #given model IDs with -high suffix
// given model IDs with -high suffix
expect(isAlreadyHighVariant("claude-opus-4-5-high")).toBe(true)
expect(isAlreadyHighVariant("gpt-5-2-high")).toBe(true)
expect(isAlreadyHighVariant("gemini-3-pro-high")).toBe(true)
})
it("should detect -high suffix after normalization", () => {
// #given model IDs with dots that end in -high
// given model IDs with dots that end in -high
expect(isAlreadyHighVariant("gpt-5.2-high")).toBe(true)
})
it("should return false for base models", () => {
// #given base model IDs without -high suffix
// given base model IDs without -high suffix
expect(isAlreadyHighVariant("claude-opus-4-5")).toBe(false)
expect(isAlreadyHighVariant("claude-opus-4.5")).toBe(false)
expect(isAlreadyHighVariant("gpt-5.2")).toBe(false)
@@ -204,7 +204,7 @@ describe("think-mode switcher", () => {
})
it("should return false for models with 'high' in name but not suffix", () => {
// #given model IDs that contain 'high' but not as suffix
// given model IDs that contain 'high' but not as suffix
expect(isAlreadyHighVariant("high-performance-model")).toBe(false)
})
})
@@ -212,7 +212,7 @@ describe("think-mode switcher", () => {
describe("getThinkingConfig", () => {
describe("Already high variants", () => {
it("should return null for already-high variants", () => {
// #given already-high model variants
// given already-high model variants
expect(
getThinkingConfig("anthropic", "claude-opus-4-5-high")
).toBeNull()
@@ -221,7 +221,7 @@ describe("think-mode switcher", () => {
})
it("should return null for already-high variants via github-copilot", () => {
// #given already-high model variants via github-copilot
// given already-high model variants via github-copilot
expect(
getThinkingConfig("github-copilot", "claude-opus-4-5-high")
).toBeNull()
@@ -231,7 +231,7 @@ describe("think-mode switcher", () => {
describe("Non-thinking-capable models", () => {
it("should return null for non-thinking-capable models", () => {
// #given models that don't support thinking mode
// given models that don't support thinking mode
expect(getThinkingConfig("anthropic", "claude-2")).toBeNull()
expect(getThinkingConfig("openai", "gpt-4")).toBeNull()
expect(getThinkingConfig("google", "gemini-1")).toBeNull()
@@ -240,7 +240,7 @@ describe("think-mode switcher", () => {
describe("Unknown providers", () => {
it("should return null for unknown providers", () => {
// #given unknown provider IDs
// given unknown provider IDs
expect(getThinkingConfig("unknown-provider", "some-model")).toBeNull()
expect(getThinkingConfig("azure", "gpt-5")).toBeNull()
})
@@ -249,38 +249,38 @@ describe("think-mode switcher", () => {
describe("Direct provider configs (backwards compatibility)", () => {
it("should still work for direct anthropic provider", () => {
// #given direct anthropic provider
// given direct anthropic provider
const config = getThinkingConfig("anthropic", "claude-opus-4-5")
// #then should return anthropic thinking config
// then should return anthropic thinking config
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
expect((config?.thinking as Record<string, unknown>)?.type).toBe("enabled")
})
it("should still work for direct google provider", () => {
// #given direct google provider
// given direct google provider
const config = getThinkingConfig("google", "gemini-3-pro")
// #then should return google thinking config
// then should return google thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
})
it("should still work for amazon-bedrock provider", () => {
// #given amazon-bedrock provider with claude model
// given amazon-bedrock provider with claude model
const config = getThinkingConfig("amazon-bedrock", "claude-sonnet-4-5")
// #then should return bedrock thinking config
// then should return bedrock thinking config
expect(config).not.toBeNull()
expect(config?.reasoningConfig).toBeDefined()
})
it("should still work for google-vertex provider", () => {
// #given google-vertex provider
// given google-vertex provider
const config = getThinkingConfig("google-vertex", "gemini-3-pro")
// #then should return google-vertex thinking config
// then should return google-vertex thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
const vertexOptions = (config?.providerOptions as Record<string, unknown>)?.[
@@ -290,10 +290,10 @@ describe("think-mode switcher", () => {
})
it("should work for direct openai provider", () => {
// #given direct openai provider
// given direct openai provider
const config = getThinkingConfig("openai", "gpt-5")
// #then should return openai thinking config
// then should return openai thinking config
expect(config).not.toBeNull()
expect(config?.reasoning_effort).toBe("high")
})
@@ -326,44 +326,44 @@ describe("think-mode switcher", () => {
describe("Custom provider prefixes support", () => {
describe("getHighVariant with prefixes", () => {
it("should preserve vertex_ai/ prefix when getting high variant", () => {
// #given a model ID with vertex_ai/ prefix
// given a model ID with vertex_ai/ prefix
const variant = getHighVariant("vertex_ai/claude-sonnet-4-5")
// #then should return high variant with prefix preserved
// then should return high variant with prefix preserved
expect(variant).toBe("vertex_ai/claude-sonnet-4-5-high")
})
it("should preserve openai/ prefix when getting high variant", () => {
// #given a model ID with openai/ prefix
// given a model ID with openai/ prefix
const variant = getHighVariant("openai/gpt-5-2")
// #then should return high variant with prefix preserved
// then should return high variant with prefix preserved
expect(variant).toBe("openai/gpt-5-2-high")
})
it("should handle prefixes with dots in version numbers", () => {
// #given a model ID with prefix and dots
// given a model ID with prefix and dots
const variant = getHighVariant("vertex_ai/claude-opus-4.5")
// #then should normalize dots and preserve prefix
// then should normalize dots and preserve prefix
expect(variant).toBe("vertex_ai/claude-opus-4-5-high")
})
it("should handle multiple different prefixes", () => {
// #given various custom prefixes
// given various custom prefixes
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
expect(getHighVariant("bedrock/claude-sonnet-4-5")).toBe("bedrock/claude-sonnet-4-5-high")
expect(getHighVariant("custom-llm/gemini-3-pro")).toBe("custom-llm/gemini-3-pro-high")
})
it("should return null for prefixed models without high variant mapping", () => {
// #given prefixed model IDs without high variant mapping
// given prefixed model IDs without high variant mapping
expect(getHighVariant("vertex_ai/unknown-model")).toBeNull()
expect(getHighVariant("custom/llama-3-70b")).toBeNull()
})
it("should return null for already-high prefixed models", () => {
// #given prefixed model IDs that are already high
// given prefixed model IDs that are already high
expect(getHighVariant("vertex_ai/claude-opus-4-5-high")).toBeNull()
expect(getHighVariant("openai/gpt-5-2-high")).toBeNull()
})
@@ -371,20 +371,20 @@ describe("think-mode switcher", () => {
describe("isAlreadyHighVariant with prefixes", () => {
it("should detect -high suffix in prefixed models", () => {
// #given prefixed model IDs with -high suffix
// given prefixed model IDs with -high suffix
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5-high")).toBe(true)
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true)
})
it("should return false for prefixed base models", () => {
// #given prefixed base model IDs without -high suffix
// given prefixed base model IDs without -high suffix
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5")).toBe(false)
expect(isAlreadyHighVariant("openai/gpt-5-2")).toBe(false)
})
it("should handle prefixed models with dots", () => {
// #given prefixed model IDs with dots
// given prefixed model IDs with dots
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2")).toBe(false)
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2-high")).toBe(true)
})
@@ -392,42 +392,42 @@ describe("think-mode switcher", () => {
describe("getThinkingConfig with prefixes", () => {
it("should return null for custom providers (not in THINKING_CONFIGS)", () => {
// #given custom provider with prefixed Claude model
// given custom provider with prefixed Claude model
const config = getThinkingConfig("dia-llm", "vertex_ai/claude-sonnet-4-5")
// #then should return null (custom provider not in THINKING_CONFIGS)
// then should return null (custom provider not in THINKING_CONFIGS)
expect(config).toBeNull()
})
it("should work with prefixed models on known providers", () => {
// #given known provider (anthropic) with prefixed model
// given known provider (anthropic) with prefixed model
// This tests that the base model name is correctly extracted for capability check
const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-5")
// #then should return thinking config (base model is capable)
// then should return thinking config (base model is capable)
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
})
it("should return null for prefixed models that are already high", () => {
// #given prefixed already-high model
// given prefixed already-high model
const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-5-high")
// #then should return null
// then should return null
expect(config).toBeNull()
})
})
describe("Real-world custom provider scenario", () => {
it("should handle LLM proxy with vertex_ai prefix correctly", () => {
// #given a custom LLM proxy provider using vertex_ai/ prefix
// given a custom LLM proxy provider using vertex_ai/ prefix
const providerID = "dia-llm"
const modelID = "vertex_ai/claude-sonnet-4-5"
// #when getting high variant
// when getting high variant
const highVariant = getHighVariant(modelID)
// #then should preserve the prefix
// then should preserve the prefix
expect(highVariant).toBe("vertex_ai/claude-sonnet-4-5-high")
// #and when checking if already high
@@ -437,17 +437,17 @@ describe("think-mode switcher", () => {
// #and when getting thinking config for custom provider
const config = getThinkingConfig(providerID, modelID)
// #then should return null (custom provider, not anthropic)
// then should return null (custom provider, not anthropic)
// This prevents applying incompatible thinking configs to custom providers
expect(config).toBeNull()
})
it("should not break when switching to high variant in think mode", () => {
// #given think mode switching vertex_ai/claude model to high variant
// given think mode switching vertex_ai/claude model to high variant
const original = "vertex_ai/claude-opus-4-5"
const high = getHighVariant(original)
// #then the high variant should be valid
// then the high variant should be valid
expect(high).toBe("vertex_ai/claude-opus-4-5-high")
// #and should be recognized as already high
@@ -462,10 +462,10 @@ describe("think-mode switcher", () => {
describe("Z.AI GLM-4.7 provider support", () => {
describe("getThinkingConfig for zai-coding-plan", () => {
it("should return thinking config for glm-4.7", () => {
// #given zai-coding-plan provider with glm-4.7 model
// given zai-coding-plan provider with glm-4.7 model
const config = getThinkingConfig("zai-coding-plan", "glm-4.7")
// #then should return zai-coding-plan thinking config
// then should return zai-coding-plan thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
const zaiOptions = (config?.providerOptions as Record<string, unknown>)?.[
@@ -479,37 +479,37 @@ describe("think-mode switcher", () => {
})
it("should return thinking config for glm-4.6v (multimodal)", () => {
// #given zai-coding-plan provider with glm-4.6v model
// given zai-coding-plan provider with glm-4.6v model
const config = getThinkingConfig("zai-coding-plan", "glm-4.6v")
// #then should return zai-coding-plan thinking config
// then should return zai-coding-plan thinking config
expect(config).not.toBeNull()
expect(config?.providerOptions).toBeDefined()
})
it("should return null for non-GLM models on zai-coding-plan", () => {
// #given zai-coding-plan provider with unknown model
// given zai-coding-plan provider with unknown model
const config = getThinkingConfig("zai-coding-plan", "some-other-model")
// #then should return null
// then should return null
expect(config).toBeNull()
})
})
describe("HIGH_VARIANT_MAP for GLM", () => {
it("should NOT have high variant for glm-4.7 (thinking enabled by default)", () => {
// #given glm-4.7 model
// given glm-4.7 model
const variant = getHighVariant("glm-4.7")
// #then should return null (no high variant needed)
// then should return null (no high variant needed)
expect(variant).toBeNull()
})
it("should NOT have high variant for glm-4.6v", () => {
// #given glm-4.6v model
// given glm-4.6v model
const variant = getHighVariant("glm-4.6v")
// #then should return null
// then should return null
expect(variant).toBeNull()
})
})
+134 -134
View File
@@ -187,7 +187,7 @@ describe("todo-continuation-enforcer", () => {
})
test("should inject continuation when idle with incomplete todos", async () => {
// #given - main session with incomplete todos
// given - main session with incomplete todos
const sessionID = "main-123"
setMainSession(sessionID)
@@ -195,24 +195,24 @@ describe("todo-continuation-enforcer", () => {
backgroundManager: createMockBackgroundManager(false),
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #then - countdown toast shown
// then - countdown toast shown
await fakeTimers.advanceBy(100)
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
expect(toastCalls[0].title).toBe("Todo Continuation")
// #then - after countdown, continuation injected
// then - after countdown, continuation injected
await fakeTimers.advanceBy(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
})
test("should not inject when all todos are complete", async () => {
// #given - session with all todos complete
// given - session with all todos complete
const sessionID = "main-456"
setMainSession(sessionID)
@@ -223,19 +223,19 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(mockInput, {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation injected
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should not inject when background tasks are running", async () => {
// #given - session with running background tasks
// given - session with running background tasks
const sessionID = "main-789"
setMainSession(sessionID)
@@ -243,49 +243,49 @@ describe("todo-continuation-enforcer", () => {
backgroundManager: createMockBackgroundManager(true),
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation injected
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should not inject for non-main session", async () => {
// #given - main session set, different session goes idle
// given - main session set, different session goes idle
setMainSession("main-session")
const otherSession = "other-session"
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - non-main session goes idle
// when - non-main session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: otherSession } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation injected
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should inject for background task session (subagent)", async () => {
// #given - main session set, background task session registered
// given - main session set, background task session registered
setMainSession("main-session")
const bgTaskSession = "bg-task-session"
subagentSessions.add(bgTaskSession)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - background task session goes idle
// when - background task session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: bgTaskSession } },
})
// #then - continuation injected for background task session
// then - continuation injected for background task session
await fakeTimers.advanceBy(2500)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
@@ -294,18 +294,18 @@ describe("todo-continuation-enforcer", () => {
test("should cancel countdown on user message after grace period", async () => {
// #given - session starting countdown
// given - session starting countdown
const sessionID = "main-cancel"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - wait past grace period (500ms), then user sends message
// when - wait past grace period (500ms), then user sends message
await fakeTimers.advanceBy(600, true)
await hook.handler({
event: {
@@ -314,24 +314,24 @@ describe("todo-continuation-enforcer", () => {
},
})
// #then - wait past countdown time and verify no injection (countdown was cancelled)
// then - wait past countdown time and verify no injection (countdown was cancelled)
await fakeTimers.advanceBy(2500)
expect(promptCalls).toHaveLength(0)
})
test("should ignore user message within grace period", async () => {
// #given - session starting countdown
// given - session starting countdown
const sessionID = "main-grace"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - user message arrives within grace period (immediately)
// when - user message arrives within grace period (immediately)
await hook.handler({
event: {
type: "message.updated",
@@ -339,25 +339,25 @@ describe("todo-continuation-enforcer", () => {
},
})
// #then - countdown should continue (message was ignored)
// then - countdown should continue (message was ignored)
// wait past 2s countdown and verify injection happens
await fakeTimers.advanceBy(2500)
expect(promptCalls).toHaveLength(1)
})
test("should cancel countdown on assistant activity", async () => {
// #given - session starting countdown
// given - session starting countdown
const sessionID = "main-assistant"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - assistant starts responding
// when - assistant starts responding
await fakeTimers.advanceBy(500)
await hook.handler({
event: {
@@ -368,23 +368,23 @@ describe("todo-continuation-enforcer", () => {
await fakeTimers.advanceBy(3000)
// #then - no continuation injected (cancelled)
// then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on tool execution", async () => {
// #given - session starting countdown
// given - session starting countdown
const sessionID = "main-tool"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - tool starts executing
// when - tool starts executing
await fakeTimers.advanceBy(500)
await hook.handler({
event: { type: "tool.execute.before", properties: { sessionID } },
@@ -392,66 +392,66 @@ describe("todo-continuation-enforcer", () => {
await fakeTimers.advanceBy(3000)
// #then - no continuation injected (cancelled)
// then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection during recovery mode", async () => {
// #given - session in recovery mode
// given - session in recovery mode
const sessionID = "main-recovery"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - mark as recovering
// when - mark as recovering
hook.markRecovering(sessionID)
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation injected
// then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should inject after recovery complete", async () => {
// #given - session was in recovery, now complete
// given - session was in recovery, now complete
const sessionID = "main-recovery-done"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - mark as recovering then complete
// when - mark as recovering then complete
hook.markRecovering(sessionID)
hook.markRecoveryComplete(sessionID)
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - continuation injected
// then - continuation injected
expect(promptCalls.length).toBe(1)
})
test("should cleanup on session deleted", async () => {
// #given - session starting countdown
// given - session starting countdown
const sessionID = "main-delete"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - session is deleted during countdown
// when - session is deleted during countdown
await fakeTimers.advanceBy(500)
await hook.handler({
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
@@ -459,21 +459,21 @@ describe("todo-continuation-enforcer", () => {
await fakeTimers.advanceBy(3000)
// #then - no continuation injected (cleaned up)
// then - no continuation injected (cleaned up)
expect(promptCalls).toHaveLength(0)
})
test("should accept skipAgents option without error", async () => {
// #given - session with skipAgents configured for Prometheus
// given - session with skipAgents configured for Prometheus
const sessionID = "main-prometheus-option"
setMainSession(sessionID)
// #when - create hook with skipAgents option (should not throw)
// when - create hook with skipAgents option (should not throw)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
skipAgents: ["Prometheus (Planner)", "custom-agent"],
})
// #then - handler works without error
// then - handler works without error
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
@@ -483,46 +483,46 @@ describe("todo-continuation-enforcer", () => {
})
test("should show countdown toast updates", async () => {
// #given - session with incomplete todos
// given - session with incomplete todos
const sessionID = "main-toast"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
// then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
await fakeTimers.advanceBy(2500)
expect(toastCalls.length).toBeGreaterThanOrEqual(2)
expect(toastCalls[0].message).toContain("2s")
})
test("should not have 10s throttle between injections", async () => {
// #given - new hook instance (no prior state)
// given - new hook instance (no prior state)
const sessionID = "main-no-throttle"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - first idle cycle completes
// when - first idle cycle completes
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3500)
// #then - first injection happened
// then - first injection happened
expect(promptCalls.length).toBe(1)
// #when - immediately trigger second idle (no 10s wait needed)
// when - immediately trigger second idle (no 10s wait needed)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3500)
// #then - second injection also happened (no throttle blocking)
// then - second injection also happened (no throttle blocking)
expect(promptCalls.length).toBe(2)
}, { timeout: 15000 })
@@ -533,13 +533,13 @@ describe("todo-continuation-enforcer", () => {
test("should NOT skip for non-abort errors even if immediately before idle", async () => {
// #given - session with incomplete todos
// given - session with incomplete todos
const sessionID = "main-noabort-error"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - non-abort error occurs (e.g., network error, API error)
// when - non-abort error occurs (e.g., network error, API error)
await hook.handler({
event: {
type: "session.error",
@@ -550,14 +550,14 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - session goes idle immediately after
// when - session goes idle immediately after
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500)
// #then - continuation injected (non-abort errors don't block)
// then - continuation injected (non-abort errors don't block)
expect(promptCalls.length).toBe(1)
})
@@ -572,7 +572,7 @@ describe("todo-continuation-enforcer", () => {
// ============================================================
test("should skip injection when last assistant message has MessageAbortedError", async () => {
// #given - session where last assistant message was aborted
// given - session where last assistant message was aborted
const sessionID = "main-api-abort"
setMainSession(sessionID)
@@ -583,19 +583,19 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation (last message was aborted)
// then - no continuation (last message was aborted)
expect(promptCalls).toHaveLength(0)
})
test("should inject when last assistant message has no error", async () => {
// #given - session where last assistant message completed normally
// given - session where last assistant message completed normally
const sessionID = "main-api-no-error"
setMainSession(sessionID)
@@ -606,19 +606,19 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - continuation injected (no abort)
// then - continuation injected (no abort)
expect(promptCalls.length).toBe(1)
})
test("should inject when last message is from user (not assistant)", async () => {
// #given - session where last message is from user
// given - session where last message is from user
const sessionID = "main-api-user-last"
setMainSession(sessionID)
@@ -629,19 +629,19 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - continuation injected (last message is user, not aborted assistant)
// then - continuation injected (last message is user, not aborted assistant)
expect(promptCalls.length).toBe(1)
})
test("should skip when last assistant message has any abort-like error", async () => {
// #given - session where last assistant message has AbortError (DOMException style)
// given - session where last assistant message has AbortError (DOMException style)
const sessionID = "main-api-abort-dom"
setMainSession(sessionID)
@@ -652,19 +652,19 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation (abort error detected)
// then - no continuation (abort error detected)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection when abort detected via session.error event (event-based, primary)", async () => {
// #given - session with incomplete todos
// given - session with incomplete todos
const sessionID = "main-event-abort"
setMainSession(sessionID)
mockMessages = [
@@ -674,7 +674,7 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error event fires
// when - abort error event fires
await hook.handler({
event: {
type: "session.error",
@@ -682,19 +682,19 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - session goes idle immediately after
// when - session goes idle immediately after
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation (abort detected via event)
// then - no continuation (abort detected via event)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection when AbortError detected via session.error event", async () => {
// #given - session with incomplete todos
// given - session with incomplete todos
const sessionID = "main-event-abort-dom"
setMainSession(sessionID)
mockMessages = [
@@ -704,7 +704,7 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - AbortError event fires
// when - AbortError event fires
await hook.handler({
event: {
type: "session.error",
@@ -712,19 +712,19 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation (abort detected via event)
// then - no continuation (abort detected via event)
expect(promptCalls).toHaveLength(0)
})
test("should inject when abort flag is stale (>3s old)", async () => {
// #given - session with incomplete todos and old abort timestamp
// given - session with incomplete todos and old abort timestamp
const sessionID = "main-stale-abort"
setMainSession(sessionID)
mockMessages = [
@@ -734,7 +734,7 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error fires
// when - abort error fires
await hook.handler({
event: {
type: "session.error",
@@ -742,7 +742,7 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - wait >3s then idle fires
// when - wait >3s then idle fires
await fakeTimers.advanceBy(3100, true)
await hook.handler({
@@ -751,12 +751,12 @@ describe("todo-continuation-enforcer", () => {
await fakeTimers.advanceBy(3000)
// #then - continuation injected (abort flag is stale)
// then - continuation injected (abort flag is stale)
expect(promptCalls.length).toBeGreaterThan(0)
}, 10000)
test("should clear abort flag on user message activity", async () => {
// #given - session with abort detected
// given - session with abort detected
const sessionID = "main-clear-on-user"
setMainSession(sessionID)
mockMessages = [
@@ -766,7 +766,7 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error fires
// when - abort error fires
await hook.handler({
event: {
type: "session.error",
@@ -774,7 +774,7 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - user sends new message (clears abort flag)
// when - user sends new message (clears abort flag)
await fakeTimers.advanceBy(600)
await hook.handler({
event: {
@@ -783,19 +783,19 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - continuation injected (abort flag was cleared by user activity)
// then - continuation injected (abort flag was cleared by user activity)
expect(promptCalls.length).toBeGreaterThan(0)
})
test("should clear abort flag on assistant message activity", async () => {
// #given - session with abort detected
// given - session with abort detected
const sessionID = "main-clear-on-assistant"
setMainSession(sessionID)
mockMessages = [
@@ -805,7 +805,7 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error fires
// when - abort error fires
await hook.handler({
event: {
type: "session.error",
@@ -813,7 +813,7 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - assistant starts responding (clears abort flag)
// when - assistant starts responding (clears abort flag)
await hook.handler({
event: {
type: "message.updated",
@@ -821,19 +821,19 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - continuation injected (abort flag was cleared by assistant activity)
// then - continuation injected (abort flag was cleared by assistant activity)
expect(promptCalls.length).toBeGreaterThan(0)
})
test("should clear abort flag on tool execution", async () => {
// #given - session with abort detected
// given - session with abort detected
const sessionID = "main-clear-on-tool"
setMainSession(sessionID)
mockMessages = [
@@ -843,7 +843,7 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error fires
// when - abort error fires
await hook.handler({
event: {
type: "session.error",
@@ -851,7 +851,7 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - tool executes (clears abort flag)
// when - tool executes (clears abort flag)
await hook.handler({
event: {
type: "tool.execute.before",
@@ -859,19 +859,19 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - continuation injected (abort flag was cleared by tool execution)
// then - continuation injected (abort flag was cleared by tool execution)
expect(promptCalls.length).toBeGreaterThan(0)
})
test("should use event-based detection even when API indicates no abort (event wins)", async () => {
// #given - session with abort event but API shows no error
// given - session with abort event but API shows no error
const sessionID = "main-event-wins"
setMainSession(sessionID)
mockMessages = [
@@ -881,7 +881,7 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error event fires (but API doesn't have it yet)
// when - abort error event fires (but API doesn't have it yet)
await hook.handler({
event: {
type: "session.error",
@@ -889,19 +889,19 @@ describe("todo-continuation-enforcer", () => {
},
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation (event-based detection wins over API)
// then - no continuation (event-based detection wins over API)
expect(promptCalls).toHaveLength(0)
})
test("should use API fallback when event is missed but API shows abort", async () => {
// #given - session where event was missed but API shows abort
// given - session where event was missed but API shows abort
const sessionID = "main-api-fallback"
setMainSession(sessionID)
mockMessages = [
@@ -911,19 +911,19 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle without prior session.error event
// when - session goes idle without prior session.error event
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation (API fallback detected the abort)
// then - no continuation (API fallback detected the abort)
expect(promptCalls).toHaveLength(0)
})
test("should pass model property in prompt call (undefined when no message context)", async () => {
// #given - session with incomplete todos, no prior message context available
// given - session with incomplete todos, no prior message context available
const sessionID = "main-model-preserve"
setMainSession(sessionID)
@@ -931,21 +931,21 @@ describe("todo-continuation-enforcer", () => {
backgroundManager: createMockBackgroundManager(false),
})
// #when - session goes idle and continuation is injected
// when - session goes idle and continuation is injected
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(2500)
// #then - prompt call made, model is undefined when no context (expected behavior)
// then - prompt call made, model is undefined when no context (expected behavior)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
expect("model" in promptCalls[0]).toBe(true)
})
test("should extract model from assistant message with flat modelID/providerID", async () => {
// #given - session with assistant message that has flat modelID/providerID (OpenCode API format)
// given - session with assistant message that has flat modelID/providerID (OpenCode API format)
const sessionID = "main-assistant-model"
setMainSession(sessionID)
@@ -981,11 +981,11 @@ describe("todo-continuation-enforcer", () => {
backgroundManager: createMockBackgroundManager(false),
})
// #when - session goes idle
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500)
// #then - model should be extracted from assistant message's flat modelID/providerID
// then - model should be extracted from assistant message's flat modelID/providerID
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
})
@@ -997,7 +997,7 @@ describe("todo-continuation-enforcer", () => {
// ============================================================
test("should skip compaction agent messages when resolving agent info", async () => {
// #given - session where last message is from compaction agent but previous was Sisyphus
// given - session where last message is from compaction agent but previous was Sisyphus
const sessionID = "main-compaction-filter"
setMainSession(sessionID)
@@ -1033,17 +1033,17 @@ describe("todo-continuation-enforcer", () => {
backgroundManager: createMockBackgroundManager(false),
})
// #when - session goes idle
// when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500)
// #then - continuation uses Sisyphus (skipped compaction agent)
// then - continuation uses Sisyphus (skipped compaction agent)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].agent).toBe("sisyphus")
})
test("should skip injection when only compaction agent messages exist", async () => {
// #given - session with only compaction agent (post-compaction, no prior agent info)
// given - session with only compaction agent (post-compaction, no prior agent info)
const sessionID = "main-only-compaction"
setMainSession(sessionID)
@@ -1075,19 +1075,19 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(mockInput, {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation (compaction is in default skipAgents)
// then - no continuation (compaction is in default skipAgents)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection when prometheus agent is after compaction", async () => {
// #given - prometheus session that was compacted
// given - prometheus session that was compacted
const sessionID = "main-prometheus-compacted"
setMainSession(sessionID)
@@ -1121,19 +1121,19 @@ describe("todo-continuation-enforcer", () => {
const hook = createTodoContinuationEnforcer(mockInput, {})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents)
// then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents)
expect(promptCalls).toHaveLength(0)
})
test("should inject when agent info is undefined but skipAgents is empty", async () => {
// #given - session with no agent info but skipAgents is empty
// given - session with no agent info but skipAgents is empty
const sessionID = "main-no-agent-no-skip"
setMainSession(sessionID)
@@ -1168,19 +1168,19 @@ describe("todo-continuation-enforcer", () => {
skipAgents: [],
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - continuation injected (no agents to skip)
// then - continuation injected (no agents to skip)
expect(promptCalls.length).toBe(1)
})
test("should not inject when isContinuationStopped returns true", async () => {
// #given - session with continuation stopped
// given - session with continuation stopped
const sessionID = "main-stopped"
setMainSession(sessionID)
@@ -1188,19 +1188,19 @@ describe("todo-continuation-enforcer", () => {
isContinuationStopped: (id) => id === sessionID,
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - no continuation injected (stopped flag is true)
// then - no continuation injected (stopped flag is true)
expect(promptCalls).toHaveLength(0)
})
test("should inject when isContinuationStopped returns false", async () => {
// #given - session with continuation not stopped
// given - session with continuation not stopped
const sessionID = "main-not-stopped"
setMainSession(sessionID)
@@ -1208,38 +1208,38 @@ describe("todo-continuation-enforcer", () => {
isContinuationStopped: () => false,
})
// #when - session goes idle
// when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await fakeTimers.advanceBy(3000)
// #then - continuation injected (stopped flag is false)
// then - continuation injected (stopped flag is false)
expect(promptCalls.length).toBe(1)
})
test("should cancel all countdowns via cancelAllCountdowns", async () => {
// #given - multiple sessions with running countdowns
// given - multiple sessions with running countdowns
const session1 = "main-cancel-all-1"
const session2 = "main-cancel-all-2"
setMainSession(session1)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - first session goes idle
// when - first session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: session1 } },
})
await fakeTimers.advanceBy(500)
// #when - cancel all countdowns
// when - cancel all countdowns
hook.cancelAllCountdowns()
// #when - advance past countdown time
// when - advance past countdown time
await fakeTimers.advanceBy(3000)
// #then - no continuation injected (all countdowns cancelled)
// then - no continuation injected (all countdowns cancelled)
expect(promptCalls).toHaveLength(0)
})
})