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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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("")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: "" }
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user