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:
@@ -4,87 +4,87 @@ import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
test("should return model-specific limit when modelConcurrency is set", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 5 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return provider limit when providerConcurrency is set for model provider", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "google/gemini-3-pro": 5 },
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return default limit when defaultConcurrency is set", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
defaultConcurrency: 2
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(2)
|
||||
})
|
||||
|
||||
test("should return default 5 when no config provided", () => {
|
||||
// #given
|
||||
// given
|
||||
const manager = new ConcurrencyManager()
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return default 5 when config exists but no concurrency settings", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should prioritize model-specific over provider-specific over default", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 10 },
|
||||
providerConcurrency: { anthropic: 5 },
|
||||
@@ -92,68 +92,68 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-5")
|
||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(modelLimit).toBe(10)
|
||||
expect(providerLimit).toBe(5)
|
||||
expect(defaultLimit).toBe(2)
|
||||
})
|
||||
|
||||
test("should handle models without provider part", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { "custom-model": 4 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("custom-model")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(4)
|
||||
})
|
||||
|
||||
test("should return Infinity when defaultConcurrency is 0", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 0 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("any-model")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when providerConcurrency is 0", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when modelConcurrency is 0", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
})
|
||||
@@ -162,69 +162,69 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
let manager: ConcurrencyManager
|
||||
|
||||
beforeEach(() => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
manager = new ConcurrencyManager(config)
|
||||
})
|
||||
|
||||
test("should allow acquiring up to limit", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - both resolved without waiting, count should be 2
|
||||
// then - both resolved without waiting, count should be 2
|
||||
expect(manager.getCount("model-a")).toBe(2)
|
||||
})
|
||||
|
||||
test("should allow acquires up to default limit of 5", async () => {
|
||||
// #given - no config = default limit of 5
|
||||
// given - no config = default limit of 5
|
||||
|
||||
// #when
|
||||
// when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - all 5 resolved, count should be 5
|
||||
// then - all 5 resolved, count should be 5
|
||||
expect(manager.getCount("model-a")).toBe(5)
|
||||
})
|
||||
|
||||
test("should queue when limit reached", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
// when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("model-a").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should still be waiting
|
||||
// then - should still be waiting
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// #when - release
|
||||
// when - release
|
||||
manager.release("model-a")
|
||||
await waitPromise
|
||||
|
||||
// #then - now resolved
|
||||
// then - now resolved
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
test("should queue multiple tasks and process in order", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
// when
|
||||
const order: string[] = []
|
||||
const task1 = manager.acquire("model-a").then(() => { order.push("1") })
|
||||
const task2 = manager.acquire("model-a").then(() => { order.push("2") })
|
||||
@@ -233,10 +233,10 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - none resolved yet
|
||||
// then - none resolved yet
|
||||
expect(order).toEqual([])
|
||||
|
||||
// #when - release one at a time
|
||||
// when - release one at a time
|
||||
manager.release("model-a")
|
||||
await task1
|
||||
expect(order).toEqual(["1"])
|
||||
@@ -251,63 +251,63 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
})
|
||||
|
||||
test("should handle independent models separately", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when - acquire different model
|
||||
// when - acquire different model
|
||||
const resolved = await Promise.race([
|
||||
manager.acquire("model-b").then(() => "resolved"),
|
||||
Promise.resolve("timeout").then(() => "timeout")
|
||||
])
|
||||
|
||||
// #then - different model should resolve immediately
|
||||
// then - different model should resolve immediately
|
||||
expect(resolved).toBe("resolved")
|
||||
})
|
||||
|
||||
test("should allow re-acquiring after release", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
await manager.acquire("model-a")
|
||||
manager.release("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - count should be 1 after re-acquiring
|
||||
// then - count should be 1 after re-acquiring
|
||||
expect(manager.getCount("model-a")).toBe(1)
|
||||
})
|
||||
|
||||
test("should handle release when no acquire", () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when - release without acquire
|
||||
// when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - count should be 0 (no negative count)
|
||||
// then - count should be 0 (no negative count)
|
||||
expect(manager.getCount("model-a")).toBe(0)
|
||||
})
|
||||
|
||||
test("should handle release when no prior acquire", () => {
|
||||
// #given - default config
|
||||
// given - default config
|
||||
|
||||
// #when - release without acquire
|
||||
// when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - count should be 0 (no negative count)
|
||||
// then - count should be 0 (no negative count)
|
||||
expect(manager.getCount("model-a")).toBe(0)
|
||||
})
|
||||
|
||||
test("should handle multiple acquires and releases correctly", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
@@ -320,12 +320,12 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
// Should be able to acquire again
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - count should be 1 after re-acquiring
|
||||
// then - count should be 1 after re-acquiring
|
||||
expect(manager.getCount("model-a")).toBe(1)
|
||||
})
|
||||
|
||||
test("should use model-specific limit for acquire", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 2 },
|
||||
defaultConcurrency: 5
|
||||
@@ -334,14 +334,14 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #when
|
||||
// when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("anthropic/claude-sonnet-4-5").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should be waiting (model-specific limit is 2)
|
||||
// then - should be waiting (model-specific limit is 2)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// Cleanup
|
||||
@@ -352,7 +352,7 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
|
||||
describe("ConcurrencyManager.cleanup", () => {
|
||||
test("cancelWaiters should reject all pending acquires", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
@@ -362,17 +362,17 @@ describe("ConcurrencyManager.cleanup", () => {
|
||||
const p1 = manager.acquire("model-a").catch(e => errors.push(e))
|
||||
const p2 = manager.acquire("model-a").catch(e => errors.push(e))
|
||||
|
||||
// #when
|
||||
// when
|
||||
manager.cancelWaiters("model-a")
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(errors.length).toBe(2)
|
||||
expect(errors[0].message).toContain("cancelled")
|
||||
})
|
||||
|
||||
test("clear should cancel all models and reset state", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
@@ -382,22 +382,22 @@ describe("ConcurrencyManager.cleanup", () => {
|
||||
const p1 = manager.acquire("model-a").catch(e => errors.push(e))
|
||||
const p2 = manager.acquire("model-b").catch(e => errors.push(e))
|
||||
|
||||
// #when
|
||||
// when
|
||||
manager.clear()
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(errors.length).toBe(2)
|
||||
expect(manager.getCount("model-a")).toBe(0)
|
||||
expect(manager.getCount("model-b")).toBe(0)
|
||||
})
|
||||
|
||||
test("getCount and getQueueLength should return correct values", async () => {
|
||||
// #given
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
// when
|
||||
await manager.acquire("model-a")
|
||||
expect(manager.getCount("model-a")).toBe(1)
|
||||
expect(manager.getQueueLength("model-a")).toBe(0)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
|
||||
export const TASK_TTL_MS = 30 * 60 * 1000
|
||||
export const MIN_STABILITY_TIME_MS = 10 * 1000
|
||||
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
|
||||
export const MIN_RUNTIME_BEFORE_STALE_MS = 30_000
|
||||
export const MIN_IDLE_TIME_MS = 5000
|
||||
export const POLLING_INTERVAL_MS = 2000
|
||||
export const TASK_CLEANUP_DELAY_MS = 5 * 60 * 1000
|
||||
export const TMUX_CALLBACK_DELAY_MS = 200
|
||||
|
||||
export type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
|
||||
|
||||
export type OpencodeClient = PluginInput["client"]
|
||||
|
||||
export interface MessagePartInfo {
|
||||
sessionID?: string
|
||||
type?: string
|
||||
tool?: string
|
||||
}
|
||||
|
||||
export interface EventProperties {
|
||||
sessionID?: string
|
||||
info?: { id?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BackgroundEvent {
|
||||
type: string
|
||||
properties?: EventProperties
|
||||
}
|
||||
|
||||
export interface Todo {
|
||||
content: string
|
||||
status: string
|
||||
priority: string
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
task: BackgroundTask
|
||||
input: LaunchInput
|
||||
}
|
||||
|
||||
export interface SubagentSessionCreatedEvent {
|
||||
sessionID: string
|
||||
parentID: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager } from "./manager"
|
||||
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
|
||||
export { ConcurrencyManager } from "./concurrency"
|
||||
export { TaskStateManager } from "./state"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { OpencodeClient, Todo } from "./constants"
|
||||
import { TASK_CLEANUP_DELAY_MS } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-injector"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { TaskStateManager } from "./state"
|
||||
|
||||
export interface ResultHandlerContext {
|
||||
client: OpencodeClient
|
||||
concurrencyManager: ConcurrencyManager
|
||||
state: TaskStateManager
|
||||
}
|
||||
|
||||
export async function checkSessionTodos(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.todo({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
const todos = (response.data ?? response) as Todo[]
|
||||
if (!todos || todos.length === 0) return false
|
||||
|
||||
const incomplete = todos.filter(
|
||||
(t) => t.status !== "completed" && t.status !== "cancelled"
|
||||
)
|
||||
return incomplete.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateSessionHasOutput(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messages = response.data ?? []
|
||||
|
||||
const hasAssistantOrToolMessage = messages.some(
|
||||
(m: { info?: { role?: string } }) =>
|
||||
m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
|
||||
if (!hasAssistantOrToolMessage) {
|
||||
log("[background-agent] No assistant/tool messages found in session:", sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasContent = messages.some((m: any) => {
|
||||
if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false
|
||||
const parts = m.parts ?? []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return parts.some((p: any) =>
|
||||
(p.type === "text" && p.text && p.text.trim().length > 0) ||
|
||||
(p.type === "reasoning" && p.text && p.text.trim().length > 0) ||
|
||||
p.type === "tool" ||
|
||||
(p.type === "tool_result" && p.content &&
|
||||
(typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0))
|
||||
)
|
||||
})
|
||||
|
||||
if (!hasContent) {
|
||||
log("[background-agent] Messages exist but no content found in session:", sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log("[background-agent] Error validating session output:", error)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDuration(start: Date, end?: Date): string {
|
||||
const duration = (end ?? new Date()).getTime() - start.getTime()
|
||||
const seconds = Math.floor(duration / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
export function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function tryCompleteTask(
|
||||
task: BackgroundTask,
|
||||
source: string,
|
||||
ctx: ResultHandlerContext
|
||||
): Promise<boolean> {
|
||||
const { concurrencyManager, state } = ctx
|
||||
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task already completed, skipping:", { taskId: task.id, status: task.status, source })
|
||||
return false
|
||||
}
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
state.markForNotification(task)
|
||||
|
||||
try {
|
||||
await notifyParentSession(task, ctx)
|
||||
log(`[background-agent] Task completed via ${source}:`, task.id)
|
||||
} catch (err) {
|
||||
log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function notifyParentSession(
|
||||
task: BackgroundTask,
|
||||
ctx: ResultHandlerContext
|
||||
): Promise<void> {
|
||||
const { client, state } = ctx
|
||||
const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
|
||||
|
||||
log("[background-agent] notifyParentSession called for task:", task.id)
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.showCompletionToast({
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
const pendingSet = state.pendingByParent.get(task.parentSessionID)
|
||||
if (pendingSet) {
|
||||
pendingSet.delete(task.id)
|
||||
if (pendingSet.size === 0) {
|
||||
state.pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
const allComplete = !pendingSet || pendingSet.size === 0
|
||||
const remainingCount = pendingSet?.size ?? 0
|
||||
|
||||
const statusText = task.status === "completed" ? "COMPLETED" : "CANCELLED"
|
||||
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
|
||||
|
||||
let notification: string
|
||||
if (allComplete) {
|
||||
const completedTasks = Array.from(state.tasks.values())
|
||||
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending")
|
||||
.map(t => `- \`${t.id}\`: ${t.description}`)
|
||||
.join("\n")
|
||||
|
||||
notification = `<system-reminder>
|
||||
[ALL BACKGROUND TASKS COMPLETE]
|
||||
|
||||
**Completed:**
|
||||
${completedTasks || `- \`${task.id}\`: ${task.description}`}
|
||||
|
||||
Use \`background_output(task_id="<id>")\` to retrieve each result.
|
||||
</system-reminder>`
|
||||
} else {
|
||||
notification = `<system-reminder>
|
||||
[BACKGROUND TASK ${statusText}]
|
||||
**ID:** \`${task.id}\`
|
||||
**Description:** ${task.description}
|
||||
**Duration:** ${duration}${errorInfo}
|
||||
|
||||
**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete.
|
||||
Do NOT poll - continue productive work.
|
||||
|
||||
Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready.
|
||||
</system-reminder>`
|
||||
}
|
||||
|
||||
let agent: string | undefined = task.parentAgent
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
|
||||
try {
|
||||
const messagesResp = await client.session.messages({ path: { id: task.parentSessionID } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
||||
agent = info.agent ?? task.parentAgent
|
||||
model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agent = currentMessage?.agent ?? task.parentAgent
|
||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
log("[background-agent] notifyParentSession context:", {
|
||||
taskId: task.id,
|
||||
resolvedAgent: agent,
|
||||
resolvedModel: model,
|
||||
})
|
||||
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: task.parentSessionID },
|
||||
body: {
|
||||
noReply: !allComplete,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: notification }],
|
||||
},
|
||||
})
|
||||
log("[background-agent] Sent notification to parent session:", {
|
||||
taskId: task.id,
|
||||
allComplete,
|
||||
noReply: !allComplete,
|
||||
})
|
||||
} catch (error) {
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
|
||||
const taskId = task.id
|
||||
const timer = setTimeout(() => {
|
||||
state.completionTimers.delete(taskId)
|
||||
if (state.tasks.has(taskId)) {
|
||||
state.clearNotificationsForTask(taskId)
|
||||
state.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
}, TASK_CLEANUP_DELAY_MS)
|
||||
state.setCompletionTimer(taskId, timer)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
||||
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
export interface SpawnerContext {
|
||||
client: OpencodeClient
|
||||
directory: string
|
||||
concurrencyManager: ConcurrencyManager
|
||||
tmuxEnabled: boolean
|
||||
onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
onTaskError: (task: BackgroundTask, error: Error) => void
|
||||
}
|
||||
|
||||
export function createTask(input: LaunchInput): BackgroundTask {
|
||||
return {
|
||||
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
agent: input.agent,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: input.parentMessageID,
|
||||
parentModel: input.parentModel,
|
||||
parentAgent: input.parentAgent,
|
||||
model: input.model,
|
||||
}
|
||||
}
|
||||
|
||||
export async function startTask(
|
||||
item: QueueItem,
|
||||
ctx: SpawnerContext
|
||||
): Promise<void> {
|
||||
const { task, input } = item
|
||||
const { client, directory, concurrencyManager, tmuxEnabled, onSubagentSessionCreated, onTaskError } = ctx
|
||||
|
||||
log("[background-agent] Starting task:", {
|
||||
taskId: task.id,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
})
|
||||
|
||||
const concurrencyKey = input.model
|
||||
? `${input.model.providerID}/${input.model.modelID}`
|
||||
: input.agent
|
||||
|
||||
const parentSession = await client.session.get({
|
||||
path: { id: input.parentSessionID },
|
||||
}).catch((err) => {
|
||||
log(`[background-agent] Failed to get parent session: ${err}`)
|
||||
return null
|
||||
})
|
||||
const parentDirectory = parentSession?.data?.directory ?? directory
|
||||
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
|
||||
|
||||
const createResult = await client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
}).catch((error) => {
|
||||
concurrencyManager.release(concurrencyKey)
|
||||
throw error
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
concurrencyManager.release(concurrencyKey)
|
||||
throw new Error(`Failed to create background session: ${createResult.error}`)
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
subagentSessions.add(sessionID)
|
||||
|
||||
log("[background-agent] tmux callback check", {
|
||||
hasCallback: !!onSubagentSessionCreated,
|
||||
tmuxEnabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) {
|
||||
log("[background-agent] Invoking tmux callback NOW", { sessionID })
|
||||
await onSubagentSessionCreated({
|
||||
sessionID,
|
||||
parentID: input.parentSessionID,
|
||||
title: input.description,
|
||||
}).catch((err) => {
|
||||
log("[background-agent] Failed to spawn tmux pane:", err)
|
||||
})
|
||||
log("[background-agent] tmux callback completed, waiting")
|
||||
await new Promise(r => setTimeout(r, TMUX_CALLBACK_DELAY_MS))
|
||||
} else {
|
||||
log("[background-agent] SKIP tmux callback - conditions not met")
|
||||
}
|
||||
|
||||
task.status = "running"
|
||||
task.startedAt = new Date()
|
||||
task.sessionID = sessionID
|
||||
task.progress = {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
task.concurrencyKey = concurrencyKey
|
||||
task.concurrencyGroup = concurrencyKey
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.updateTask(task.id, "running")
|
||||
}
|
||||
|
||||
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
|
||||
sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
hasSkillContent: !!input.skillContent,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const launchModel = input.model
|
||||
? { providerID: input.model.providerID, modelID: input.model.modelID }
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
promptWithModelSuggestionRetry(client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
system: input.skillContent,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] promptAsync error:", error)
|
||||
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
}
|
||||
|
||||
export async function resumeTask(
|
||||
task: BackgroundTask,
|
||||
input: ResumeInput,
|
||||
ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "onTaskError">
|
||||
): Promise<void> {
|
||||
const { client, concurrencyManager, onTaskError } = ctx
|
||||
|
||||
if (!task.sessionID) {
|
||||
throw new Error(`Task has no sessionID: ${task.id}`)
|
||||
}
|
||||
|
||||
if (task.status === "running") {
|
||||
log("[background-agent] Resume skipped - task already running:", {
|
||||
taskId: task.id,
|
||||
sessionID: task.sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const concurrencyKey = task.concurrencyGroup ?? task.agent
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
task.concurrencyKey = concurrencyKey
|
||||
task.concurrencyGroup = concurrencyKey
|
||||
|
||||
task.status = "running"
|
||||
task.completedAt = undefined
|
||||
task.error = undefined
|
||||
task.parentSessionID = input.parentSessionID
|
||||
task.parentMessageID = input.parentMessageID
|
||||
task.parentModel = input.parentModel
|
||||
task.parentAgent = input.parentAgent
|
||||
task.startedAt = new Date()
|
||||
|
||||
task.progress = {
|
||||
toolCalls: task.progress?.toolCalls ?? 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
|
||||
subagentSessions.add(task.sessionID)
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: task.id,
|
||||
description: task.description,
|
||||
agent: task.agent,
|
||||
isBackground: true,
|
||||
})
|
||||
}
|
||||
|
||||
log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionID })
|
||||
|
||||
log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
|
||||
sessionID: task.sessionID,
|
||||
agent: task.agent,
|
||||
model: task.model,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
const resumeModel = task.model
|
||||
? { providerID: task.model.providerID, modelID: task.model.modelID }
|
||||
: undefined
|
||||
const resumeVariant = task.model?.variant
|
||||
|
||||
client.session.prompt({
|
||||
path: { id: task.sessionID },
|
||||
body: {
|
||||
agent: task.agent,
|
||||
...(resumeModel ? { model: resumeModel } : {}),
|
||||
...(resumeVariant ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
...getAgentToolRestrictions(task.agent),
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] resume prompt error:", error)
|
||||
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
import type { QueueItem } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
|
||||
export class TaskStateManager {
|
||||
readonly tasks: Map<string, BackgroundTask> = new Map()
|
||||
readonly notifications: Map<string, BackgroundTask[]> = new Map()
|
||||
readonly pendingByParent: Map<string, Set<string>> = new Map()
|
||||
readonly queuesByKey: Map<string, QueueItem[]> = new Map()
|
||||
readonly processingKeys: Set<string> = new Set()
|
||||
readonly completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
|
||||
getTask(id: string): BackgroundTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
findBySession(sessionID: string): BackgroundTask | undefined {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.sessionID === sessionID) {
|
||||
return task
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
||||
const result: BackgroundTask[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.parentSessionID === sessionID) {
|
||||
result.push(task)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
|
||||
const result: BackgroundTask[] = []
|
||||
const directChildren = this.getTasksByParentSession(sessionID)
|
||||
|
||||
for (const child of directChildren) {
|
||||
result.push(child)
|
||||
if (child.sessionID) {
|
||||
const descendants = this.getAllDescendantTasks(child.sessionID)
|
||||
result.push(...descendants)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
getRunningTasks(): BackgroundTask[] {
|
||||
return Array.from(this.tasks.values()).filter(t => t.status === "running")
|
||||
}
|
||||
|
||||
getCompletedTasks(): BackgroundTask[] {
|
||||
return Array.from(this.tasks.values()).filter(t => t.status !== "running")
|
||||
}
|
||||
|
||||
hasRunningTasks(): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === "running") return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
getConcurrencyKeyFromInput(input: LaunchInput): string {
|
||||
if (input.model) {
|
||||
return `${input.model.providerID}/${input.model.modelID}`
|
||||
}
|
||||
return input.agent
|
||||
}
|
||||
|
||||
getConcurrencyKeyFromTask(task: BackgroundTask): string {
|
||||
if (task.model) {
|
||||
return `${task.model.providerID}/${task.model.modelID}`
|
||||
}
|
||||
return task.agent
|
||||
}
|
||||
|
||||
addTask(task: BackgroundTask): void {
|
||||
this.tasks.set(task.id, task)
|
||||
}
|
||||
|
||||
removeTask(taskId: string): void {
|
||||
const task = this.tasks.get(taskId)
|
||||
if (task?.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
this.tasks.delete(taskId)
|
||||
}
|
||||
|
||||
trackPendingTask(parentSessionID: string, taskId: string): void {
|
||||
const pending = this.pendingByParent.get(parentSessionID) ?? new Set()
|
||||
pending.add(taskId)
|
||||
this.pendingByParent.set(parentSessionID, pending)
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task: BackgroundTask): void {
|
||||
if (!task.parentSessionID) return
|
||||
const pending = this.pendingByParent.get(task.parentSessionID)
|
||||
if (pending) {
|
||||
pending.delete(task.id)
|
||||
if (pending.size === 0) {
|
||||
this.pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markForNotification(task: BackgroundTask): void {
|
||||
const queue = this.notifications.get(task.parentSessionID) ?? []
|
||||
queue.push(task)
|
||||
this.notifications.set(task.parentSessionID, queue)
|
||||
}
|
||||
|
||||
getPendingNotifications(sessionID: string): BackgroundTask[] {
|
||||
return this.notifications.get(sessionID) ?? []
|
||||
}
|
||||
|
||||
clearNotifications(sessionID: string): void {
|
||||
this.notifications.delete(sessionID)
|
||||
}
|
||||
|
||||
clearNotificationsForTask(taskId: string): void {
|
||||
for (const [sessionID, tasks] of this.notifications.entries()) {
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
if (filtered.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else {
|
||||
this.notifications.set(sessionID, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addToQueue(key: string, item: QueueItem): void {
|
||||
const queue = this.queuesByKey.get(key) ?? []
|
||||
queue.push(item)
|
||||
this.queuesByKey.set(key, queue)
|
||||
}
|
||||
|
||||
getQueue(key: string): QueueItem[] | undefined {
|
||||
return this.queuesByKey.get(key)
|
||||
}
|
||||
|
||||
removeFromQueue(key: string, taskId: string): boolean {
|
||||
const queue = this.queuesByKey.get(key)
|
||||
if (!queue) return false
|
||||
|
||||
const index = queue.findIndex(item => item.task.id === taskId)
|
||||
if (index === -1) return false
|
||||
|
||||
queue.splice(index, 1)
|
||||
if (queue.length === 0) {
|
||||
this.queuesByKey.delete(key)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setCompletionTimer(taskId: string, timer: ReturnType<typeof setTimeout>): void {
|
||||
this.completionTimers.set(taskId, timer)
|
||||
}
|
||||
|
||||
clearCompletionTimer(taskId: string): void {
|
||||
const timer = this.completionTimers.get(taskId)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
this.completionTimers.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
clearAllCompletionTimers(): void {
|
||||
for (const timer of this.completionTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.completionTimers.clear()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.clearAllCompletionTimers()
|
||||
this.tasks.clear()
|
||||
this.notifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
this.queuesByKey.clear()
|
||||
this.processingKeys.clear()
|
||||
}
|
||||
|
||||
cancelPendingTask(taskId: string): boolean {
|
||||
const task = this.tasks.get(taskId)
|
||||
if (!task || task.status !== "pending") {
|
||||
return false
|
||||
}
|
||||
|
||||
const key = this.getConcurrencyKeyFromTask(task)
|
||||
this.removeFromQueue(key, taskId)
|
||||
|
||||
task.status = "cancelled"
|
||||
task.completedAt = new Date()
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
|
||||
log("[background-agent] Cancelled pending task:", { taskId, key })
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -36,15 +36,15 @@ describe("boulder-state", () => {
|
||||
|
||||
describe("readBoulderState", () => {
|
||||
test("should return null when no boulder.json exists", () => {
|
||||
// #given - no boulder.json file
|
||||
// #when
|
||||
// given - no boulder.json file
|
||||
// when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should read valid boulder state", () => {
|
||||
// #given - valid boulder.json
|
||||
// given - valid boulder.json
|
||||
const state: BoulderState = {
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
@@ -53,10 +53,10 @@ describe("boulder-state", () => {
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.active_plan).toBe("/path/to/plan.md")
|
||||
expect(result?.session_ids).toEqual(["session-1", "session-2"])
|
||||
@@ -66,7 +66,7 @@ describe("boulder-state", () => {
|
||||
|
||||
describe("writeBoulderState", () => {
|
||||
test("should write state and create .sisyphus directory if needed", () => {
|
||||
// #given - state to write
|
||||
// given - state to write
|
||||
const state: BoulderState = {
|
||||
active_plan: "/test/plan.md",
|
||||
started_at: "2026-01-02T12:00:00Z",
|
||||
@@ -74,11 +74,11 @@ describe("boulder-state", () => {
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const success = writeBoulderState(TEST_DIR, state)
|
||||
const readBack = readBoulderState(TEST_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(success).toBe(true)
|
||||
expect(readBack).not.toBeNull()
|
||||
expect(readBack?.active_plan).toBe("/test/plan.md")
|
||||
@@ -87,7 +87,7 @@ describe("boulder-state", () => {
|
||||
|
||||
describe("appendSessionId", () => {
|
||||
test("should append new session id to existing state", () => {
|
||||
// #given - existing state with one session
|
||||
// given - existing state with one session
|
||||
const state: BoulderState = {
|
||||
active_plan: "/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
@@ -96,16 +96,16 @@ describe("boulder-state", () => {
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = appendSessionId(TEST_DIR, "session-2")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.session_ids).toEqual(["session-1", "session-2"])
|
||||
})
|
||||
|
||||
test("should not duplicate existing session id", () => {
|
||||
// #given - state with session-1 already
|
||||
// given - state with session-1 already
|
||||
const state: BoulderState = {
|
||||
active_plan: "/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
@@ -114,26 +114,26 @@ describe("boulder-state", () => {
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// #when
|
||||
// when
|
||||
appendSessionId(TEST_DIR, "session-1")
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result?.session_ids).toEqual(["session-1"])
|
||||
})
|
||||
|
||||
test("should return null when no state exists", () => {
|
||||
// #given - no boulder.json
|
||||
// #when
|
||||
// given - no boulder.json
|
||||
// when
|
||||
const result = appendSessionId(TEST_DIR, "new-session")
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearBoulderState", () => {
|
||||
test("should remove boulder.json", () => {
|
||||
// #given - existing state
|
||||
// given - existing state
|
||||
const state: BoulderState = {
|
||||
active_plan: "/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
@@ -142,27 +142,27 @@ describe("boulder-state", () => {
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const success = clearBoulderState(TEST_DIR)
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(success).toBe(true)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should succeed even when no file exists", () => {
|
||||
// #given - no boulder.json
|
||||
// #when
|
||||
// given - no boulder.json
|
||||
// when
|
||||
const success = clearBoulderState(TEST_DIR)
|
||||
// #then
|
||||
// then
|
||||
expect(success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPlanProgress", () => {
|
||||
test("should count completed and uncompleted checkboxes", () => {
|
||||
// #given - plan file with checkboxes
|
||||
// given - plan file with checkboxes
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [ ] Task 1
|
||||
@@ -171,50 +171,50 @@ describe("boulder-state", () => {
|
||||
- [X] Task 4
|
||||
`)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(progress.total).toBe(4)
|
||||
expect(progress.completed).toBe(2)
|
||||
expect(progress.isComplete).toBe(false)
|
||||
})
|
||||
|
||||
test("should return isComplete true when all checked", () => {
|
||||
// #given - all tasks completed
|
||||
// given - all tasks completed
|
||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
||||
writeFileSync(planPath, `# Plan
|
||||
- [x] Task 1
|
||||
- [X] Task 2
|
||||
`)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(progress.total).toBe(2)
|
||||
expect(progress.completed).toBe(2)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("should return isComplete true for empty plan", () => {
|
||||
// #given - plan with no checkboxes
|
||||
// given - plan with no checkboxes
|
||||
const planPath = join(TEST_DIR, "empty-plan.md")
|
||||
writeFileSync(planPath, "# Plan\nNo tasks here")
|
||||
|
||||
// #when
|
||||
// when
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(progress.total).toBe(0)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle non-existent file", () => {
|
||||
// #given - non-existent file
|
||||
// #when
|
||||
// given - non-existent file
|
||||
// when
|
||||
const progress = getPlanProgress("/non/existent/file.md")
|
||||
// #then
|
||||
// then
|
||||
expect(progress.total).toBe(0)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
@@ -222,25 +222,25 @@ describe("boulder-state", () => {
|
||||
|
||||
describe("getPlanName", () => {
|
||||
test("should extract plan name from path", () => {
|
||||
// #given
|
||||
// given
|
||||
const path = "/home/user/.sisyphus/plans/project/my-feature.md"
|
||||
// #when
|
||||
// when
|
||||
const name = getPlanName(path)
|
||||
// #then
|
||||
// then
|
||||
expect(name).toBe("my-feature")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createBoulderState", () => {
|
||||
test("should create state with correct fields", () => {
|
||||
// #given
|
||||
// given
|
||||
const planPath = "/path/to/auth-refactor.md"
|
||||
const sessionId = "ses-abc123"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const state = createBoulderState(planPath, sessionId)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(state.active_plan).toBe(planPath)
|
||||
expect(state.session_ids).toEqual([sessionId])
|
||||
expect(state.plan_name).toBe("auth-refactor")
|
||||
|
||||
@@ -3,21 +3,21 @@ import { STOP_CONTINUATION_TEMPLATE } from "./stop-continuation"
|
||||
|
||||
describe("stop-continuation template", () => {
|
||||
test("should export a non-empty template string", () => {
|
||||
// #given - the stop-continuation template
|
||||
// given - the stop-continuation template
|
||||
|
||||
// #when - we access the template
|
||||
// when - we access the template
|
||||
|
||||
// #then - it should be a non-empty string
|
||||
// then - it should be a non-empty string
|
||||
expect(typeof STOP_CONTINUATION_TEMPLATE).toBe("string")
|
||||
expect(STOP_CONTINUATION_TEMPLATE.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("should describe the stop-continuation behavior", () => {
|
||||
// #given - the stop-continuation template
|
||||
// given - the stop-continuation template
|
||||
|
||||
// #when - we check the content
|
||||
// when - we check the content
|
||||
|
||||
// #then - it should mention key behaviors
|
||||
// then - it should mention key behaviors
|
||||
expect(STOP_CONTINUATION_TEMPLATE).toContain("todo-continuation-enforcer")
|
||||
expect(STOP_CONTINUATION_TEMPLATE).toContain("Ralph Loop")
|
||||
expect(STOP_CONTINUATION_TEMPLATE).toContain("boulder state")
|
||||
|
||||
@@ -3,12 +3,12 @@ import { createBuiltinSkills } from "./skills"
|
||||
|
||||
describe("createBuiltinSkills", () => {
|
||||
test("returns playwright skill by default", () => {
|
||||
// #given - no options (default)
|
||||
// given - no options (default)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const skills = createBuiltinSkills()
|
||||
|
||||
// #then
|
||||
// then
|
||||
const browserSkill = skills.find((s) => s.name === "playwright")
|
||||
expect(browserSkill).toBeDefined()
|
||||
expect(browserSkill!.description).toContain("browser")
|
||||
@@ -16,13 +16,13 @@ describe("createBuiltinSkills", () => {
|
||||
})
|
||||
|
||||
test("returns playwright skill when browserProvider is 'playwright'", () => {
|
||||
// #given
|
||||
// given
|
||||
const options = { browserProvider: "playwright" as const }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const skills = createBuiltinSkills(options)
|
||||
|
||||
// #then
|
||||
// then
|
||||
const playwrightSkill = skills.find((s) => s.name === "playwright")
|
||||
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
||||
expect(playwrightSkill).toBeDefined()
|
||||
@@ -30,13 +30,13 @@ describe("createBuiltinSkills", () => {
|
||||
})
|
||||
|
||||
test("returns agent-browser skill when browserProvider is 'agent-browser'", () => {
|
||||
// #given
|
||||
// given
|
||||
const options = { browserProvider: "agent-browser" as const }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const skills = createBuiltinSkills(options)
|
||||
|
||||
// #then
|
||||
// then
|
||||
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
||||
const playwrightSkill = skills.find((s) => s.name === "playwright")
|
||||
expect(agentBrowserSkill).toBeDefined()
|
||||
@@ -47,14 +47,14 @@ describe("createBuiltinSkills", () => {
|
||||
})
|
||||
|
||||
test("agent-browser skill template is inlined (not loaded from file)", () => {
|
||||
// #given
|
||||
// given
|
||||
const options = { browserProvider: "agent-browser" as const }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const skills = createBuiltinSkills(options)
|
||||
const agentBrowserSkill = skills.find((s) => s.name === "agent-browser")
|
||||
|
||||
// #then - template should contain substantial content (inlined, not fallback)
|
||||
// then - template should contain substantial content (inlined, not fallback)
|
||||
expect(agentBrowserSkill!.template).toContain("## Quick start")
|
||||
expect(agentBrowserSkill!.template).toContain("## Commands")
|
||||
expect(agentBrowserSkill!.template).toContain("agent-browser open")
|
||||
@@ -62,13 +62,13 @@ describe("createBuiltinSkills", () => {
|
||||
})
|
||||
|
||||
test("always includes frontend-ui-ux and git-master skills", () => {
|
||||
// #given - both provider options
|
||||
// given - both provider options
|
||||
|
||||
// #when
|
||||
// when
|
||||
const defaultSkills = createBuiltinSkills()
|
||||
const agentBrowserSkills = createBuiltinSkills({ browserProvider: "agent-browser" })
|
||||
|
||||
// #then
|
||||
// then
|
||||
for (const skills of [defaultSkills, agentBrowserSkills]) {
|
||||
expect(skills.find((s) => s.name === "frontend-ui-ux")).toBeDefined()
|
||||
expect(skills.find((s) => s.name === "git-master")).toBeDefined()
|
||||
@@ -76,13 +76,13 @@ describe("createBuiltinSkills", () => {
|
||||
})
|
||||
|
||||
test("returns exactly 4 skills regardless of provider", () => {
|
||||
// #given
|
||||
// given
|
||||
|
||||
// #when
|
||||
// when
|
||||
const defaultSkills = createBuiltinSkills()
|
||||
const agentBrowserSkills = createBuiltinSkills({ browserProvider: "agent-browser" })
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(defaultSkills).toHaveLength(4)
|
||||
expect(agentBrowserSkills).toHaveLength(4)
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
import type { BuiltinSkill } from "../types"
|
||||
|
||||
export const devBrowserSkill: BuiltinSkill = {
|
||||
name: "dev-browser",
|
||||
description:
|
||||
"Browser automation with persistent page state. Use when users ask to navigate websites, fill forms, take screenshots, extract web data, test web apps, or automate browser workflows. Trigger phrases include 'go to [url]', 'click on', 'fill out the form', 'take a screenshot', 'scrape', 'automate', 'test the website', 'log into', or any browser interaction request.",
|
||||
template: `# Dev Browser Skill
|
||||
|
||||
Browser automation that maintains page state across script executions. Write small, focused scripts to accomplish tasks incrementally. Once you've proven out part of a workflow and there is repeated work to be done, you can write a script to do the repeated work in a single execution.
|
||||
|
||||
## Choosing Your Approach
|
||||
|
||||
- **Local/source-available sites**: Read the source code first to write selectors directly
|
||||
- **Unknown page layouts**: Use \`getAISnapshot()\` to discover elements and \`selectSnapshotRef()\` to interact with them
|
||||
- **Visual feedback**: Take screenshots to see what the user sees
|
||||
|
||||
## Setup
|
||||
|
||||
**IMPORTANT**: Before using this skill, ensure the server is running. See [references/installation.md](references/installation.md) for platform-specific setup instructions (macOS, Linux, Windows).
|
||||
|
||||
Two modes available. Ask the user if unclear which to use.
|
||||
|
||||
### Standalone Mode (Default)
|
||||
|
||||
Launches a new Chromium browser for fresh automation sessions.
|
||||
|
||||
**macOS/Linux:**
|
||||
\`\`\`bash
|
||||
./skills/dev-browser/server.sh &
|
||||
\`\`\`
|
||||
|
||||
**Windows (PowerShell):**
|
||||
\`\`\`powershell
|
||||
Start-Process -NoNewWindow -FilePath "node" -ArgumentList "skills/dev-browser/server.js"
|
||||
\`\`\`
|
||||
|
||||
Add \`--headless\` flag if user requests it. **Wait for the \`Ready\` message before running scripts.**
|
||||
|
||||
### Extension Mode
|
||||
|
||||
Connects to user's existing Chrome browser. Use this when:
|
||||
|
||||
- The user is already logged into sites and wants you to do things behind an authed experience that isn't local dev.
|
||||
- The user asks you to use the extension
|
||||
|
||||
**Important**: The core flow is still the same. You create named pages inside of their browser.
|
||||
|
||||
**Start the relay server:**
|
||||
|
||||
**macOS/Linux:**
|
||||
\`\`\`bash
|
||||
cd skills/dev-browser && npm i && npm run start-extension &
|
||||
\`\`\`
|
||||
|
||||
**Windows (PowerShell):**
|
||||
\`\`\`powershell
|
||||
cd skills/dev-browser; npm i; Start-Process -NoNewWindow -FilePath "npm" -ArgumentList "run", "start-extension"
|
||||
\`\`\`
|
||||
|
||||
Wait for \`Waiting for extension to connect...\` followed by \`Extension connected\` in the console.
|
||||
|
||||
If the extension hasn't connected yet, tell the user to launch and activate it. Download link: https://github.com/SawyerHood/dev-browser/releases
|
||||
|
||||
## Writing Scripts
|
||||
|
||||
> **Run all scripts from \`skills/dev-browser/\` directory.** The \`@/\` import alias requires this directory's config.
|
||||
|
||||
Execute scripts inline using heredocs:
|
||||
|
||||
**macOS/Linux:**
|
||||
\`\`\`bash
|
||||
cd skills/dev-browser && npx tsx <<'EOF'
|
||||
import { connect, waitForPageLoad } from "@/client.js";
|
||||
|
||||
const client = await connect();
|
||||
const page = await client.page("example", { viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
await page.goto("https://example.com");
|
||||
await waitForPageLoad(page);
|
||||
|
||||
console.log({ title: await page.title(), url: page.url() });
|
||||
await client.disconnect();
|
||||
EOF
|
||||
\`\`\`
|
||||
|
||||
**Windows (PowerShell):**
|
||||
\`\`\`powershell
|
||||
cd skills/dev-browser
|
||||
@"
|
||||
import { connect, waitForPageLoad } from "@/client.js";
|
||||
|
||||
const client = await connect();
|
||||
const page = await client.page("example", { viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
await page.goto("https://example.com");
|
||||
await waitForPageLoad(page);
|
||||
|
||||
console.log({ title: await page.title(), url: page.url() });
|
||||
await client.disconnect();
|
||||
"@ | npx tsx --input-type=module
|
||||
\`\`\`
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Small scripts**: Each script does ONE thing (navigate, click, fill, check)
|
||||
2. **Evaluate state**: Log/return state at the end to decide next steps
|
||||
3. **Descriptive page names**: Use \`"checkout"\`, \`"login"\`, not \`"main"\`
|
||||
4. **Disconnect to exit**: \`await client.disconnect()\` - pages persist on server
|
||||
5. **Plain JS in evaluate**: \`page.evaluate()\` runs in browser - no TypeScript syntax
|
||||
|
||||
## Workflow Loop
|
||||
|
||||
1. **Write a script** to perform one action
|
||||
2. **Run it** and observe the output
|
||||
3. **Evaluate** - did it work? What's the current state?
|
||||
4. **Decide** - is the task complete or do we need another script?
|
||||
5. **Repeat** until task is done
|
||||
|
||||
### No TypeScript in Browser Context
|
||||
|
||||
Code passed to \`page.evaluate()\` runs in the browser, which doesn't understand TypeScript:
|
||||
|
||||
\`\`\`typescript
|
||||
// Correct: plain JavaScript
|
||||
const text = await page.evaluate(() => {
|
||||
return document.body.innerText;
|
||||
});
|
||||
|
||||
// Wrong: TypeScript syntax will fail at runtime
|
||||
const text = await page.evaluate(() => {
|
||||
const el: HTMLElement = document.body; // Type annotation breaks in browser!
|
||||
return el.innerText;
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
## Scraping Data
|
||||
|
||||
For scraping large datasets, intercept and replay network requests rather than scrolling the DOM. See [references/scraping.md](references/scraping.md) for the complete guide.
|
||||
|
||||
## Client API
|
||||
|
||||
\`\`\`typescript
|
||||
const client = await connect();
|
||||
|
||||
// Get or create named page
|
||||
const page = await client.page("name");
|
||||
const pageWithSize = await client.page("name", { viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const pages = await client.list(); // List all page names
|
||||
await client.close("name"); // Close a page
|
||||
await client.disconnect(); // Disconnect (pages persist)
|
||||
|
||||
// ARIA Snapshot methods
|
||||
const snapshot = await client.getAISnapshot("name"); // Get accessibility tree
|
||||
const element = await client.selectSnapshotRef("name", "e5"); // Get element by ref
|
||||
\`\`\`
|
||||
|
||||
## Waiting
|
||||
|
||||
\`\`\`typescript
|
||||
import { waitForPageLoad } from "@/client.js";
|
||||
|
||||
await waitForPageLoad(page); // After navigation
|
||||
await page.waitForSelector(".results"); // For specific elements
|
||||
await page.waitForURL("**/success"); // For specific URL
|
||||
\`\`\`
|
||||
|
||||
## Screenshots
|
||||
|
||||
\`\`\`typescript
|
||||
await page.screenshot({ path: "tmp/screenshot.png" });
|
||||
await page.screenshot({ path: "tmp/full.png", fullPage: true });
|
||||
\`\`\`
|
||||
|
||||
## ARIA Snapshot (Element Discovery)
|
||||
|
||||
Use \`getAISnapshot()\` to discover page elements. Returns YAML-formatted accessibility tree:
|
||||
|
||||
\`\`\`yaml
|
||||
- banner:
|
||||
- link "Hacker News" [ref=e1]
|
||||
- navigation:
|
||||
- link "new" [ref=e2]
|
||||
- main:
|
||||
- list:
|
||||
- listitem:
|
||||
- link "Article Title" [ref=e8]
|
||||
\`\`\`
|
||||
|
||||
**Interacting with refs:**
|
||||
|
||||
\`\`\`typescript
|
||||
const snapshot = await client.getAISnapshot("hackernews");
|
||||
console.log(snapshot); // Find the ref you need
|
||||
|
||||
const element = await client.selectSnapshotRef("hackernews", "e2");
|
||||
await element.click();
|
||||
\`\`\`
|
||||
|
||||
## Error Recovery
|
||||
|
||||
Page state persists after failures. Debug with:
|
||||
|
||||
\`\`\`bash
|
||||
cd skills/dev-browser && npx tsx <<'EOF'
|
||||
import { connect } from "@/client.js";
|
||||
|
||||
const client = await connect();
|
||||
const page = await client.page("hackernews");
|
||||
|
||||
await page.screenshot({ path: "tmp/debug.png" });
|
||||
console.log({
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
bodyText: await page.textContent("body").then((t) => t?.slice(0, 200)),
|
||||
});
|
||||
|
||||
await client.disconnect();
|
||||
EOF
|
||||
\`\`\``,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { BuiltinSkill } from "../types"
|
||||
|
||||
export const frontendUiUxSkill: BuiltinSkill = {
|
||||
name: "frontend-ui-ux",
|
||||
description: "Designer-turned-developer who crafts stunning UI/UX even without design mockups",
|
||||
template: `# Role: Designer-Turned-Developer
|
||||
|
||||
You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces.
|
||||
|
||||
**Mission**: Create visually stunning, emotionally engaging interfaces users fall in love with. Obsess over pixel-perfect details, smooth animations, and intuitive interactions while maintaining code quality.
|
||||
|
||||
---
|
||||
|
||||
# Work Principles
|
||||
|
||||
1. **Complete what's asked** — Execute the exact task. No scope creep. Work until it works. Never mark work complete without proper verification.
|
||||
2. **Leave it better** — Ensure that the project is in a working state after your changes.
|
||||
3. **Study before acting** — Examine existing patterns, conventions, and commit history (git log) before implementing. Understand why code is structured the way it is.
|
||||
4. **Blend seamlessly** — Match existing code patterns. Your code should look like the team wrote it.
|
||||
5. **Be transparent** — Announce each step. Explain reasoning. Report both successes and failures.
|
||||
|
||||
---
|
||||
|
||||
# Design Process
|
||||
|
||||
Before coding, commit to a **BOLD aesthetic direction**:
|
||||
|
||||
1. **Purpose**: What problem does this solve? Who uses it?
|
||||
2. **Tone**: Pick an extreme—brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian
|
||||
3. **Constraints**: Technical requirements (framework, performance, accessibility)
|
||||
4. **Differentiation**: What's the ONE thing someone will remember?
|
||||
|
||||
**Key**: Choose a clear direction and execute with precision. Intentionality > intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, Angular, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
---
|
||||
|
||||
# Aesthetic Guidelines
|
||||
|
||||
## Typography
|
||||
Choose distinctive fonts. **Avoid**: Arial, Inter, Roboto, system fonts, Space Grotesk. Pair a characterful display font with a refined body font.
|
||||
|
||||
## Color
|
||||
Commit to a cohesive palette. Use CSS variables. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. **Avoid**: purple gradients on white (AI slop).
|
||||
|
||||
## Motion
|
||||
Focus on high-impact moments. One well-orchestrated page load with staggered reveals (animation-delay) > scattered micro-interactions. Use scroll-triggering and hover states that surprise. Prioritize CSS-only. Use Motion library for React when available.
|
||||
|
||||
## Spatial Composition
|
||||
Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
|
||||
## Visual Details
|
||||
Create atmosphere and depth—gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, grain overlays. Never default to solid colors.
|
||||
|
||||
---
|
||||
|
||||
# Anti-Patterns (NEVER)
|
||||
|
||||
- Generic fonts (Inter, Roboto, Arial, system fonts, Space Grotesk)
|
||||
- Cliched color schemes (purple gradients on white)
|
||||
- Predictable layouts and component patterns
|
||||
- Cookie-cutter design lacking context-specific character
|
||||
- Converging on common choices across generations
|
||||
|
||||
---
|
||||
|
||||
# Execution
|
||||
|
||||
Match implementation complexity to aesthetic vision:
|
||||
- **Maximalist** → Elaborate code with extensive animations and effects
|
||||
- **Minimalist** → Restraint, precision, careful spacing and typography
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. You are capable of extraordinary creative work—don't hold back.`,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
export { playwrightSkill, agentBrowserSkill } from "./playwright"
|
||||
export { frontendUiUxSkill } from "./frontend-ui-ux"
|
||||
export { gitMasterSkill } from "./git-master"
|
||||
export { devBrowserSkill } from "./dev-browser"
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { BuiltinSkill } from "../types"
|
||||
|
||||
export const playwrightSkill: BuiltinSkill = {
|
||||
name: "playwright",
|
||||
description: "MUST USE for any browser-related tasks. Browser automation via Playwright MCP - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.",
|
||||
template: `# Playwright Browser Automation
|
||||
|
||||
This skill provides browser automation capabilities via the Playwright MCP server.`,
|
||||
mcpConfig: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp@latest"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const agentBrowserSkill: BuiltinSkill = {
|
||||
name: "agent-browser",
|
||||
description: "MUST USE for any browser-related tasks. Browser automation via agent-browser CLI - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.",
|
||||
template: `# Browser Automation with agent-browser
|
||||
|
||||
## Quick start
|
||||
|
||||
\`\`\`bash
|
||||
agent-browser open <url> # Navigate to page
|
||||
agent-browser snapshot -i # Get interactive elements with refs
|
||||
agent-browser click @e1 # Click element by ref
|
||||
agent-browser fill @e2 "text" # Fill input by ref
|
||||
agent-browser close # Close browser
|
||||
\`\`\`
|
||||
|
||||
## Core workflow
|
||||
|
||||
1. Navigate: \`agent-browser open <url>\`
|
||||
2. Snapshot: \`agent-browser snapshot -i\` (returns elements with refs like \`@e1\`, \`@e2\`)
|
||||
3. Interact using refs from the snapshot
|
||||
4. Re-snapshot after navigation or significant DOM changes
|
||||
|
||||
## Commands
|
||||
|
||||
### Navigation
|
||||
\`\`\`bash
|
||||
agent-browser open <url> # Navigate to URL
|
||||
agent-browser back # Go back
|
||||
agent-browser forward # Go forward
|
||||
agent-browser reload # Reload page
|
||||
agent-browser close # Close browser
|
||||
\`\`\`
|
||||
|
||||
### Snapshot (page analysis)
|
||||
\`\`\`bash
|
||||
agent-browser snapshot # Full accessibility tree
|
||||
agent-browser snapshot -i # Interactive elements only (recommended)
|
||||
agent-browser snapshot -c # Compact output
|
||||
agent-browser snapshot -d 3 # Limit depth to 3
|
||||
agent-browser snapshot -s "#main" # Scope to CSS selector
|
||||
\`\`\`
|
||||
|
||||
### Interactions (use @refs from snapshot)
|
||||
\`\`\`bash
|
||||
agent-browser click @e1 # Click
|
||||
agent-browser dblclick @e1 # Double-click
|
||||
agent-browser focus @e1 # Focus element
|
||||
agent-browser fill @e2 "text" # Clear and type
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser press Enter # Press key
|
||||
agent-browser press Control+a # Key combination
|
||||
agent-browser keydown Shift # Hold key down
|
||||
agent-browser keyup Shift # Release key
|
||||
agent-browser hover @e1 # Hover
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser uncheck @e1 # Uncheck checkbox
|
||||
agent-browser select @e1 "value" # Select dropdown
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scrollintoview @e1 # Scroll element into view
|
||||
agent-browser drag @e1 @e2 # Drag and drop
|
||||
agent-browser upload @e1 file.pdf # Upload files
|
||||
\`\`\`
|
||||
|
||||
### Get information
|
||||
\`\`\`bash
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get html @e1 # Get innerHTML
|
||||
agent-browser get value @e1 # Get input value
|
||||
agent-browser get attr @e1 href # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get count ".item" # Count matching elements
|
||||
agent-browser get box @e1 # Get bounding box
|
||||
\`\`\`
|
||||
|
||||
### Check state
|
||||
\`\`\`bash
|
||||
agent-browser is visible @e1 # Check if visible
|
||||
agent-browser is enabled @e1 # Check if enabled
|
||||
agent-browser is checked @e1 # Check if checked
|
||||
\`\`\`
|
||||
|
||||
### Screenshots & PDF
|
||||
\`\`\`bash
|
||||
agent-browser screenshot # Screenshot to stdout
|
||||
agent-browser screenshot path.png # Save to file
|
||||
agent-browser screenshot --full # Full page
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
\`\`\`
|
||||
|
||||
### Video recording
|
||||
\`\`\`bash
|
||||
agent-browser record start ./demo.webm # Start recording (uses current URL + state)
|
||||
agent-browser click @e1 # Perform actions
|
||||
agent-browser record stop # Stop and save video
|
||||
agent-browser record restart ./take2.webm # Stop current + start new recording
|
||||
\`\`\`
|
||||
Recording creates a fresh context but preserves cookies/storage from your session.
|
||||
|
||||
### Wait
|
||||
\`\`\`bash
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait --text "Success" # Wait for text
|
||||
agent-browser wait --url "**/dashboard" # Wait for URL pattern
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --fn "window.ready" # Wait for JS condition
|
||||
\`\`\`
|
||||
|
||||
### Mouse control
|
||||
\`\`\`bash
|
||||
agent-browser mouse move 100 200 # Move mouse
|
||||
agent-browser mouse down left # Press button
|
||||
agent-browser mouse up left # Release button
|
||||
agent-browser mouse wheel 100 # Scroll wheel
|
||||
\`\`\`
|
||||
|
||||
### Semantic locators (alternative to refs)
|
||||
\`\`\`bash
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find first ".item" click
|
||||
agent-browser find nth 2 "a" text
|
||||
\`\`\`
|
||||
|
||||
### Browser settings
|
||||
\`\`\`bash
|
||||
agent-browser set viewport 1920 1080 # Set viewport size
|
||||
agent-browser set device "iPhone 14" # Emulate device
|
||||
agent-browser set geo 37.7749 -122.4194 # Set geolocation
|
||||
agent-browser set offline on # Toggle offline mode
|
||||
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
|
||||
agent-browser set credentials user pass # HTTP basic auth
|
||||
agent-browser set media dark # Emulate color scheme
|
||||
\`\`\`
|
||||
|
||||
### Cookies & Storage
|
||||
\`\`\`bash
|
||||
agent-browser cookies # Get all cookies
|
||||
agent-browser cookies set name value # Set cookie
|
||||
agent-browser cookies clear # Clear cookies
|
||||
agent-browser storage local # Get all localStorage
|
||||
agent-browser storage local key # Get specific key
|
||||
agent-browser storage local set k v # Set value
|
||||
agent-browser storage local clear # Clear all
|
||||
agent-browser storage session # Get all sessionStorage
|
||||
agent-browser storage session key # Get specific key
|
||||
agent-browser storage session set k v # Set value
|
||||
agent-browser storage session clear # Clear all
|
||||
\`\`\`
|
||||
|
||||
### Network
|
||||
\`\`\`bash
|
||||
agent-browser network route <url> # Intercept requests
|
||||
agent-browser network route <url> --abort # Block requests
|
||||
agent-browser network route <url> --body '{}' # Mock response
|
||||
agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests
|
||||
agent-browser network requests --filter api # Filter requests
|
||||
\`\`\`
|
||||
|
||||
### Tabs & Windows
|
||||
\`\`\`bash
|
||||
agent-browser tab # List tabs
|
||||
agent-browser tab new [url] # New tab
|
||||
agent-browser tab 2 # Switch to tab
|
||||
agent-browser tab close # Close tab
|
||||
agent-browser window new # New window
|
||||
\`\`\`
|
||||
|
||||
### Frames
|
||||
\`\`\`bash
|
||||
agent-browser frame "#iframe" # Switch to iframe
|
||||
agent-browser frame main # Back to main frame
|
||||
\`\`\`
|
||||
|
||||
### Dialogs
|
||||
\`\`\`bash
|
||||
agent-browser dialog accept [text] # Accept dialog
|
||||
agent-browser dialog dismiss # Dismiss dialog
|
||||
\`\`\`
|
||||
|
||||
### JavaScript
|
||||
\`\`\`bash
|
||||
agent-browser eval "document.title" # Run JavaScript
|
||||
\`\`\`
|
||||
|
||||
## Global Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| \`--session <name>\` | Isolated browser session (\`AGENT_BROWSER_SESSION\` env) |
|
||||
| \`--profile <path>\` | Persistent browser profile (\`AGENT_BROWSER_PROFILE\` env) |
|
||||
| \`--headers <json>\` | HTTP headers scoped to URL's origin |
|
||||
| \`--executable-path <path>\` | Custom browser binary (\`AGENT_BROWSER_EXECUTABLE_PATH\` env) |
|
||||
| \`--args <args>\` | Browser launch args (\`AGENT_BROWSER_ARGS\` env) |
|
||||
| \`--user-agent <ua>\` | Custom User-Agent (\`AGENT_BROWSER_USER_AGENT\` env) |
|
||||
| \`--proxy <url>\` | Proxy server (\`AGENT_BROWSER_PROXY\` env) |
|
||||
| \`--proxy-bypass <hosts>\` | Hosts to bypass proxy (\`AGENT_BROWSER_PROXY_BYPASS\` env) |
|
||||
| \`-p, --provider <name>\` | Cloud browser provider (\`AGENT_BROWSER_PROVIDER\` env) |
|
||||
| \`--json\` | Machine-readable JSON output |
|
||||
| \`--headed\` | Show browser window (not headless) |
|
||||
| \`--cdp <port\\|wss://url>\` | Connect via Chrome DevTools Protocol |
|
||||
| \`--debug\` | Debug output |
|
||||
|
||||
## Example: Form submission
|
||||
|
||||
\`\`\`bash
|
||||
agent-browser open https://example.com/form
|
||||
agent-browser snapshot -i
|
||||
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]
|
||||
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i # Check result
|
||||
\`\`\`
|
||||
|
||||
## Example: Authentication with saved state
|
||||
|
||||
\`\`\`bash
|
||||
# Login once
|
||||
agent-browser open https://app.example.com/login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "username"
|
||||
agent-browser fill @e2 "password"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --url "**/dashboard"
|
||||
agent-browser state save auth.json
|
||||
|
||||
# Later sessions: load saved state
|
||||
agent-browser state load auth.json
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
\`\`\`
|
||||
|
||||
### Header-based Auth (Skip login flows)
|
||||
\`\`\`bash
|
||||
# Headers scoped to api.example.com only
|
||||
agent-browser open api.example.com --headers '{"Authorization": "Bearer <token>"}'
|
||||
# Navigate to another domain - headers NOT sent (safe)
|
||||
agent-browser open other-site.com
|
||||
# Global headers (all domains)
|
||||
agent-browser set headers '{"X-Custom-Header": "value"}'
|
||||
\`\`\`
|
||||
|
||||
## Sessions & Persistent Profiles
|
||||
|
||||
### Sessions (parallel browsers)
|
||||
\`\`\`bash
|
||||
agent-browser --session test1 open site-a.com
|
||||
agent-browser --session test2 open site-b.com
|
||||
agent-browser session list
|
||||
\`\`\`
|
||||
|
||||
### Persistent Profiles
|
||||
Persists cookies, localStorage, IndexedDB, service workers, cache, login sessions across browser restarts.
|
||||
\`\`\`bash
|
||||
agent-browser --profile ~/.myapp-profile open myapp.com
|
||||
# Or via env var
|
||||
AGENT_BROWSER_PROFILE=~/.myapp-profile agent-browser open myapp.com
|
||||
\`\`\`
|
||||
- Use different profile paths for different projects
|
||||
- Login once → restart browser → still logged in
|
||||
- Stores: cookies, localStorage, IndexedDB, service workers, browser cache
|
||||
|
||||
## JSON output (for parsing)
|
||||
|
||||
Add \`--json\` for machine-readable output:
|
||||
\`\`\`bash
|
||||
agent-browser snapshot -i --json
|
||||
agent-browser get text @e1 --json
|
||||
\`\`\`
|
||||
|
||||
## Debugging
|
||||
|
||||
\`\`\`bash
|
||||
agent-browser open example.com --headed # Show browser window
|
||||
agent-browser console # View console messages
|
||||
agent-browser errors # View page errors
|
||||
agent-browser record start ./debug.webm # Record from current page
|
||||
agent-browser record stop # Save recording
|
||||
agent-browser connect 9222 # Local CDP port
|
||||
agent-browser --cdp "wss://browser-service.com/cdp?token=..." snapshot # Remote via WebSocket
|
||||
agent-browser console --clear # Clear console
|
||||
agent-browser errors --clear # Clear errors
|
||||
agent-browser highlight @e1 # Highlight element
|
||||
agent-browser trace start # Start recording trace
|
||||
agent-browser trace stop trace.zip # Stop and save trace
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
Install: \`bun add -g agent-browser && agent-browser install\`. Run \`agent-browser --help\` for all commands. Repo: https://github.com/vercel-labs/agent-browser`,
|
||||
allowedTools: ["Bash(agent-browser:*)"],
|
||||
}
|
||||
@@ -15,16 +15,16 @@ describe("getSystemMcpServerNames", () => {
|
||||
})
|
||||
|
||||
it("returns empty set when no .mcp.json files exist", async () => {
|
||||
// #given
|
||||
// given
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
// when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(names).toBeInstanceOf(Set)
|
||||
expect(names.size).toBe(0)
|
||||
} finally {
|
||||
@@ -33,7 +33,7 @@ describe("getSystemMcpServerNames", () => {
|
||||
})
|
||||
|
||||
it("returns server names from project .mcp.json", async () => {
|
||||
// #given
|
||||
// given
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
@@ -52,11 +52,11 @@ describe("getSystemMcpServerNames", () => {
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
// when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(names.has("playwright")).toBe(true)
|
||||
expect(names.has("sqlite")).toBe(true)
|
||||
expect(names.size).toBe(2)
|
||||
@@ -66,7 +66,7 @@ describe("getSystemMcpServerNames", () => {
|
||||
})
|
||||
|
||||
it("returns server names from .claude/.mcp.json", async () => {
|
||||
// #given
|
||||
// given
|
||||
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
@@ -82,11 +82,11 @@ describe("getSystemMcpServerNames", () => {
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
// when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(names.has("memory")).toBe(true)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
@@ -94,7 +94,7 @@ describe("getSystemMcpServerNames", () => {
|
||||
})
|
||||
|
||||
it("excludes disabled MCP servers", async () => {
|
||||
// #given
|
||||
// given
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
@@ -114,11 +114,11 @@ describe("getSystemMcpServerNames", () => {
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
// when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(names.has("playwright")).toBe(false)
|
||||
expect(names.has("active")).toBe(true)
|
||||
} finally {
|
||||
@@ -127,7 +127,7 @@ describe("getSystemMcpServerNames", () => {
|
||||
})
|
||||
|
||||
it("merges server names from multiple .mcp.json files", async () => {
|
||||
// #given
|
||||
// given
|
||||
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
|
||||
|
||||
const projectMcp = {
|
||||
@@ -148,11 +148,11 @@ describe("getSystemMcpServerNames", () => {
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
// when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(names.has("playwright")).toBe(true)
|
||||
expect(names.has("memory")).toBe(true)
|
||||
} finally {
|
||||
|
||||
@@ -11,124 +11,124 @@ import {
|
||||
|
||||
describe("claude-code-session-state", () => {
|
||||
beforeEach(() => {
|
||||
// #given - clean state before each test
|
||||
// given - clean state before each test
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// #then - cleanup after each test to prevent pollution
|
||||
// then - cleanup after each test to prevent pollution
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
describe("setSessionAgent", () => {
|
||||
test("should store agent for session", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "test-session-1"
|
||||
const agent = "Prometheus (Planner)"
|
||||
|
||||
// #when
|
||||
// when
|
||||
setSessionAgent(sessionID, agent)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(getSessionAgent(sessionID)).toBe(agent)
|
||||
})
|
||||
|
||||
test("should NOT overwrite existing agent (first-write wins)", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "test-session-1"
|
||||
setSessionAgent(sessionID, "Prometheus (Planner)")
|
||||
|
||||
// #when - try to overwrite
|
||||
// when - try to overwrite
|
||||
setSessionAgent(sessionID, "sisyphus")
|
||||
|
||||
// #then - first agent preserved
|
||||
// then - first agent preserved
|
||||
expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)")
|
||||
})
|
||||
|
||||
test("should return undefined for unknown session", () => {
|
||||
// #given - no session set
|
||||
// given - no session set
|
||||
|
||||
// #when / #then
|
||||
// when / then
|
||||
expect(getSessionAgent("unknown-session")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateSessionAgent", () => {
|
||||
test("should overwrite existing agent", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "test-session-1"
|
||||
setSessionAgent(sessionID, "Prometheus (Planner)")
|
||||
|
||||
// #when - force update
|
||||
// when - force update
|
||||
updateSessionAgent(sessionID, "sisyphus")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(getSessionAgent(sessionID)).toBe("sisyphus")
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearSessionAgent", () => {
|
||||
test("should remove agent from session", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "test-session-1"
|
||||
setSessionAgent(sessionID, "Prometheus (Planner)")
|
||||
expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)")
|
||||
|
||||
// #when
|
||||
// when
|
||||
clearSessionAgent(sessionID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(getSessionAgent(sessionID)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("mainSessionID", () => {
|
||||
test("should store and retrieve main session ID", () => {
|
||||
// #given
|
||||
// given
|
||||
const mainID = "main-session-123"
|
||||
|
||||
// #when
|
||||
// when
|
||||
setMainSession(mainID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(getMainSessionID()).toBe(mainID)
|
||||
})
|
||||
|
||||
test("should return undefined when not set", () => {
|
||||
// #given - explicit reset to ensure clean state (parallel test isolation)
|
||||
// given - explicit reset to ensure clean state (parallel test isolation)
|
||||
_resetForTesting()
|
||||
// #then
|
||||
// then
|
||||
expect(getMainSessionID()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("prometheus-md-only integration scenario", () => {
|
||||
test("should correctly identify Prometheus agent for permission checks", () => {
|
||||
// #given - Prometheus session
|
||||
// given - Prometheus session
|
||||
const sessionID = "test-prometheus-session"
|
||||
const prometheusAgent = "Prometheus (Planner)"
|
||||
|
||||
// #when - agent is set (simulating chat.message hook)
|
||||
// when - agent is set (simulating chat.message hook)
|
||||
setSessionAgent(sessionID, prometheusAgent)
|
||||
|
||||
// #then - getSessionAgent returns correct agent for prometheus-md-only hook
|
||||
// then - getSessionAgent returns correct agent for prometheus-md-only hook
|
||||
const agent = getSessionAgent(sessionID)
|
||||
expect(agent).toBe("Prometheus (Planner)")
|
||||
expect(["Prometheus (Planner)"].includes(agent!)).toBe(true)
|
||||
})
|
||||
|
||||
test("should return undefined when agent not set (bug scenario)", () => {
|
||||
// #given - session exists but no agent set (the bug)
|
||||
// given - session exists but no agent set (the bug)
|
||||
const sessionID = "test-prometheus-session"
|
||||
|
||||
// #when / #then - this is the bug: agent is undefined
|
||||
// when / then - this is the bug: agent is undefined
|
||||
expect(getSessionAgent(sessionID)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("issue #893: custom agent switch reset", () => {
|
||||
test("should preserve custom agent when default agent is sent on subsequent messages", () => {
|
||||
// #given - user switches to custom agent "MyCustomAgent"
|
||||
// given - user switches to custom agent "MyCustomAgent"
|
||||
const sessionID = "test-session-custom"
|
||||
const customAgent = "MyCustomAgent"
|
||||
const defaultAgent = "sisyphus"
|
||||
@@ -137,27 +137,27 @@ describe("claude-code-session-state", () => {
|
||||
setSessionAgent(sessionID, customAgent)
|
||||
expect(getSessionAgent(sessionID)).toBe(customAgent)
|
||||
|
||||
// #when - first message after switch sends default agent
|
||||
// when - first message after switch sends default agent
|
||||
// This simulates the bug: input.agent = "Sisyphus" on first message
|
||||
// Using setSessionAgent (first-write wins) should preserve custom agent
|
||||
setSessionAgent(sessionID, defaultAgent)
|
||||
|
||||
// #then - custom agent should be preserved, NOT overwritten
|
||||
// then - custom agent should be preserved, NOT overwritten
|
||||
expect(getSessionAgent(sessionID)).toBe(customAgent)
|
||||
})
|
||||
|
||||
test("should allow explicit agent update via updateSessionAgent", () => {
|
||||
// #given - custom agent is set
|
||||
// given - custom agent is set
|
||||
const sessionID = "test-session-explicit"
|
||||
const customAgent = "MyCustomAgent"
|
||||
const newAgent = "AnotherAgent"
|
||||
|
||||
setSessionAgent(sessionID, customAgent)
|
||||
|
||||
// #when - explicit update (user intentionally switches)
|
||||
// when - explicit update (user intentionally switches)
|
||||
updateSessionAgent(sessionID, newAgent)
|
||||
|
||||
// #then - should be updated
|
||||
// then - should be updated
|
||||
expect(getSessionAgent(sessionID)).toBe(newAgent)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("ContextCollector", () => {
|
||||
|
||||
describe("register", () => {
|
||||
it("registers context for a session", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_test1"
|
||||
const options = {
|
||||
id: "ulw-context",
|
||||
@@ -19,10 +19,10 @@ describe("ContextCollector", () => {
|
||||
content: "Ultrawork mode activated",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
collector.register(sessionID, options)
|
||||
|
||||
// #then
|
||||
// then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.hasContent).toBe(true)
|
||||
expect(pending.entries).toHaveLength(1)
|
||||
@@ -30,26 +30,26 @@ describe("ContextCollector", () => {
|
||||
})
|
||||
|
||||
it("assigns default priority of 'normal' when not specified", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_test2"
|
||||
|
||||
// #when
|
||||
// when
|
||||
collector.register(sessionID, {
|
||||
id: "test",
|
||||
source: "keyword-detector",
|
||||
content: "test content",
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries[0].priority).toBe("normal")
|
||||
})
|
||||
|
||||
it("uses specified priority", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_test3"
|
||||
|
||||
// #when
|
||||
// when
|
||||
collector.register(sessionID, {
|
||||
id: "critical-context",
|
||||
source: "keyword-detector",
|
||||
@@ -57,13 +57,13 @@ describe("ContextCollector", () => {
|
||||
priority: "critical",
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries[0].priority).toBe("critical")
|
||||
})
|
||||
|
||||
it("deduplicates by source + id combination", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_test4"
|
||||
const options = {
|
||||
id: "ulw-context",
|
||||
@@ -71,21 +71,21 @@ describe("ContextCollector", () => {
|
||||
content: "First content",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
collector.register(sessionID, options)
|
||||
collector.register(sessionID, { ...options, content: "Updated content" })
|
||||
|
||||
// #then
|
||||
// then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries).toHaveLength(1)
|
||||
expect(pending.entries[0].content).toBe("Updated content")
|
||||
})
|
||||
|
||||
it("allows same id from different sources", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_test5"
|
||||
|
||||
// #when
|
||||
// when
|
||||
collector.register(sessionID, {
|
||||
id: "context-1",
|
||||
source: "keyword-detector",
|
||||
@@ -97,7 +97,7 @@ describe("ContextCollector", () => {
|
||||
content: "From rules-injector",
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries).toHaveLength(2)
|
||||
})
|
||||
@@ -105,20 +105,20 @@ describe("ContextCollector", () => {
|
||||
|
||||
describe("getPending", () => {
|
||||
it("returns empty result for session with no context", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_empty"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(pending.hasContent).toBe(false)
|
||||
expect(pending.entries).toHaveLength(0)
|
||||
expect(pending.merged).toBe("")
|
||||
})
|
||||
|
||||
it("merges multiple contexts with separator", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_merge"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-1",
|
||||
@@ -131,17 +131,17 @@ describe("ContextCollector", () => {
|
||||
content: "Second context",
|
||||
})
|
||||
|
||||
// #when
|
||||
// when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(pending.hasContent).toBe(true)
|
||||
expect(pending.merged).toContain("First context")
|
||||
expect(pending.merged).toContain("Second context")
|
||||
})
|
||||
|
||||
it("orders contexts by priority (critical > high > normal > low)", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_priority"
|
||||
collector.register(sessionID, {
|
||||
id: "low",
|
||||
@@ -168,16 +168,16 @@ describe("ContextCollector", () => {
|
||||
priority: "high",
|
||||
})
|
||||
|
||||
// #when
|
||||
// when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
const order = pending.entries.map((e) => e.priority)
|
||||
expect(order).toEqual(["critical", "high", "normal", "low"])
|
||||
})
|
||||
|
||||
it("maintains registration order within same priority", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_order"
|
||||
collector.register(sessionID, {
|
||||
id: "first",
|
||||
@@ -198,10 +198,10 @@ describe("ContextCollector", () => {
|
||||
priority: "normal",
|
||||
})
|
||||
|
||||
// #when
|
||||
// when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
const ids = pending.entries.map((e) => e.id)
|
||||
expect(ids).toEqual(["first", "second", "third"])
|
||||
})
|
||||
@@ -209,7 +209,7 @@ describe("ContextCollector", () => {
|
||||
|
||||
describe("consume", () => {
|
||||
it("clears pending context for session", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_consume"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
@@ -217,16 +217,16 @@ describe("ContextCollector", () => {
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when
|
||||
// when
|
||||
collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.hasContent).toBe(false)
|
||||
})
|
||||
|
||||
it("returns the consumed context", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_consume_return"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
@@ -234,16 +234,16 @@ describe("ContextCollector", () => {
|
||||
content: "test content",
|
||||
})
|
||||
|
||||
// #when
|
||||
// when
|
||||
const consumed = collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(consumed.hasContent).toBe(true)
|
||||
expect(consumed.entries[0].content).toBe("test content")
|
||||
})
|
||||
|
||||
it("does not affect other sessions", () => {
|
||||
// #given
|
||||
// given
|
||||
const session1 = "ses_1"
|
||||
const session2 = "ses_2"
|
||||
collector.register(session1, {
|
||||
@@ -257,10 +257,10 @@ describe("ContextCollector", () => {
|
||||
content: "session 2",
|
||||
})
|
||||
|
||||
// #when
|
||||
// when
|
||||
collector.consume(session1)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(collector.getPending(session1).hasContent).toBe(false)
|
||||
expect(collector.getPending(session2).hasContent).toBe(true)
|
||||
})
|
||||
@@ -268,7 +268,7 @@ describe("ContextCollector", () => {
|
||||
|
||||
describe("clear", () => {
|
||||
it("removes all context for a session", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_clear"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-1",
|
||||
@@ -281,17 +281,17 @@ describe("ContextCollector", () => {
|
||||
content: "test 2",
|
||||
})
|
||||
|
||||
// #when
|
||||
// when
|
||||
collector.clear(sessionID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(collector.getPending(sessionID).hasContent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasPending", () => {
|
||||
it("returns true when session has pending context", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_has"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
@@ -299,20 +299,20 @@ describe("ContextCollector", () => {
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when session has no pending context", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_empty"
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false after consume", () => {
|
||||
// #given
|
||||
// given
|
||||
const sessionID = "ses_after_consume"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
@@ -320,10 +320,10 @@ describe("ContextCollector", () => {
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when
|
||||
// when
|
||||
collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("createContextInjectorMessagesTransformHook", () => {
|
||||
})
|
||||
|
||||
it("inserts synthetic part before text part in last user message", async () => {
|
||||
// #given
|
||||
// given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform1"
|
||||
collector.register(sessionID, {
|
||||
@@ -53,10 +53,10 @@ describe("createContextInjectorMessagesTransformHook", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then - synthetic part inserted before original text part
|
||||
// then - synthetic part inserted before original text part
|
||||
expect(output.messages.length).toBe(3)
|
||||
expect(output.messages[2].parts.length).toBe(2)
|
||||
expect(output.messages[2].parts[0].text).toBe("Ultrawork context")
|
||||
@@ -65,22 +65,22 @@ describe("createContextInjectorMessagesTransformHook", () => {
|
||||
})
|
||||
|
||||
it("does nothing when no pending context", async () => {
|
||||
// #given
|
||||
// given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform2"
|
||||
const messages = [createMockMessage("user", "Hello world", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(output.messages.length).toBe(1)
|
||||
})
|
||||
|
||||
it("does nothing when no user messages", async () => {
|
||||
// #given
|
||||
// given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform3"
|
||||
collector.register(sessionID, {
|
||||
@@ -92,16 +92,16 @@ describe("createContextInjectorMessagesTransformHook", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(output.messages.length).toBe(1)
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("consumes context after injection", async () => {
|
||||
// #given
|
||||
// given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform4"
|
||||
collector.register(sessionID, {
|
||||
@@ -113,10 +113,10 @@ describe("createContextInjectorMessagesTransformHook", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
// when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,29 +5,29 @@ const nativeFetch = Bun.fetch.bind(Bun)
|
||||
|
||||
describe("findAvailablePort", () => {
|
||||
it("returns the start port when it is available", async () => {
|
||||
//#given
|
||||
// given
|
||||
const startPort = 19877
|
||||
|
||||
//#when
|
||||
// when
|
||||
const port = await findAvailablePort(startPort)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(port).toBeGreaterThanOrEqual(startPort)
|
||||
expect(port).toBeLessThan(startPort + 20)
|
||||
})
|
||||
|
||||
it("skips busy ports and returns next available", async () => {
|
||||
//#given
|
||||
// given
|
||||
const blocker = Bun.serve({
|
||||
port: 19877,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response(),
|
||||
})
|
||||
|
||||
//#when
|
||||
// when
|
||||
const port = await findAvailablePort(19877)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(port).toBeGreaterThan(19877)
|
||||
blocker.stop(true)
|
||||
})
|
||||
@@ -44,23 +44,23 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("starts server and returns port", async () => {
|
||||
//#given - no preconditions
|
||||
// given - no preconditions
|
||||
|
||||
//#when
|
||||
// when
|
||||
server = await startCallbackServer()
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(server.port).toBeGreaterThanOrEqual(19877)
|
||||
expect(typeof server.waitForCallback).toBe("function")
|
||||
expect(typeof server.close).toBe("function")
|
||||
})
|
||||
|
||||
it("resolves callback with code and state from query params", async () => {
|
||||
//#given
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const callbackUrl = `http://127.0.0.1:${server.port}/oauth/callback?code=test-code&state=test-state`
|
||||
|
||||
//#when
|
||||
// when
|
||||
// Use Promise.all to ensure fetch and waitForCallback run concurrently
|
||||
// This prevents race condition where waitForCallback blocks before fetch starts
|
||||
const [result, response] = await Promise.all([
|
||||
@@ -68,7 +68,7 @@ describe("startCallbackServer", () => {
|
||||
nativeFetch(callbackUrl)
|
||||
])
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ code: "test-code", state: "test-state" })
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
@@ -76,25 +76,25 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("returns 404 for non-callback routes", async () => {
|
||||
//#given
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
|
||||
//#when
|
||||
// when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/other`)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when code is missing", async () => {
|
||||
//#given
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -102,14 +102,14 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when state is missing", async () => {
|
||||
//#given
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
@@ -117,15 +117,15 @@ describe("startCallbackServer", () => {
|
||||
})
|
||||
|
||||
it("close stops the server immediately", async () => {
|
||||
//#given
|
||||
// given
|
||||
server = await startCallbackServer()
|
||||
const port = server.port
|
||||
|
||||
//#when
|
||||
// when
|
||||
server.close()
|
||||
server = null
|
||||
|
||||
//#then
|
||||
// then
|
||||
try {
|
||||
await nativeFetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`)
|
||||
expect(true).toBe(false)
|
||||
|
||||
@@ -27,7 +27,7 @@ function createStorage(initial: ClientCredentials | null):
|
||||
|
||||
describe("getOrRegisterClient", () => {
|
||||
it("returns cached registration when available", async () => {
|
||||
// #given
|
||||
// given
|
||||
const storage = createStorage({
|
||||
clientId: "cached-client",
|
||||
clientSecret: "cached-secret",
|
||||
@@ -36,7 +36,7 @@ describe("getOrRegisterClient", () => {
|
||||
throw new Error("fetch should not be called")
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: "https://server.example.com/register",
|
||||
serverIdentifier: "server-1",
|
||||
@@ -47,7 +47,7 @@ describe("getOrRegisterClient", () => {
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
clientId: "cached-client",
|
||||
clientSecret: "cached-secret",
|
||||
@@ -55,7 +55,7 @@ describe("getOrRegisterClient", () => {
|
||||
})
|
||||
|
||||
it("registers client and stores credentials when endpoint available", async () => {
|
||||
// #given
|
||||
// given
|
||||
const storage = createStorage(null)
|
||||
let fetchCalled = false
|
||||
const fetchMock: DcrFetch = async (
|
||||
@@ -85,7 +85,7 @@ describe("getOrRegisterClient", () => {
|
||||
}
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: "https://server.example.com/register",
|
||||
serverIdentifier: "server-2",
|
||||
@@ -96,7 +96,7 @@ describe("getOrRegisterClient", () => {
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(fetchCalled).toBe(true)
|
||||
expect(result).toEqual({
|
||||
clientId: "registered-client",
|
||||
@@ -110,7 +110,7 @@ describe("getOrRegisterClient", () => {
|
||||
})
|
||||
|
||||
it("uses config client id when registration endpoint missing", async () => {
|
||||
// #given
|
||||
// given
|
||||
const storage = createStorage(null)
|
||||
let fetchCalled = false
|
||||
const fetchMock: DcrFetch = async () => {
|
||||
@@ -121,7 +121,7 @@ describe("getOrRegisterClient", () => {
|
||||
}
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: undefined,
|
||||
serverIdentifier: "server-3",
|
||||
@@ -133,19 +133,19 @@ describe("getOrRegisterClient", () => {
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(fetchCalled).toBe(false)
|
||||
expect(result).toEqual({ clientId: "config-client" })
|
||||
})
|
||||
|
||||
it("falls back to config client id when registration fails", async () => {
|
||||
// #given
|
||||
// given
|
||||
const storage = createStorage(null)
|
||||
const fetchMock: DcrFetch = async () => {
|
||||
throw new Error("network error")
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: "https://server.example.com/register",
|
||||
serverIdentifier: "server-4",
|
||||
@@ -157,7 +157,7 @@ describe("getOrRegisterClient", () => {
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({ clientId: "fallback-client" })
|
||||
expect(storage.getLastSet()).toBeNull()
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
})
|
||||
|
||||
test("returns endpoints from PRM + AS discovery", () => {
|
||||
// #given
|
||||
// given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const authServer = "https://auth.example.com"
|
||||
@@ -39,9 +39,9 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
// when
|
||||
return discoverOAuthServerMetadata(resource).then((result) => {
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
authorizationEndpoint: "https://auth.example.com/authorize",
|
||||
tokenEndpoint: "https://auth.example.com/token",
|
||||
@@ -53,7 +53,7 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
})
|
||||
|
||||
test("falls back to RFC 8414 when PRM returns 404", () => {
|
||||
// #given
|
||||
// given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
|
||||
@@ -77,9 +77,9 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
// when
|
||||
return discoverOAuthServerMetadata(resource).then((result) => {
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
authorizationEndpoint: "https://mcp.example.com/authorize",
|
||||
tokenEndpoint: "https://mcp.example.com/token",
|
||||
@@ -91,7 +91,7 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
})
|
||||
|
||||
test("throws when both PRM and AS discovery return 404", () => {
|
||||
// #given
|
||||
// given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
|
||||
@@ -104,15 +104,15 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = discoverOAuthServerMetadata(resource)
|
||||
|
||||
// #then
|
||||
// then
|
||||
return expect(result).rejects.toThrow("OAuth authorization server metadata not found")
|
||||
})
|
||||
|
||||
test("throws when AS metadata is malformed", () => {
|
||||
// #given
|
||||
// given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const authServer = "https://auth.example.com"
|
||||
@@ -131,15 +131,15 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = discoverOAuthServerMetadata(resource)
|
||||
|
||||
// #then
|
||||
// then
|
||||
return expect(result).rejects.toThrow("token_endpoint")
|
||||
})
|
||||
|
||||
test("caches discovery results per resource URL", () => {
|
||||
// #given
|
||||
// given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const authServer = "https://auth.example.com"
|
||||
@@ -164,11 +164,11 @@ describe("discoverOAuthServerMetadata", () => {
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
// when
|
||||
return discoverOAuthServerMetadata(resource)
|
||||
.then(() => discoverOAuthServerMetadata(resource))
|
||||
.then(() => {
|
||||
// #then
|
||||
// then
|
||||
expect(calls).toEqual([prmUrl, asUrl])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,49 +6,49 @@ import type { OAuthTokenData } from "./storage"
|
||||
describe("McpOAuthProvider", () => {
|
||||
describe("generateCodeVerifier", () => {
|
||||
it("returns a base64url-encoded 32-byte random string", () => {
|
||||
//#given
|
||||
// given
|
||||
const verifier = generateCodeVerifier()
|
||||
|
||||
//#when
|
||||
// when
|
||||
const decoded = Buffer.from(verifier, "base64url")
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(decoded.length).toBe(32)
|
||||
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
|
||||
it("produces unique values on each call", () => {
|
||||
//#given
|
||||
// given
|
||||
const first = generateCodeVerifier()
|
||||
|
||||
//#when
|
||||
// when
|
||||
const second = generateCodeVerifier()
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateCodeChallenge", () => {
|
||||
it("returns SHA256 base64url digest of the verifier", () => {
|
||||
//#given
|
||||
// given
|
||||
const verifier = "test-verifier-value"
|
||||
const expected = createHash("sha256").update(verifier).digest("base64url")
|
||||
|
||||
//#when
|
||||
// when
|
||||
const challenge = generateCodeChallenge(verifier)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(challenge).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildAuthorizationUrl", () => {
|
||||
it("builds URL with all required PKCE parameters", () => {
|
||||
//#given
|
||||
// given
|
||||
const endpoint = "https://auth.example.com/authorize"
|
||||
|
||||
//#when
|
||||
// when
|
||||
const url = buildAuthorizationUrl(endpoint, {
|
||||
clientId: "my-client",
|
||||
redirectUri: "http://127.0.0.1:8912/callback",
|
||||
@@ -58,7 +58,7 @@ describe("McpOAuthProvider", () => {
|
||||
resource: "https://mcp.example.com",
|
||||
})
|
||||
|
||||
//#then
|
||||
// then
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.origin + parsed.pathname).toBe("https://auth.example.com/authorize")
|
||||
expect(parsed.searchParams.get("response_type")).toBe("code")
|
||||
@@ -72,10 +72,10 @@ describe("McpOAuthProvider", () => {
|
||||
})
|
||||
|
||||
it("omits scope when empty", () => {
|
||||
//#given
|
||||
// given
|
||||
const endpoint = "https://auth.example.com/authorize"
|
||||
|
||||
//#when
|
||||
// when
|
||||
const url = buildAuthorizationUrl(endpoint, {
|
||||
clientId: "my-client",
|
||||
redirectUri: "http://127.0.0.1:8912/callback",
|
||||
@@ -84,16 +84,16 @@ describe("McpOAuthProvider", () => {
|
||||
scopes: [],
|
||||
})
|
||||
|
||||
//#then
|
||||
// then
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.has("scope")).toBe(false)
|
||||
})
|
||||
|
||||
it("omits resource when undefined", () => {
|
||||
//#given
|
||||
// given
|
||||
const endpoint = "https://auth.example.com/authorize"
|
||||
|
||||
//#when
|
||||
// when
|
||||
const url = buildAuthorizationUrl(endpoint, {
|
||||
clientId: "my-client",
|
||||
redirectUri: "http://127.0.0.1:8912/callback",
|
||||
@@ -101,7 +101,7 @@ describe("McpOAuthProvider", () => {
|
||||
state: "state-value",
|
||||
})
|
||||
|
||||
//#then
|
||||
// then
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.has("resource")).toBe(false)
|
||||
})
|
||||
@@ -109,43 +109,43 @@ describe("McpOAuthProvider", () => {
|
||||
|
||||
describe("constructor and basic methods", () => {
|
||||
it("stores serverUrl and optional clientId and scopes", () => {
|
||||
//#given
|
||||
// given
|
||||
const options = {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
clientId: "my-client",
|
||||
scopes: ["openid"],
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const provider = new McpOAuthProvider(options)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(provider.tokens()).toBeNull()
|
||||
expect(provider.clientInformation()).toBeNull()
|
||||
expect(provider.codeVerifier()).toBeNull()
|
||||
})
|
||||
|
||||
it("defaults scopes to empty array", () => {
|
||||
//#given
|
||||
// given
|
||||
const options = { serverUrl: "https://mcp.example.com" }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const provider = new McpOAuthProvider(options)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(provider.redirectUrl()).toBe("http://127.0.0.1:19877/callback")
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveCodeVerifier / codeVerifier", () => {
|
||||
it("stores and retrieves code verifier", () => {
|
||||
//#given
|
||||
// given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
|
||||
//#when
|
||||
// when
|
||||
provider.saveCodeVerifier("my-verifier")
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(provider.codeVerifier()).toBe("my-verifier")
|
||||
})
|
||||
})
|
||||
@@ -172,7 +172,7 @@ describe("McpOAuthProvider", () => {
|
||||
})
|
||||
|
||||
it("persists and loads token data via storage", () => {
|
||||
//#given
|
||||
// given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
const tokenData: OAuthTokenData = {
|
||||
accessToken: "access-token-123",
|
||||
@@ -180,11 +180,11 @@ describe("McpOAuthProvider", () => {
|
||||
expiresAt: 1710000000,
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const saved = provider.saveTokens(tokenData)
|
||||
const loaded = provider.tokens()
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(saved).toBe(true)
|
||||
expect(loaded).toEqual(tokenData)
|
||||
})
|
||||
@@ -192,7 +192,7 @@ describe("McpOAuthProvider", () => {
|
||||
|
||||
describe("redirectToAuthorization", () => {
|
||||
it("throws when no client information is set", async () => {
|
||||
//#given
|
||||
// given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
const metadata = {
|
||||
authorizationEndpoint: "https://auth.example.com/authorize",
|
||||
@@ -200,23 +200,23 @@ describe("McpOAuthProvider", () => {
|
||||
resource: "https://mcp.example.com",
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = provider.redirectToAuthorization(metadata)
|
||||
|
||||
//#then
|
||||
// then
|
||||
await expect(result).rejects.toThrow("No client information available")
|
||||
})
|
||||
})
|
||||
|
||||
describe("redirectUrl", () => {
|
||||
it("returns localhost callback URL with default port", () => {
|
||||
//#given
|
||||
// given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
|
||||
//#when
|
||||
// when
|
||||
const url = provider.redirectUrl()
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(url).toBe("http://127.0.0.1:19877/callback")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,118 +3,118 @@ import { addResourceToParams, getResourceIndicator } from "./resource-indicator"
|
||||
|
||||
describe("getResourceIndicator", () => {
|
||||
it("returns URL unchanged when already normalized", () => {
|
||||
// #given
|
||||
// given
|
||||
const url = "https://mcp.example.com"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("strips trailing slash", () => {
|
||||
// #given
|
||||
// given
|
||||
const url = "https://mcp.example.com/"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("strips query parameters", () => {
|
||||
// #given
|
||||
// given
|
||||
const url = "https://mcp.example.com/v1?token=abc&debug=true"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("https://mcp.example.com/v1")
|
||||
})
|
||||
|
||||
it("strips fragment", () => {
|
||||
// #given
|
||||
// given
|
||||
const url = "https://mcp.example.com/v1#section"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("https://mcp.example.com/v1")
|
||||
})
|
||||
|
||||
it("strips query and trailing slash together", () => {
|
||||
// #given
|
||||
// given
|
||||
const url = "https://mcp.example.com/api/?key=val"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("https://mcp.example.com/api")
|
||||
})
|
||||
|
||||
it("preserves path segments", () => {
|
||||
// #given
|
||||
// given
|
||||
const url = "https://mcp.example.com/org/project/v2"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("https://mcp.example.com/org/project/v2")
|
||||
})
|
||||
|
||||
it("preserves port number", () => {
|
||||
// #given
|
||||
// given
|
||||
const url = "https://mcp.example.com:8443/api/"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBe("https://mcp.example.com:8443/api")
|
||||
})
|
||||
})
|
||||
|
||||
describe("addResourceToParams", () => {
|
||||
it("sets resource parameter on empty params", () => {
|
||||
// #given
|
||||
// given
|
||||
const params = new URLSearchParams()
|
||||
const resource = "https://mcp.example.com"
|
||||
|
||||
// #when
|
||||
// when
|
||||
addResourceToParams(params, resource)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(params.get("resource")).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("adds resource alongside existing parameters", () => {
|
||||
// #given
|
||||
// given
|
||||
const params = new URLSearchParams({ grant_type: "authorization_code" })
|
||||
const resource = "https://mcp.example.com/v1"
|
||||
|
||||
// #when
|
||||
// when
|
||||
addResourceToParams(params, resource)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(params.get("grant_type")).toBe("authorization_code")
|
||||
expect(params.get("resource")).toBe("https://mcp.example.com/v1")
|
||||
})
|
||||
|
||||
it("overwrites existing resource parameter", () => {
|
||||
// #given
|
||||
// given
|
||||
const params = new URLSearchParams({ resource: "https://old.example.com" })
|
||||
const resource = "https://new.example.com"
|
||||
|
||||
// #when
|
||||
// when
|
||||
addResourceToParams(params, resource)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(params.get("resource")).toBe("https://new.example.com")
|
||||
expect(params.getAll("resource")).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -4,57 +4,57 @@ import { McpOauthSchema } from "./schema"
|
||||
|
||||
describe("McpOauthSchema", () => {
|
||||
test("parses empty oauth config", () => {
|
||||
//#given
|
||||
// given
|
||||
const input = {}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = McpOauthSchema.parse(input)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("parses oauth config with clientId", () => {
|
||||
//#given
|
||||
// given
|
||||
const input = { clientId: "client-123" }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = McpOauthSchema.parse(input)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ clientId: "client-123" })
|
||||
})
|
||||
|
||||
test("parses oauth config with scopes", () => {
|
||||
//#given
|
||||
// given
|
||||
const input = { scopes: ["openid", "profile"] }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = McpOauthSchema.parse(input)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toEqual({ scopes: ["openid", "profile"] })
|
||||
})
|
||||
|
||||
test("rejects non-string clientId", () => {
|
||||
//#given
|
||||
// given
|
||||
const input = { clientId: 123 }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = McpOauthSchema.safeParse(input)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects non-string scopes", () => {
|
||||
//#given
|
||||
// given
|
||||
const input = { scopes: ["openid", 42] }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = McpOauthSchema.safeParse(input)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,24 +3,24 @@ import { isStepUpRequired, mergeScopes, parseWwwAuthenticate } from "./step-up"
|
||||
|
||||
describe("parseWwwAuthenticate", () => {
|
||||
it("parses scope from simple Bearer header", () => {
|
||||
// #given
|
||||
// given
|
||||
const header = 'Bearer scope="read write"'
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({ requiredScopes: ["read", "write"] })
|
||||
})
|
||||
|
||||
it("parses scope with error fields", () => {
|
||||
// #given
|
||||
// given
|
||||
const header = 'Bearer error="insufficient_scope", scope="admin"'
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
requiredScopes: ["admin"],
|
||||
error: "insufficient_scope",
|
||||
@@ -28,14 +28,14 @@ describe("parseWwwAuthenticate", () => {
|
||||
})
|
||||
|
||||
it("parses all fields including error_description", () => {
|
||||
// #given
|
||||
// given
|
||||
const header =
|
||||
'Bearer realm="example", error="insufficient_scope", error_description="Need admin access", scope="admin write"'
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
requiredScopes: ["admin", "write"],
|
||||
error: "insufficient_scope",
|
||||
@@ -44,180 +44,180 @@ describe("parseWwwAuthenticate", () => {
|
||||
})
|
||||
|
||||
it("returns null for non-Bearer scheme", () => {
|
||||
// #given
|
||||
// given
|
||||
const header = 'Basic realm="example"'
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when no scope parameter present", () => {
|
||||
// #given
|
||||
// given
|
||||
const header = 'Bearer error="invalid_token"'
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for empty scope value", () => {
|
||||
// #given
|
||||
// given
|
||||
const header = 'Bearer scope=""'
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for bare Bearer with no params", () => {
|
||||
// #given
|
||||
// given
|
||||
const header = "Bearer"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("handles case-insensitive Bearer prefix", () => {
|
||||
// #given
|
||||
// given
|
||||
const header = 'bearer scope="read"'
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({ requiredScopes: ["read"] })
|
||||
})
|
||||
|
||||
it("parses single scope value", () => {
|
||||
// #given
|
||||
// given
|
||||
const header = 'Bearer scope="admin"'
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({ requiredScopes: ["admin"] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeScopes", () => {
|
||||
it("merges new scopes into existing", () => {
|
||||
// #given
|
||||
// given
|
||||
const existing = ["read", "write"]
|
||||
const required = ["admin", "write"]
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual(["read", "write", "admin"])
|
||||
})
|
||||
|
||||
it("returns required when existing is empty", () => {
|
||||
// #given
|
||||
// given
|
||||
const existing: string[] = []
|
||||
const required = ["read", "write"]
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual(["read", "write"])
|
||||
})
|
||||
|
||||
it("returns existing when required is empty", () => {
|
||||
// #given
|
||||
// given
|
||||
const existing = ["read"]
|
||||
const required: string[] = []
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual(["read"])
|
||||
})
|
||||
|
||||
it("deduplicates identical scopes", () => {
|
||||
// #given
|
||||
// given
|
||||
const existing = ["read", "write"]
|
||||
const required = ["read", "write"]
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual(["read", "write"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isStepUpRequired", () => {
|
||||
it("returns step-up info for 403 with WWW-Authenticate", () => {
|
||||
// #given
|
||||
// given
|
||||
const statusCode = 403
|
||||
const headers = { "www-authenticate": 'Bearer scope="admin"' }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({ requiredScopes: ["admin"] })
|
||||
})
|
||||
|
||||
it("returns null for non-403 status", () => {
|
||||
// #given
|
||||
// given
|
||||
const statusCode = 401
|
||||
const headers = { "www-authenticate": 'Bearer scope="admin"' }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when no WWW-Authenticate header", () => {
|
||||
// #given
|
||||
// given
|
||||
const statusCode = 403
|
||||
const headers = { "content-type": "application/json" }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("handles capitalized WWW-Authenticate header", () => {
|
||||
// #given
|
||||
// given
|
||||
const statusCode = 403
|
||||
const headers = { "WWW-Authenticate": 'Bearer scope="read write"' }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual({ requiredScopes: ["read", "write"] })
|
||||
})
|
||||
|
||||
it("returns null for 403 with unparseable WWW-Authenticate", () => {
|
||||
// #given
|
||||
// given
|
||||
const statusCode = 403
|
||||
const headers = { "www-authenticate": 'Basic realm="example"' }
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("mcp-oauth storage", () => {
|
||||
})
|
||||
|
||||
test("should save tokens with {host}/{resource} key and set 0600 permissions", () => {
|
||||
// #given
|
||||
// given
|
||||
const token: OAuthTokenData = {
|
||||
accessToken: "access-1",
|
||||
refreshToken: "refresh-1",
|
||||
@@ -44,13 +44,13 @@ describe("mcp-oauth storage", () => {
|
||||
clientInfo: { clientId: "client-1", clientSecret: "secret-1" },
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const success = saveToken("https://example.com:443", "mcp/v1", token)
|
||||
const storagePath = getMcpOauthStoragePath()
|
||||
const parsed = JSON.parse(readFileSync(storagePath, "utf-8")) as Record<string, OAuthTokenData>
|
||||
const mode = statSync(storagePath).mode & 0o777
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(success).toBe(true)
|
||||
expect(Object.keys(parsed)).toEqual(["example.com/mcp/v1"])
|
||||
expect(parsed["example.com/mcp/v1"].accessToken).toBe("access-1")
|
||||
@@ -58,41 +58,41 @@ describe("mcp-oauth storage", () => {
|
||||
})
|
||||
|
||||
test("should load a saved token", () => {
|
||||
// #given
|
||||
// given
|
||||
const token: OAuthTokenData = { accessToken: "access-2", refreshToken: "refresh-2" }
|
||||
saveToken("api.example.com", "resource-a", token)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const loaded = loadToken("api.example.com:8443", "resource-a")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(loaded).toEqual(token)
|
||||
})
|
||||
|
||||
test("should delete a token", () => {
|
||||
// #given
|
||||
// given
|
||||
const token: OAuthTokenData = { accessToken: "access-3" }
|
||||
saveToken("api.example.com", "resource-b", token)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const success = deleteToken("api.example.com", "resource-b")
|
||||
const loaded = loadToken("api.example.com", "resource-b")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(success).toBe(true)
|
||||
expect(loaded).toBeNull()
|
||||
})
|
||||
|
||||
test("should list tokens by host", () => {
|
||||
// #given
|
||||
// given
|
||||
saveToken("api.example.com", "resource-a", { accessToken: "access-a" })
|
||||
saveToken("api.example.com", "resource-b", { accessToken: "access-b" })
|
||||
saveToken("other.example.com", "resource-c", { accessToken: "access-c" })
|
||||
|
||||
// #when
|
||||
// when
|
||||
const entries = listTokensByHost("api.example.com:5555")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(Object.keys(entries).sort()).toEqual([
|
||||
"api.example.com/resource-a",
|
||||
"api.example.com/resource-b",
|
||||
@@ -101,23 +101,23 @@ describe("mcp-oauth storage", () => {
|
||||
})
|
||||
|
||||
test("should handle missing storage file", () => {
|
||||
// #given
|
||||
// given
|
||||
const storagePath = getMcpOauthStoragePath()
|
||||
if (existsSync(storagePath)) {
|
||||
rmSync(storagePath, { force: true })
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const loaded = loadToken("api.example.com", "resource-a")
|
||||
const entries = listTokensByHost("api.example.com")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(loaded).toBeNull()
|
||||
expect(entries).toEqual({})
|
||||
})
|
||||
|
||||
test("should handle invalid JSON", () => {
|
||||
// #given
|
||||
// given
|
||||
const storagePath = getMcpOauthStoragePath()
|
||||
const dir = join(storagePath, "..")
|
||||
if (!existsSync(dir)) {
|
||||
@@ -125,11 +125,11 @@ describe("mcp-oauth storage", () => {
|
||||
}
|
||||
writeFileSync(storagePath, "{not-valid-json", "utf-8")
|
||||
|
||||
// #when
|
||||
// when
|
||||
const loaded = loadToken("api.example.com", "resource-a")
|
||||
const entries = listTokensByHost("api.example.com")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(loaded).toBeNull()
|
||||
expect(entries).toEqual({})
|
||||
})
|
||||
|
||||
@@ -36,19 +36,19 @@ describe("async-loader", () => {
|
||||
|
||||
describe("discoverSkillsInDirAsync", () => {
|
||||
it("returns empty array for non-existent directory", async () => {
|
||||
// #given - non-existent directory
|
||||
// given - non-existent directory
|
||||
const nonExistentDir = join(TEST_DIR, "does-not-exist")
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(nonExistentDir)
|
||||
|
||||
// #then - should return empty array, not throw
|
||||
// then - should return empty array, not throw
|
||||
expect(skills).toEqual([])
|
||||
})
|
||||
|
||||
it("discovers skills from SKILL.md in directory", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
@@ -57,18 +57,18 @@ This is the skill body.
|
||||
`
|
||||
createTestSkill("test-skill", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("test-skill")
|
||||
expect(skills[0].definition.description).toContain("A test skill")
|
||||
})
|
||||
|
||||
it("discovers skills from {name}.md pattern in directory", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: named-skill
|
||||
description: Named pattern skill
|
||||
@@ -79,17 +79,17 @@ Skill body.
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, "named-skill.md"), skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("named-skill")
|
||||
})
|
||||
|
||||
it("discovers direct .md files", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: direct-skill
|
||||
description: Direct markdown file
|
||||
@@ -98,17 +98,17 @@ Direct skill.
|
||||
`
|
||||
createDirectSkill("direct-skill", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("direct-skill")
|
||||
})
|
||||
|
||||
it("skips entries starting with dot", async () => {
|
||||
// #given
|
||||
// given
|
||||
const validContent = `---
|
||||
name: valid-skill
|
||||
---
|
||||
@@ -122,17 +122,17 @@ Hidden.
|
||||
createTestSkill("valid-skill", validContent)
|
||||
createTestSkill(".hidden-skill", hiddenContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - only valid-skill should be discovered
|
||||
// then - only valid-skill should be discovered
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0]?.name).toBe("valid-skill")
|
||||
})
|
||||
|
||||
it("skips invalid files and continues with valid ones", async () => {
|
||||
// #given - one valid, one invalid (unreadable)
|
||||
// given - one valid, one invalid (unreadable)
|
||||
const validContent = `---
|
||||
name: valid-skill
|
||||
---
|
||||
@@ -152,11 +152,11 @@ Invalid skill.
|
||||
chmodSync(invalidFile, 0o000)
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - should skip invalid and return only valid
|
||||
// then - should skip invalid and return only valid
|
||||
expect(skills.length).toBeGreaterThanOrEqual(1)
|
||||
expect(skills.some((s: LoadedSkill) => s.name === "valid-skill")).toBe(true)
|
||||
|
||||
@@ -167,7 +167,7 @@ Invalid skill.
|
||||
})
|
||||
|
||||
it("discovers multiple skills correctly", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skill1 = `---
|
||||
name: skill-one
|
||||
description: First skill
|
||||
@@ -183,11 +183,11 @@ Skill two.
|
||||
createTestSkill("skill-one", skill1)
|
||||
createTestSkill("skill-two", skill2)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const asyncSkills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(asyncSkills.length).toBe(2)
|
||||
expect(asyncSkills.map((s: LoadedSkill) => s.name).sort()).toEqual(["skill-one", "skill-two"])
|
||||
|
||||
@@ -196,7 +196,7 @@ Skill two.
|
||||
})
|
||||
|
||||
it("loads MCP config from frontmatter", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: mcp-skill
|
||||
description: Skill with MCP
|
||||
@@ -209,11 +209,11 @@ MCP skill.
|
||||
`
|
||||
createTestSkill("mcp-skill", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "mcp-skill")
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
@@ -221,7 +221,7 @@ MCP skill.
|
||||
})
|
||||
|
||||
it("loads MCP config from mcp.json file", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: json-mcp-skill
|
||||
description: Skill with mcp.json
|
||||
@@ -238,18 +238,18 @@ Skill body.
|
||||
}
|
||||
createTestSkill("json-mcp-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "json-mcp-skill")
|
||||
expect(skill?.mcpConfig?.playwright).toBeDefined()
|
||||
expect(skill?.mcpConfig?.playwright?.command).toBe("npx")
|
||||
})
|
||||
|
||||
it("prioritizes mcp.json over frontmatter MCP", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: priority-test
|
||||
mcp:
|
||||
@@ -267,11 +267,11 @@ Skill.
|
||||
}
|
||||
createTestSkill("priority-test", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - mcp.json should take priority
|
||||
// then - mcp.json should take priority
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "priority-test")
|
||||
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
|
||||
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
|
||||
@@ -280,7 +280,7 @@ Skill.
|
||||
|
||||
describe("mapWithConcurrency", () => {
|
||||
it("processes items with concurrency limit", async () => {
|
||||
// #given
|
||||
// given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
const items = Array.from({ length: 50 }, (_, i) => i)
|
||||
let maxConcurrent = 0
|
||||
@@ -294,41 +294,41 @@ Skill.
|
||||
return item * 2
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const results = await mapWithConcurrency(items, mapper, 16)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(results).toEqual(items.map(i => i * 2))
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(16)
|
||||
expect(maxConcurrent).toBeGreaterThan(1) // Should actually run concurrently
|
||||
})
|
||||
|
||||
it("handles empty array", async () => {
|
||||
// #given
|
||||
// given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
|
||||
// #when
|
||||
// when
|
||||
const results = await mapWithConcurrency([], async (x: number) => x * 2, 16)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it("handles single item", async () => {
|
||||
// #given
|
||||
// given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
|
||||
// #when
|
||||
// when
|
||||
const results = await mapWithConcurrency([42], async (x: number) => x * 2, 16)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(results).toEqual([84])
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadSkillFromPathAsync", () => {
|
||||
it("loads skill from valid path", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: path-skill
|
||||
description: Loaded from path
|
||||
@@ -338,47 +338,47 @@ Path skill.
|
||||
const skillDir = createTestSkill("path-skill", skillContent)
|
||||
const skillPath = join(skillDir, "SKILL.md")
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(skillPath, skillDir, "path-skill", "opencode-project")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).not.toBeNull()
|
||||
expect(skill?.name).toBe("path-skill")
|
||||
expect(skill?.scope).toBe("opencode-project")
|
||||
})
|
||||
|
||||
it("returns null for invalid path", async () => {
|
||||
// #given
|
||||
// given
|
||||
const invalidPath = join(TEST_DIR, "nonexistent.md")
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(invalidPath, TEST_DIR, "invalid", "opencode")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for malformed skill file", async () => {
|
||||
// #given
|
||||
// given
|
||||
const malformedContent = "This is not valid frontmatter content\nNo YAML here!"
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const malformedPath = join(SKILLS_DIR, "malformed.md")
|
||||
writeFileSync(malformedPath, malformedContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(malformedPath, SKILLS_DIR, "malformed", "user")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).not.toBeNull() // parseFrontmatter handles missing frontmatter gracefully
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadMcpJsonFromDirAsync", () => {
|
||||
it("loads mcp.json with mcpServers format", async () => {
|
||||
// #given
|
||||
// given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
@@ -390,43 +390,43 @@ Path skill.
|
||||
}
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.test).toBeDefined()
|
||||
expect(config?.test?.command).toBe("test-cmd")
|
||||
})
|
||||
|
||||
it("returns undefined for non-existent mcp.json", async () => {
|
||||
// #given
|
||||
// given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined for invalid JSON", async () => {
|
||||
// #given
|
||||
// given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), "{ invalid json }")
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
|
||||
it("supports direct format without mcpServers", async () => {
|
||||
// #given
|
||||
// given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const mcpJson = {
|
||||
direct: {
|
||||
@@ -436,11 +436,11 @@ Path skill.
|
||||
}
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(config?.direct).toBeDefined()
|
||||
expect(config?.direct?.command).toBe("direct-cmd")
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ afterEach(() => {
|
||||
|
||||
describe("discoverAllSkillsBlocking", () => {
|
||||
it("returns skills synchronously from valid directories", () => {
|
||||
// #given valid skill directory
|
||||
// given valid skill directory
|
||||
const skillDir = join(TEST_DIR, "skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
@@ -34,10 +34,10 @@ This is test skill content.`
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
// when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns skills synchronously
|
||||
// then returns skills synchronously
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("test-skill")
|
||||
@@ -45,38 +45,38 @@ This is test skill content.`
|
||||
})
|
||||
|
||||
it("returns empty array for empty directories", () => {
|
||||
// #given empty directory
|
||||
// given empty directory
|
||||
const emptyDir = join(TEST_DIR, "empty")
|
||||
mkdirSync(emptyDir, { recursive: true })
|
||||
|
||||
const dirs = [emptyDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
// when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns empty array
|
||||
// then returns empty array
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(0)
|
||||
})
|
||||
|
||||
it("returns empty array for non-existent directories", () => {
|
||||
// #given non-existent directory
|
||||
// given non-existent directory
|
||||
const nonExistentDir = join(TEST_DIR, "does-not-exist")
|
||||
|
||||
const dirs = [nonExistentDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
// when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns empty array (no throw)
|
||||
// then returns empty array (no throw)
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(0)
|
||||
})
|
||||
|
||||
it("handles multiple directories with mixed content", () => {
|
||||
// #given multiple directories with valid and invalid skills
|
||||
// given multiple directories with valid and invalid skills
|
||||
const dir1 = join(TEST_DIR, "dir1")
|
||||
const dir2 = join(TEST_DIR, "dir2")
|
||||
mkdirSync(dir1, { recursive: true })
|
||||
@@ -103,10 +103,10 @@ Skill 2 content.`
|
||||
const dirs = [dir1, dir2]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
// when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns all valid skills
|
||||
// then returns all valid skills
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(2)
|
||||
|
||||
@@ -115,7 +115,7 @@ Skill 2 content.`
|
||||
})
|
||||
|
||||
it("skips invalid YAML files", () => {
|
||||
// #given directory with invalid YAML
|
||||
// given directory with invalid YAML
|
||||
const skillDir = join(TEST_DIR, "skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
@@ -142,17 +142,17 @@ Invalid content.`
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
// when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then skips invalid, returns valid
|
||||
// then skips invalid, returns valid
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("valid-skill")
|
||||
})
|
||||
|
||||
it("handles directory-based skills with SKILL.md", () => {
|
||||
// #given directory-based skill structure
|
||||
// given directory-based skill structure
|
||||
const skillsDir = join(TEST_DIR, "skills")
|
||||
const mySkillDir = join(skillsDir, "my-skill")
|
||||
mkdirSync(mySkillDir, { recursive: true })
|
||||
@@ -170,17 +170,17 @@ This is a directory-based skill.`
|
||||
const dirs = [skillsDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
// when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns skill from SKILL.md
|
||||
// then returns skill from SKILL.md
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("my-skill")
|
||||
})
|
||||
|
||||
it("processes large skill sets without timeout", () => {
|
||||
// #given directory with many skills (20+)
|
||||
// given directory with many skills (20+)
|
||||
const skillDir = join(TEST_DIR, "many-skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
@@ -200,10 +200,10 @@ Content for skill ${i}.`
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
// when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then completes without timeout
|
||||
// then completes without timeout
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(skillCount)
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("skill loader MCP parsing", () => {
|
||||
|
||||
describe("parseSkillMcpConfig", () => {
|
||||
it("parses skill with nested MCP config", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: test-skill
|
||||
description: A test skill with MCP
|
||||
@@ -47,7 +47,7 @@ This is the skill body.
|
||||
`
|
||||
createTestSkill("test-mcp-skill", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -56,7 +56,7 @@ This is the skill body.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "test-skill")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
@@ -74,7 +74,7 @@ This is the skill body.
|
||||
})
|
||||
|
||||
it("returns undefined mcpConfig for skill without MCP", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: simple-skill
|
||||
description: A simple skill without MCP
|
||||
@@ -83,7 +83,7 @@ This is a simple skill.
|
||||
`
|
||||
createTestSkill("simple-skill", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -92,7 +92,7 @@ This is a simple skill.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "simple-skill")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeUndefined()
|
||||
} finally {
|
||||
@@ -101,7 +101,7 @@ This is a simple skill.
|
||||
})
|
||||
|
||||
it("preserves env var placeholders without expansion", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: env-skill
|
||||
mcp:
|
||||
@@ -116,7 +116,7 @@ Skill with env vars.
|
||||
`
|
||||
createTestSkill("env-skill", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -125,7 +125,7 @@ Skill with env vars.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "env-skill")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill?.mcpConfig?.["api-server"]?.env?.API_KEY).toBe("${API_KEY}")
|
||||
expect(skill?.mcpConfig?.["api-server"]?.env?.DB_PATH).toBe("${HOME}/data.db")
|
||||
} finally {
|
||||
@@ -134,7 +134,7 @@ Skill with env vars.
|
||||
})
|
||||
|
||||
it("handles malformed YAML gracefully", async () => {
|
||||
// #given - malformed YAML causes entire frontmatter to fail parsing
|
||||
// given - malformed YAML causes entire frontmatter to fail parsing
|
||||
const skillContent = `---
|
||||
name: bad-yaml
|
||||
mcp: [this is not valid yaml for mcp
|
||||
@@ -143,14 +143,14 @@ Skill body.
|
||||
`
|
||||
createTestSkill("bad-yaml-skill", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
// #then - when YAML fails, skill uses directory name as fallback
|
||||
// then - when YAML fails, skill uses directory name as fallback
|
||||
const skill = skills.find(s => s.name === "bad-yaml-skill")
|
||||
|
||||
expect(skill).toBeDefined()
|
||||
@@ -163,7 +163,7 @@ Skill body.
|
||||
|
||||
describe("mcp.json file loading (AmpCode compat)", () => {
|
||||
it("loads MCP config from mcp.json with mcpServers format", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: ampcode-skill
|
||||
description: Skill with mcp.json
|
||||
@@ -180,7 +180,7 @@ Skill body.
|
||||
}
|
||||
createTestSkill("ampcode-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -189,7 +189,7 @@ Skill body.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "ampcode-skill")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.playwright).toBeDefined()
|
||||
@@ -201,7 +201,7 @@ Skill body.
|
||||
})
|
||||
|
||||
it("mcp.json takes priority over YAML frontmatter", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: priority-skill
|
||||
mcp:
|
||||
@@ -221,7 +221,7 @@ Skill body.
|
||||
}
|
||||
createTestSkill("priority-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -230,7 +230,7 @@ Skill body.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "priority-skill")
|
||||
|
||||
// #then - mcp.json should take priority
|
||||
// then - mcp.json should take priority
|
||||
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
|
||||
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
|
||||
} finally {
|
||||
@@ -239,7 +239,7 @@ Skill body.
|
||||
})
|
||||
|
||||
it("supports direct format without mcpServers wrapper", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: direct-format
|
||||
---
|
||||
@@ -253,7 +253,7 @@ Skill body.
|
||||
}
|
||||
createTestSkill("direct-format", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -262,7 +262,7 @@ Skill body.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "direct-format")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
|
||||
} finally {
|
||||
@@ -273,7 +273,7 @@ Skill body.
|
||||
|
||||
describe("allowed-tools parsing", () => {
|
||||
it("parses space-separated allowed-tools string", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: space-separated-tools
|
||||
description: Skill with space-separated allowed-tools
|
||||
@@ -283,7 +283,7 @@ Skill body.
|
||||
`
|
||||
createTestSkill("space-separated-tools", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -292,7 +292,7 @@ Skill body.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "space-separated-tools")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
|
||||
} finally {
|
||||
@@ -301,7 +301,7 @@ Skill body.
|
||||
})
|
||||
|
||||
it("parses YAML inline array allowed-tools", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: yaml-inline-array
|
||||
description: Skill with YAML inline array allowed-tools
|
||||
@@ -311,7 +311,7 @@ Skill body.
|
||||
`
|
||||
createTestSkill("yaml-inline-array", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -320,7 +320,7 @@ Skill body.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "yaml-inline-array")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
|
||||
} finally {
|
||||
@@ -329,7 +329,7 @@ Skill body.
|
||||
})
|
||||
|
||||
it("parses YAML multi-line array allowed-tools", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: yaml-multiline-array
|
||||
description: Skill with YAML multi-line array allowed-tools
|
||||
@@ -343,7 +343,7 @@ Skill body.
|
||||
`
|
||||
createTestSkill("yaml-multiline-array", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -352,7 +352,7 @@ Skill body.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "yaml-multiline-array")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
|
||||
} finally {
|
||||
@@ -361,7 +361,7 @@ Skill body.
|
||||
})
|
||||
|
||||
it("returns undefined for skill without allowed-tools", async () => {
|
||||
// #given
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: no-allowed-tools
|
||||
description: Skill without allowed-tools field
|
||||
@@ -370,7 +370,7 @@ Skill body.
|
||||
`
|
||||
createTestSkill("no-allowed-tools", skillContent)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
@@ -379,7 +379,7 @@ Skill body.
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "no-allowed-tools")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.allowedTools).toBeUndefined()
|
||||
} finally {
|
||||
|
||||
@@ -3,55 +3,55 @@ import { resolveSkillContent, resolveMultipleSkills, resolveSkillContentAsync, r
|
||||
|
||||
describe("resolveSkillContent", () => {
|
||||
it("should return template for existing skill", () => {
|
||||
// #given: builtin skills with 'frontend-ui-ux' skill
|
||||
// #when: resolving content for 'frontend-ui-ux'
|
||||
// given: builtin skills with 'frontend-ui-ux' skill
|
||||
// when: resolving content for 'frontend-ui-ux'
|
||||
const result = resolveSkillContent("frontend-ui-ux")
|
||||
|
||||
// #then: returns template string
|
||||
// then: returns template string
|
||||
expect(result).not.toBeNull()
|
||||
expect(typeof result).toBe("string")
|
||||
expect(result).toContain("Role: Designer-Turned-Developer")
|
||||
})
|
||||
|
||||
it("should return template for 'playwright' skill", () => {
|
||||
// #given: builtin skills with 'playwright' skill
|
||||
// #when: resolving content for 'playwright'
|
||||
// given: builtin skills with 'playwright' skill
|
||||
// when: resolving content for 'playwright'
|
||||
const result = resolveSkillContent("playwright")
|
||||
|
||||
// #then: returns template string
|
||||
// then: returns template string
|
||||
expect(result).not.toBeNull()
|
||||
expect(typeof result).toBe("string")
|
||||
expect(result).toContain("Playwright Browser Automation")
|
||||
})
|
||||
|
||||
it("should return null for non-existent skill", () => {
|
||||
// #given: builtin skills without 'nonexistent' skill
|
||||
// #when: resolving content for 'nonexistent'
|
||||
// given: builtin skills without 'nonexistent' skill
|
||||
// when: resolving content for 'nonexistent'
|
||||
const result = resolveSkillContent("nonexistent")
|
||||
|
||||
// #then: returns null
|
||||
// then: returns null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for empty string", () => {
|
||||
// #given: builtin skills
|
||||
// #when: resolving content for empty string
|
||||
// given: builtin skills
|
||||
// when: resolving content for empty string
|
||||
const result = resolveSkillContent("")
|
||||
|
||||
// #then: returns null
|
||||
// then: returns null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveMultipleSkills", () => {
|
||||
it("should resolve all existing skills", () => {
|
||||
// #given: list of existing skill names
|
||||
// given: list of existing skill names
|
||||
const skillNames = ["frontend-ui-ux", "playwright"]
|
||||
|
||||
// #when: resolving multiple skills
|
||||
// when: resolving multiple skills
|
||||
const result = resolveMultipleSkills(skillNames)
|
||||
|
||||
// #then: all skills resolved, none not found
|
||||
// then: all skills resolved, none not found
|
||||
expect(result.resolved.size).toBe(2)
|
||||
expect(result.notFound).toEqual([])
|
||||
expect(result.resolved.get("frontend-ui-ux")).toContain("Designer-Turned-Developer")
|
||||
@@ -59,13 +59,13 @@ describe("resolveMultipleSkills", () => {
|
||||
})
|
||||
|
||||
it("should handle partial success - some skills not found", () => {
|
||||
// #given: list with existing and non-existing skills
|
||||
// given: list with existing and non-existing skills
|
||||
const skillNames = ["frontend-ui-ux", "nonexistent", "playwright", "another-missing"]
|
||||
|
||||
// #when: resolving multiple skills
|
||||
// when: resolving multiple skills
|
||||
const result = resolveMultipleSkills(skillNames)
|
||||
|
||||
// #then: resolves existing skills, lists not found skills
|
||||
// then: resolves existing skills, lists not found skills
|
||||
expect(result.resolved.size).toBe(2)
|
||||
expect(result.notFound).toEqual(["nonexistent", "another-missing"])
|
||||
expect(result.resolved.get("frontend-ui-ux")).toContain("Designer-Turned-Developer")
|
||||
@@ -73,37 +73,37 @@ describe("resolveMultipleSkills", () => {
|
||||
})
|
||||
|
||||
it("should handle empty array", () => {
|
||||
// #given: empty skill names list
|
||||
// given: empty skill names list
|
||||
const skillNames: string[] = []
|
||||
|
||||
// #when: resolving multiple skills
|
||||
// when: resolving multiple skills
|
||||
const result = resolveMultipleSkills(skillNames)
|
||||
|
||||
// #then: returns empty resolved and notFound
|
||||
// then: returns empty resolved and notFound
|
||||
expect(result.resolved.size).toBe(0)
|
||||
expect(result.notFound).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle all skills not found", () => {
|
||||
// #given: list of non-existing skills
|
||||
// given: list of non-existing skills
|
||||
const skillNames = ["skill-one", "skill-two", "skill-three"]
|
||||
|
||||
// #when: resolving multiple skills
|
||||
// when: resolving multiple skills
|
||||
const result = resolveMultipleSkills(skillNames)
|
||||
|
||||
// #then: no skills resolved, all in notFound
|
||||
// then: no skills resolved, all in notFound
|
||||
expect(result.resolved.size).toBe(0)
|
||||
expect(result.notFound).toEqual(["skill-one", "skill-two", "skill-three"])
|
||||
})
|
||||
|
||||
it("should preserve skill order in resolved map", () => {
|
||||
// #given: list of skill names in specific order
|
||||
// given: list of skill names in specific order
|
||||
const skillNames = ["playwright", "frontend-ui-ux"]
|
||||
|
||||
// #when: resolving multiple skills
|
||||
// when: resolving multiple skills
|
||||
const result = resolveMultipleSkills(skillNames)
|
||||
|
||||
// #then: map contains skills with expected keys
|
||||
// then: map contains skills with expected keys
|
||||
expect(result.resolved.has("playwright")).toBe(true)
|
||||
expect(result.resolved.has("frontend-ui-ux")).toBe(true)
|
||||
expect(result.resolved.size).toBe(2)
|
||||
@@ -112,35 +112,35 @@ describe("resolveMultipleSkills", () => {
|
||||
|
||||
describe("resolveSkillContentAsync", () => {
|
||||
it("should return template for builtin skill", async () => {
|
||||
// #given: builtin skill 'frontend-ui-ux'
|
||||
// #when: resolving content async
|
||||
// given: builtin skill 'frontend-ui-ux'
|
||||
// when: resolving content async
|
||||
const result = await resolveSkillContentAsync("frontend-ui-ux")
|
||||
|
||||
// #then: returns template string
|
||||
// then: returns template string
|
||||
expect(result).not.toBeNull()
|
||||
expect(typeof result).toBe("string")
|
||||
expect(result).toContain("Role: Designer-Turned-Developer")
|
||||
})
|
||||
|
||||
it("should return null for non-existent skill", async () => {
|
||||
// #given: non-existent skill name
|
||||
// #when: resolving content async
|
||||
// given: non-existent skill name
|
||||
// when: resolving content async
|
||||
const result = await resolveSkillContentAsync("definitely-not-a-skill-12345")
|
||||
|
||||
// #then: returns null
|
||||
// then: returns null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveMultipleSkillsAsync", () => {
|
||||
it("should resolve builtin skills", async () => {
|
||||
// #given: builtin skill names
|
||||
// given: builtin skill names
|
||||
const skillNames = ["playwright", "frontend-ui-ux"]
|
||||
|
||||
// #when: resolving multiple skills async
|
||||
// when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: all builtin skills resolved
|
||||
// then: all builtin skills resolved
|
||||
expect(result.resolved.size).toBe(2)
|
||||
expect(result.notFound).toEqual([])
|
||||
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
|
||||
@@ -148,20 +148,20 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
})
|
||||
|
||||
it("should handle partial success with non-existent skills", async () => {
|
||||
// #given: mix of existing and non-existing skills
|
||||
// given: mix of existing and non-existing skills
|
||||
const skillNames = ["playwright", "nonexistent-skill-12345"]
|
||||
|
||||
// #when: resolving multiple skills async
|
||||
// when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: existing skills resolved, non-existing in notFound
|
||||
// then: existing skills resolved, non-existing in notFound
|
||||
expect(result.resolved.size).toBe(1)
|
||||
expect(result.notFound).toEqual(["nonexistent-skill-12345"])
|
||||
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
|
||||
})
|
||||
|
||||
it("should NOT inject watermark when both options are disabled", async () => {
|
||||
// #given: git-master skill with watermark disabled
|
||||
// given: git-master skill with watermark disabled
|
||||
const skillNames = ["git-master"]
|
||||
const options = {
|
||||
gitMasterConfig: {
|
||||
@@ -170,10 +170,10 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
},
|
||||
}
|
||||
|
||||
// #when: resolving with git-master config
|
||||
// when: resolving with git-master config
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, options)
|
||||
|
||||
// #then: no watermark section injected
|
||||
// then: no watermark section injected
|
||||
expect(result.resolved.size).toBe(1)
|
||||
expect(result.notFound).toEqual([])
|
||||
const gitMasterContent = result.resolved.get("git-master")
|
||||
@@ -182,7 +182,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
})
|
||||
|
||||
it("should inject watermark when enabled (default)", async () => {
|
||||
// #given: git-master skill with default config (watermark enabled)
|
||||
// given: git-master skill with default config (watermark enabled)
|
||||
const skillNames = ["git-master"]
|
||||
const options = {
|
||||
gitMasterConfig: {
|
||||
@@ -191,10 +191,10 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
},
|
||||
}
|
||||
|
||||
// #when: resolving with git-master config
|
||||
// when: resolving with git-master config
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, options)
|
||||
|
||||
// #then: watermark section is injected
|
||||
// then: watermark section is injected
|
||||
expect(result.resolved.size).toBe(1)
|
||||
const gitMasterContent = result.resolved.get("git-master")
|
||||
expect(gitMasterContent).toContain("Ultraworked with [Sisyphus]")
|
||||
@@ -202,7 +202,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
})
|
||||
|
||||
it("should inject only footer when co-author is disabled", async () => {
|
||||
// #given: git-master skill with only footer enabled
|
||||
// given: git-master skill with only footer enabled
|
||||
const skillNames = ["git-master"]
|
||||
const options = {
|
||||
gitMasterConfig: {
|
||||
@@ -211,23 +211,23 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
},
|
||||
}
|
||||
|
||||
// #when: resolving with git-master config
|
||||
// when: resolving with git-master config
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, options)
|
||||
|
||||
// #then: only footer is injected
|
||||
// then: only footer is injected
|
||||
const gitMasterContent = result.resolved.get("git-master")
|
||||
expect(gitMasterContent).toContain("Ultraworked with [Sisyphus]")
|
||||
expect(gitMasterContent).not.toContain("Co-authored-by: Sisyphus")
|
||||
})
|
||||
|
||||
it("should inject watermark by default when no config provided", async () => {
|
||||
// #given: git-master skill with NO config (default behavior)
|
||||
// given: git-master skill with NO config (default behavior)
|
||||
const skillNames = ["git-master"]
|
||||
|
||||
// #when: resolving without any gitMasterConfig
|
||||
// when: resolving without any gitMasterConfig
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: watermark is injected (default is ON)
|
||||
// then: watermark is injected (default is ON)
|
||||
expect(result.resolved.size).toBe(1)
|
||||
const gitMasterContent = result.resolved.get("git-master")
|
||||
expect(gitMasterContent).toContain("Ultraworked with [Sisyphus]")
|
||||
@@ -235,7 +235,7 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
})
|
||||
|
||||
it("should inject only co-author when footer is disabled", async () => {
|
||||
// #given: git-master skill with only co-author enabled
|
||||
// given: git-master skill with only co-author enabled
|
||||
const skillNames = ["git-master"]
|
||||
const options = {
|
||||
gitMasterConfig: {
|
||||
@@ -244,23 +244,23 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
},
|
||||
}
|
||||
|
||||
// #when: resolving with git-master config
|
||||
// when: resolving with git-master config
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, options)
|
||||
|
||||
// #then: only co-author is injected
|
||||
// then: only co-author is injected
|
||||
const gitMasterContent = result.resolved.get("git-master")
|
||||
expect(gitMasterContent).not.toContain("Ultraworked with [Sisyphus]")
|
||||
expect(gitMasterContent).toContain("Co-authored-by: Sisyphus")
|
||||
})
|
||||
|
||||
it("should handle empty array", async () => {
|
||||
// #given: empty skill names
|
||||
// given: empty skill names
|
||||
const skillNames: string[] = []
|
||||
|
||||
// #when: resolving multiple skills async
|
||||
// when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: empty results
|
||||
// then: empty results
|
||||
expect(result.resolved.size).toBe(0)
|
||||
expect(result.notFound).toEqual([])
|
||||
})
|
||||
@@ -268,62 +268,62 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
|
||||
describe("resolveSkillContent with browserProvider", () => {
|
||||
it("should resolve agent-browser skill when browserProvider is 'agent-browser'", () => {
|
||||
// #given: browserProvider set to agent-browser
|
||||
// given: browserProvider set to agent-browser
|
||||
const options = { browserProvider: "agent-browser" as const }
|
||||
|
||||
// #when: resolving content for 'agent-browser'
|
||||
// when: resolving content for 'agent-browser'
|
||||
const result = resolveSkillContent("agent-browser", options)
|
||||
|
||||
// #then: returns agent-browser template
|
||||
// then: returns agent-browser template
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toContain("agent-browser")
|
||||
})
|
||||
|
||||
it("should return null for agent-browser when browserProvider is default", () => {
|
||||
// #given: no browserProvider (defaults to playwright)
|
||||
// given: no browserProvider (defaults to playwright)
|
||||
|
||||
// #when: resolving content for 'agent-browser'
|
||||
// when: resolving content for 'agent-browser'
|
||||
const result = resolveSkillContent("agent-browser")
|
||||
|
||||
// #then: returns null because agent-browser is not in default builtin skills
|
||||
// then: returns null because agent-browser is not in default builtin skills
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for playwright when browserProvider is agent-browser", () => {
|
||||
// #given: browserProvider set to agent-browser
|
||||
// given: browserProvider set to agent-browser
|
||||
const options = { browserProvider: "agent-browser" as const }
|
||||
|
||||
// #when: resolving content for 'playwright'
|
||||
// when: resolving content for 'playwright'
|
||||
const result = resolveSkillContent("playwright", options)
|
||||
|
||||
// #then: returns null because playwright is replaced by agent-browser
|
||||
// then: returns null because playwright is replaced by agent-browser
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveMultipleSkills with browserProvider", () => {
|
||||
it("should resolve agent-browser when browserProvider is set", () => {
|
||||
// #given: agent-browser and git-master requested with browserProvider
|
||||
// given: agent-browser and git-master requested with browserProvider
|
||||
const skillNames = ["agent-browser", "git-master"]
|
||||
const options = { browserProvider: "agent-browser" as const }
|
||||
|
||||
// #when: resolving multiple skills
|
||||
// when: resolving multiple skills
|
||||
const result = resolveMultipleSkills(skillNames, options)
|
||||
|
||||
// #then: both resolved
|
||||
// then: both resolved
|
||||
expect(result.resolved.has("agent-browser")).toBe(true)
|
||||
expect(result.resolved.has("git-master")).toBe(true)
|
||||
expect(result.notFound).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should not resolve agent-browser without browserProvider option", () => {
|
||||
// #given: agent-browser requested without browserProvider
|
||||
// given: agent-browser requested without browserProvider
|
||||
const skillNames = ["agent-browser"]
|
||||
|
||||
// #when: resolving multiple skills
|
||||
// when: resolving multiple skills
|
||||
const result = resolveMultipleSkills(skillNames)
|
||||
|
||||
// #then: agent-browser not found
|
||||
// then: agent-browser not found
|
||||
expect(result.resolved.has("agent-browser")).toBe(false)
|
||||
expect(result.notFound).toContain("agent-browser")
|
||||
})
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
} from "./types"
|
||||
|
||||
describe("MailboxMessageSchema", () => {
|
||||
//#given a valid mailbox message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
// given a valid mailbox message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses valid message", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
@@ -23,9 +23,9 @@ describe("MailboxMessageSchema", () => {
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given a message with optional color
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
// given a message with optional color
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses message with color", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
@@ -39,9 +39,9 @@ describe("MailboxMessageSchema", () => {
|
||||
})
|
||||
|
||||
describe("ProtocolMessageSchema", () => {
|
||||
//#given permission_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
// given permission_request message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses permission_request", () => {
|
||||
const msg = {
|
||||
type: "permission_request",
|
||||
@@ -54,9 +54,9 @@ describe("ProtocolMessageSchema", () => {
|
||||
expect(PermissionRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given permission_response message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
// given permission_response message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses permission_response", () => {
|
||||
const approved = {
|
||||
type: "permission_response",
|
||||
@@ -75,17 +75,17 @@ describe("ProtocolMessageSchema", () => {
|
||||
expect(PermissionResponseSchema.safeParse(rejected).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given shutdown_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
// given shutdown_request message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses shutdown messages", () => {
|
||||
const request = { type: "shutdown_request" }
|
||||
expect(ShutdownRequestSchema.safeParse(request).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given task_assignment message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
// given task_assignment message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses task_assignment", () => {
|
||||
const msg = {
|
||||
type: "task_assignment",
|
||||
@@ -98,9 +98,9 @@ describe("ProtocolMessageSchema", () => {
|
||||
expect(TaskAssignmentSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
//#given join_request message
|
||||
//#when parsing
|
||||
//#then it should succeed
|
||||
// given join_request message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses join_request", () => {
|
||||
const msg = {
|
||||
type: "join_request",
|
||||
|
||||
@@ -25,18 +25,18 @@ describe("Storage Utilities", () => {
|
||||
})
|
||||
|
||||
describe("getTaskDir", () => {
|
||||
//#given default config (no claude_code_compat)
|
||||
//#when getting task directory
|
||||
//#then it should return .sisyphus/tasks/{listId}
|
||||
// given default config (no claude_code_compat)
|
||||
// when getting task directory
|
||||
// then it should return .sisyphus/tasks/{listId}
|
||||
it("returns sisyphus path by default", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".sisyphus/tasks/list-123")
|
||||
})
|
||||
|
||||
//#given claude_code_compat enabled
|
||||
//#when getting task directory
|
||||
//#then it should return Claude Code path
|
||||
// given claude_code_compat enabled
|
||||
// when getting task directory
|
||||
// then it should return Claude Code path
|
||||
it("returns claude code path when compat enabled", () => {
|
||||
const config = {
|
||||
sisyphus: {
|
||||
@@ -52,9 +52,9 @@ describe("Storage Utilities", () => {
|
||||
})
|
||||
|
||||
describe("getTaskPath", () => {
|
||||
//#given list and task IDs
|
||||
//#when getting task path
|
||||
//#then it should return path to task JSON file
|
||||
// given list and task IDs
|
||||
// when getting task path
|
||||
// then it should return path to task JSON file
|
||||
it("returns path to task JSON", () => {
|
||||
const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } }
|
||||
const result = getTaskPath("list-123", "1", config as any)
|
||||
@@ -63,9 +63,9 @@ describe("Storage Utilities", () => {
|
||||
})
|
||||
|
||||
describe("getTeamDir", () => {
|
||||
//#given team name and default config
|
||||
//#when getting team directory
|
||||
//#then it should return .sisyphus/teams/{teamName}
|
||||
// given team name and default config
|
||||
// when getting team directory
|
||||
// then it should return .sisyphus/teams/{teamName}
|
||||
it("returns sisyphus team path", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getTeamDir("my-team", config as any)
|
||||
@@ -74,9 +74,9 @@ describe("Storage Utilities", () => {
|
||||
})
|
||||
|
||||
describe("getInboxPath", () => {
|
||||
//#given team and agent names
|
||||
//#when getting inbox path
|
||||
//#then it should return path to inbox JSON file
|
||||
// given team and agent names
|
||||
// when getting inbox path
|
||||
// then it should return path to inbox JSON file
|
||||
it("returns path to inbox JSON", () => {
|
||||
const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } }
|
||||
const result = getInboxPath("my-team", "agent-001", config as any)
|
||||
@@ -85,18 +85,18 @@ describe("Storage Utilities", () => {
|
||||
})
|
||||
|
||||
describe("ensureDir", () => {
|
||||
//#given a non-existent directory path
|
||||
//#when calling ensureDir
|
||||
//#then it should create the directory
|
||||
// given a non-existent directory path
|
||||
// when calling ensureDir
|
||||
// then it should create the directory
|
||||
it("creates directory if not exists", () => {
|
||||
const dirPath = join(TEST_DIR, "new-dir", "nested")
|
||||
ensureDir(dirPath)
|
||||
expect(existsSync(dirPath)).toBe(true)
|
||||
})
|
||||
|
||||
//#given an existing directory
|
||||
//#when calling ensureDir
|
||||
//#then it should not throw
|
||||
// given an existing directory
|
||||
// when calling ensureDir
|
||||
// then it should not throw
|
||||
it("does not throw for existing directory", () => {
|
||||
const dirPath = join(TEST_DIR, "existing")
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
@@ -105,9 +105,9 @@ describe("Storage Utilities", () => {
|
||||
})
|
||||
|
||||
describe("readJsonSafe", () => {
|
||||
//#given a valid JSON file matching schema
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return parsed object
|
||||
// given a valid JSON file matching schema
|
||||
// when reading with readJsonSafe
|
||||
// then it should return parsed object
|
||||
it("reads and parses valid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string(), value: z.number() })
|
||||
const filePath = join(TEST_DIR, "test.json")
|
||||
@@ -117,18 +117,18 @@ describe("Storage Utilities", () => {
|
||||
expect(result).toEqual({ name: "test", value: 42 })
|
||||
})
|
||||
|
||||
//#given a non-existent file
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
// given a non-existent file
|
||||
// when reading with readJsonSafe
|
||||
// then it should return null
|
||||
it("returns null for non-existent file", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const result = readJsonSafe(join(TEST_DIR, "missing.json"), testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
//#given invalid JSON content
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
// given invalid JSON content
|
||||
// when reading with readJsonSafe
|
||||
// then it should return null
|
||||
it("returns null for invalid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const filePath = join(TEST_DIR, "invalid.json")
|
||||
@@ -138,9 +138,9 @@ describe("Storage Utilities", () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
//#given JSON that doesn't match schema
|
||||
//#when reading with readJsonSafe
|
||||
//#then it should return null
|
||||
// given JSON that doesn't match schema
|
||||
// when reading with readJsonSafe
|
||||
// then it should return null
|
||||
it("returns null for schema mismatch", () => {
|
||||
const testSchema = z.object({ name: z.string(), required: z.number() })
|
||||
const filePath = join(TEST_DIR, "mismatch.json")
|
||||
@@ -152,9 +152,9 @@ describe("Storage Utilities", () => {
|
||||
})
|
||||
|
||||
describe("writeJsonAtomic", () => {
|
||||
//#given data to write
|
||||
//#when calling writeJsonAtomic
|
||||
//#then it should write to file atomically
|
||||
// given data to write
|
||||
// when calling writeJsonAtomic
|
||||
// then it should write to file atomically
|
||||
it("writes JSON atomically", () => {
|
||||
const filePath = join(TEST_DIR, "atomic.json")
|
||||
const data = { key: "value", number: 123 }
|
||||
@@ -165,9 +165,9 @@ describe("Storage Utilities", () => {
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
})
|
||||
|
||||
//#given a deeply nested path
|
||||
//#when calling writeJsonAtomic
|
||||
//#then it should create parent directories
|
||||
// given a deeply nested path
|
||||
// when calling writeJsonAtomic
|
||||
// then it should create parent directories
|
||||
it("creates parent directories", () => {
|
||||
const filePath = join(TEST_DIR, "deep", "nested", "file.json")
|
||||
writeJsonAtomic(filePath, { test: true })
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, it, expect } from "bun:test"
|
||||
import { TaskSchema, TaskStatusSchema, type Task } from "./types"
|
||||
|
||||
describe("TaskSchema", () => {
|
||||
//#given a valid task object
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should succeed
|
||||
// given a valid task object
|
||||
// when parsing with TaskSchema
|
||||
// then it should succeed
|
||||
it("parses valid task object", () => {
|
||||
const validTask = {
|
||||
id: "1",
|
||||
@@ -19,9 +19,9 @@ describe("TaskSchema", () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
//#given a task with all optional fields
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should succeed
|
||||
// given a task with all optional fields
|
||||
// when parsing with TaskSchema
|
||||
// then it should succeed
|
||||
it("parses task with optional fields", () => {
|
||||
const taskWithOptionals = {
|
||||
id: "2",
|
||||
@@ -39,9 +39,9 @@ describe("TaskSchema", () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
//#given an invalid status value
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should fail
|
||||
// given an invalid status value
|
||||
// when parsing with TaskSchema
|
||||
// then it should fail
|
||||
it("rejects invalid status", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
@@ -56,9 +56,9 @@ describe("TaskSchema", () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
//#given missing required fields
|
||||
//#when parsing with TaskSchema
|
||||
//#then it should fail
|
||||
// given missing required fields
|
||||
// when parsing with TaskSchema
|
||||
// then it should fail
|
||||
it("rejects missing required fields", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
@@ -71,9 +71,9 @@ describe("TaskSchema", () => {
|
||||
})
|
||||
|
||||
describe("TaskStatusSchema", () => {
|
||||
//#given valid status values
|
||||
//#when parsing
|
||||
//#then all should succeed
|
||||
// given valid status values
|
||||
// when parsing
|
||||
// then all should succeed
|
||||
it("accepts valid statuses", () => {
|
||||
expect(TaskStatusSchema.safeParse("pending").success).toBe(true)
|
||||
expect(TaskStatusSchema.safeParse("in_progress").success).toBe(true)
|
||||
|
||||
@@ -19,16 +19,16 @@ describe("createCleanMcpEnvironment", () => {
|
||||
|
||||
describe("NPM_CONFIG_* filtering", () => {
|
||||
it("filters out uppercase NPM_CONFIG_* variables", () => {
|
||||
// #given
|
||||
// given
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
|
||||
process.env.NPM_CONFIG_CACHE = "/some/cache/path"
|
||||
process.env.NPM_CONFIG_PREFIX = "/some/prefix"
|
||||
process.env.PATH = "/usr/bin"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_PREFIX).toBeUndefined()
|
||||
@@ -36,17 +36,17 @@ describe("createCleanMcpEnvironment", () => {
|
||||
})
|
||||
|
||||
it("filters out lowercase npm_config_* variables", () => {
|
||||
// #given
|
||||
// given
|
||||
process.env.npm_config_registry = "https://private.registry.com"
|
||||
process.env.npm_config_cache = "/some/cache/path"
|
||||
process.env.npm_config_https_proxy = "http://proxy:8080"
|
||||
process.env.npm_config_proxy = "http://proxy:8080"
|
||||
process.env.HOME = "/home/user"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(cleanEnv.npm_config_registry).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_cache).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_https_proxy).toBeUndefined()
|
||||
@@ -57,16 +57,16 @@ describe("createCleanMcpEnvironment", () => {
|
||||
|
||||
describe("YARN_* filtering", () => {
|
||||
it("filters out YARN_* variables", () => {
|
||||
// #given
|
||||
// given
|
||||
process.env.YARN_CACHE_FOLDER = "/yarn/cache"
|
||||
process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = "true"
|
||||
process.env.YARN_REGISTRY = "https://yarn.registry.com"
|
||||
process.env.NODE_ENV = "production"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(cleanEnv.YARN_CACHE_FOLDER).toBeUndefined()
|
||||
expect(cleanEnv.YARN_ENABLE_IMMUTABLE_INSTALLS).toBeUndefined()
|
||||
expect(cleanEnv.YARN_REGISTRY).toBeUndefined()
|
||||
@@ -76,15 +76,15 @@ describe("createCleanMcpEnvironment", () => {
|
||||
|
||||
describe("PNPM_* filtering", () => {
|
||||
it("filters out PNPM_* variables", () => {
|
||||
// #given
|
||||
// given
|
||||
process.env.PNPM_HOME = "/pnpm/home"
|
||||
process.env.PNPM_STORE_DIR = "/pnpm/store"
|
||||
process.env.USER = "testuser"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(cleanEnv.PNPM_HOME).toBeUndefined()
|
||||
expect(cleanEnv.PNPM_STORE_DIR).toBeUndefined()
|
||||
expect(cleanEnv.USER).toBe("testuser")
|
||||
@@ -93,14 +93,14 @@ describe("createCleanMcpEnvironment", () => {
|
||||
|
||||
describe("NO_UPDATE_NOTIFIER filtering", () => {
|
||||
it("filters out NO_UPDATE_NOTIFIER variable", () => {
|
||||
// #given
|
||||
// given
|
||||
process.env.NO_UPDATE_NOTIFIER = "1"
|
||||
process.env.SHELL = "/bin/bash"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(cleanEnv.NO_UPDATE_NOTIFIER).toBeUndefined()
|
||||
expect(cleanEnv.SHELL).toBe("/bin/bash")
|
||||
})
|
||||
@@ -108,7 +108,7 @@ describe("createCleanMcpEnvironment", () => {
|
||||
|
||||
describe("custom environment overlay", () => {
|
||||
it("merges custom env on top of clean process.env", () => {
|
||||
// #given
|
||||
// given
|
||||
process.env.PATH = "/usr/bin"
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
|
||||
const customEnv = {
|
||||
@@ -116,10 +116,10 @@ describe("createCleanMcpEnvironment", () => {
|
||||
CUSTOM_VAR: "custom-value",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment(customEnv)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin")
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.MCP_API_KEY).toBe("secret-key")
|
||||
@@ -127,30 +127,30 @@ describe("createCleanMcpEnvironment", () => {
|
||||
})
|
||||
|
||||
it("custom env can override process.env values", () => {
|
||||
// #given
|
||||
// given
|
||||
process.env.NODE_ENV = "development"
|
||||
const customEnv = {
|
||||
NODE_ENV: "production",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment(customEnv)
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(cleanEnv.NODE_ENV).toBe("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("undefined value handling", () => {
|
||||
it("skips undefined values from process.env", () => {
|
||||
// #given - process.env can have undefined values in TypeScript
|
||||
// given - process.env can have undefined values in TypeScript
|
||||
const envWithUndefined = { ...process.env, UNDEFINED_VAR: undefined }
|
||||
Object.assign(process.env, envWithUndefined)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then - should not throw and should not include undefined values
|
||||
// then - should not throw and should not include undefined values
|
||||
expect(cleanEnv.UNDEFINED_VAR).toBeUndefined()
|
||||
expect(Object.values(cleanEnv).every((v) => v !== undefined)).toBe(true)
|
||||
})
|
||||
@@ -158,16 +158,16 @@ describe("createCleanMcpEnvironment", () => {
|
||||
|
||||
describe("mixed case handling", () => {
|
||||
it("filters both uppercase and lowercase npm config variants", () => {
|
||||
// #given - pnpm/yarn can set both cases simultaneously
|
||||
// given - pnpm/yarn can set both cases simultaneously
|
||||
process.env.NPM_CONFIG_CACHE = "/uppercase/cache"
|
||||
process.env.npm_config_cache = "/lowercase/cache"
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://uppercase.registry.com"
|
||||
process.env.npm_config_registry = "https://lowercase.registry.com"
|
||||
|
||||
// #when
|
||||
// when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_cache).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
@@ -178,7 +178,7 @@ describe("createCleanMcpEnvironment", () => {
|
||||
|
||||
describe("EXCLUDED_ENV_PATTERNS", () => {
|
||||
it("contains patterns for npm, yarn, and pnpm configs", () => {
|
||||
// #given / #when / #then
|
||||
// given / #when / #then
|
||||
expect(EXCLUDED_ENV_PATTERNS.length).toBeGreaterThanOrEqual(4)
|
||||
|
||||
// Test that patterns match expected strings
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("SkillMcpManager", () => {
|
||||
describe("getOrCreateClient", () => {
|
||||
describe("configuration validation", () => {
|
||||
it("throws error when neither url nor command is provided", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
@@ -74,14 +74,14 @@ describe("SkillMcpManager", () => {
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/no valid connection configuration/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes both HTTP and stdio examples in error message", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "my-mcp",
|
||||
skillName: "data-skill",
|
||||
@@ -89,14 +89,14 @@ describe("SkillMcpManager", () => {
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/HTTP[\s\S]*Stdio/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes server and skill names in error message", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "custom-server",
|
||||
skillName: "custom-skill",
|
||||
@@ -104,7 +104,7 @@ describe("SkillMcpManager", () => {
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/custom-server[\s\S]*custom-skill/
|
||||
)
|
||||
@@ -113,7 +113,7 @@ describe("SkillMcpManager", () => {
|
||||
|
||||
describe("connection type detection", () => {
|
||||
it("detects HTTP connection from explicit type='http'", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "http-server",
|
||||
skillName: "test-skill",
|
||||
@@ -124,14 +124,14 @@ describe("SkillMcpManager", () => {
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
// when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects HTTP connection from explicit type='sse'", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "sse-server",
|
||||
skillName: "test-skill",
|
||||
@@ -142,14 +142,14 @@ describe("SkillMcpManager", () => {
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
// when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects HTTP connection from url field when type is not specified", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "inferred-http",
|
||||
skillName: "test-skill",
|
||||
@@ -159,14 +159,14 @@ describe("SkillMcpManager", () => {
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
// when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect[\s\S]*URL/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects stdio connection from explicit type='stdio'", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "stdio-server",
|
||||
skillName: "test-skill",
|
||||
@@ -178,14 +178,14 @@ describe("SkillMcpManager", () => {
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
// when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect[\s\S]*Command/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects stdio connection from command field when type is not specified", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "inferred-stdio",
|
||||
skillName: "test-skill",
|
||||
@@ -196,14 +196,14 @@ describe("SkillMcpManager", () => {
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
// when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect[\s\S]*Command/
|
||||
)
|
||||
})
|
||||
|
||||
it("prefers explicit type over inferred type", async () => {
|
||||
// #given - has both url and command, but type is explicitly stdio
|
||||
// given - has both url and command, but type is explicitly stdio
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "mixed-config",
|
||||
skillName: "test-skill",
|
||||
@@ -216,7 +216,7 @@ describe("SkillMcpManager", () => {
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when / #then - should use stdio (show Command in error, not URL)
|
||||
// when / #then - should use stdio (show Command in error, not URL)
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Command: node/
|
||||
)
|
||||
@@ -225,7 +225,7 @@ describe("SkillMcpManager", () => {
|
||||
|
||||
describe("HTTP connection", () => {
|
||||
it("throws error for invalid URL", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "bad-url-server",
|
||||
skillName: "test-skill",
|
||||
@@ -236,14 +236,14 @@ describe("SkillMcpManager", () => {
|
||||
url: "not-a-valid-url",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/invalid URL/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes URL in HTTP connection error", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "http-error-server",
|
||||
skillName: "test-skill",
|
||||
@@ -253,14 +253,14 @@ describe("SkillMcpManager", () => {
|
||||
url: "https://nonexistent.example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/https:\/\/nonexistent\.example\.com\/mcp/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes helpful hints for HTTP connection failures", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "hint-server",
|
||||
skillName: "test-skill",
|
||||
@@ -270,14 +270,14 @@ describe("SkillMcpManager", () => {
|
||||
url: "https://nonexistent.example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Hints[\s\S]*Verify the URL[\s\S]*authentication headers[\s\S]*MCP over HTTP/
|
||||
)
|
||||
})
|
||||
|
||||
it("calls mocked transport connect for HTTP connections", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "mock-test-server",
|
||||
skillName: "test-skill",
|
||||
@@ -287,14 +287,14 @@ describe("SkillMcpManager", () => {
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// #then - verify mock was called (transport was instantiated)
|
||||
// then - verify mock was called (transport was instantiated)
|
||||
// The connection attempt happens through the Client.connect() which
|
||||
// internally calls transport.start()
|
||||
expect(mockHttpConnect).toHaveBeenCalled()
|
||||
@@ -303,7 +303,7 @@ describe("SkillMcpManager", () => {
|
||||
|
||||
describe("stdio connection (backward compatibility)", () => {
|
||||
it("throws error when command is missing for stdio type", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "missing-command",
|
||||
skillName: "test-skill",
|
||||
@@ -314,14 +314,14 @@ describe("SkillMcpManager", () => {
|
||||
// command is missing
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/missing 'command' field/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes command in stdio connection error", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
@@ -332,14 +332,14 @@ describe("SkillMcpManager", () => {
|
||||
args: ["--foo"],
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/nonexistent-command-xyz --foo/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes helpful hints for stdio connection failures", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
@@ -349,7 +349,7 @@ describe("SkillMcpManager", () => {
|
||||
command: "nonexistent-command",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Hints[\s\S]*PATH[\s\S]*package exists/
|
||||
)
|
||||
@@ -359,7 +359,7 @@ describe("SkillMcpManager", () => {
|
||||
|
||||
describe("disconnectSession", () => {
|
||||
it("removes all clients for a specific session", async () => {
|
||||
// #given
|
||||
// given
|
||||
const session1Info: SkillMcpClientInfo = {
|
||||
serverName: "server1",
|
||||
skillName: "skill1",
|
||||
@@ -371,56 +371,56 @@ describe("SkillMcpManager", () => {
|
||||
sessionID: "session-2",
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
await manager.disconnectSession("session-1")
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(manager.isConnected(session1Info)).toBe(false)
|
||||
expect(manager.isConnected(session2Info)).toBe(false)
|
||||
})
|
||||
|
||||
it("does not throw when session has no clients", async () => {
|
||||
// #given / #when / #then
|
||||
// given / #when / #then
|
||||
await expect(manager.disconnectSession("nonexistent")).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("disconnectAll", () => {
|
||||
it("clears all clients", async () => {
|
||||
// #given - no actual clients connected (would require real MCP server)
|
||||
// given - no actual clients connected (would require real MCP server)
|
||||
|
||||
// #when
|
||||
// when
|
||||
await manager.disconnectAll()
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(manager.getConnectedServers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isConnected", () => {
|
||||
it("returns false for unconnected server", () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "unknown",
|
||||
skillName: "test",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
expect(manager.isConnected(info)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConnectedServers", () => {
|
||||
it("returns empty array when no servers connected", () => {
|
||||
// #given / #when / #then
|
||||
// given / #when / #then
|
||||
expect(manager.getConnectedServers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("environment variable handling", () => {
|
||||
it("always inherits process.env even when config.env is undefined", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
@@ -431,8 +431,8 @@ describe("SkillMcpManager", () => {
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when - attempt connection (will fail but exercises env merging code path)
|
||||
// #then - should not throw "undefined" related errors for env
|
||||
// when - attempt connection (will fail but exercises env merging code path)
|
||||
// then - should not throw "undefined" related errors for env
|
||||
try {
|
||||
await manager.getOrCreateClient(info, configWithoutEnv)
|
||||
} catch (error) {
|
||||
@@ -443,7 +443,7 @@ describe("SkillMcpManager", () => {
|
||||
})
|
||||
|
||||
it("overlays config.env on top of inherited process.env", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
@@ -457,8 +457,8 @@ describe("SkillMcpManager", () => {
|
||||
},
|
||||
}
|
||||
|
||||
// #when - attempt connection
|
||||
// #then - should not throw, env merging should work
|
||||
// when - attempt connection
|
||||
// then - should not throw, env merging should work
|
||||
try {
|
||||
await manager.getOrCreateClient(info, configWithEnv)
|
||||
} catch (error) {
|
||||
@@ -470,7 +470,7 @@ describe("SkillMcpManager", () => {
|
||||
|
||||
describe("HTTP headers handling", () => {
|
||||
it("accepts configuration with headers", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "auth-server",
|
||||
skillName: "test-skill",
|
||||
@@ -484,7 +484,7 @@ describe("SkillMcpManager", () => {
|
||||
},
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
// when / #then - should fail at connection, not config validation
|
||||
// Headers are passed through to the transport
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
@@ -498,7 +498,7 @@ describe("SkillMcpManager", () => {
|
||||
})
|
||||
|
||||
it("works without headers (optional)", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "no-auth-server",
|
||||
skillName: "test-skill",
|
||||
@@ -509,7 +509,7 @@ describe("SkillMcpManager", () => {
|
||||
// no headers
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
// when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
@@ -518,7 +518,7 @@ describe("SkillMcpManager", () => {
|
||||
|
||||
describe("operation retry logic", () => {
|
||||
it("should retry operation when 'Not connected' error occurs", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "retry-server",
|
||||
skillName: "retry-skill",
|
||||
@@ -546,17 +546,17 @@ describe("SkillMcpManager", () => {
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = await manager.callTool(info, context, "test-tool", {})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(callCount).toBe(2)
|
||||
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should fail after 3 retry attempts", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "fail-server",
|
||||
skillName: "fail-skill",
|
||||
@@ -579,7 +579,7 @@ describe("SkillMcpManager", () => {
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
||||
/Failed after 3 reconnection attempts/
|
||||
)
|
||||
@@ -587,7 +587,7 @@ describe("SkillMcpManager", () => {
|
||||
})
|
||||
|
||||
it("should not retry on non-connection errors", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "error-server",
|
||||
skillName: "error-skill",
|
||||
@@ -610,7 +610,7 @@ describe("SkillMcpManager", () => {
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
||||
"Tool not found"
|
||||
)
|
||||
@@ -625,7 +625,7 @@ describe("SkillMcpManager", () => {
|
||||
})
|
||||
|
||||
it("injects Authorization header when oauth config has stored tokens", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "oauth-server",
|
||||
skillName: "oauth-skill",
|
||||
@@ -640,18 +640,18 @@ describe("SkillMcpManager", () => {
|
||||
}
|
||||
mockTokens.mockReturnValue({ accessToken: "stored-access-token" })
|
||||
|
||||
// #when
|
||||
// when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
// then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.Authorization).toBe("Bearer stored-access-token")
|
||||
})
|
||||
|
||||
it("does not inject Authorization header when no stored tokens exist and login fails", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "oauth-no-token",
|
||||
skillName: "oauth-skill",
|
||||
@@ -666,18 +666,18 @@ describe("SkillMcpManager", () => {
|
||||
mockTokens.mockReturnValue(null)
|
||||
mockLogin.mockRejectedValue(new Error("Login failed"))
|
||||
|
||||
// #when
|
||||
// when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
// then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it("preserves existing static headers alongside OAuth token", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "oauth-with-headers",
|
||||
skillName: "oauth-skill",
|
||||
@@ -694,19 +694,19 @@ describe("SkillMcpManager", () => {
|
||||
}
|
||||
mockTokens.mockReturnValue({ accessToken: "oauth-token" })
|
||||
|
||||
// #when
|
||||
// when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
// then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.["X-Custom"]).toBe("custom-value")
|
||||
expect(headers?.Authorization).toBe("Bearer oauth-token")
|
||||
})
|
||||
|
||||
it("does not create auth provider when oauth config is absent", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "no-oauth-server",
|
||||
skillName: "test-skill",
|
||||
@@ -719,19 +719,19 @@ describe("SkillMcpManager", () => {
|
||||
},
|
||||
}
|
||||
|
||||
// #when
|
||||
// when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
// then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.Authorization).toBe("Bearer static-token")
|
||||
expect(mockTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles step-up auth by triggering re-login on 403 with scope", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "stepup-server",
|
||||
skillName: "stepup-skill",
|
||||
@@ -767,16 +767,16 @@ describe("SkillMcpManager", () => {
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when
|
||||
// when
|
||||
const result = await manager.callTool(info, context, "test-tool", {})
|
||||
|
||||
// #then
|
||||
// then
|
||||
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||
expect(mockLogin).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not attempt step-up when oauth config is absent", async () => {
|
||||
// #given
|
||||
// given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "no-stepup-server",
|
||||
skillName: "no-stepup-skill",
|
||||
@@ -799,7 +799,7 @@ describe("SkillMcpManager", () => {
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when / #then
|
||||
// when / #then
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(/403/)
|
||||
expect(mockLogin).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("TaskToastManager", () => {
|
||||
|
||||
describe("skills in toast message", () => {
|
||||
test("should display skills when provided", () => {
|
||||
// #given - a task with skills
|
||||
// given - a task with skills
|
||||
const task = {
|
||||
id: "task_1",
|
||||
description: "Test task",
|
||||
@@ -35,10 +35,10 @@ describe("TaskToastManager", () => {
|
||||
skills: ["playwright", "git-master"],
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast message should include skills
|
||||
// then - toast message should include skills
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("playwright")
|
||||
@@ -46,7 +46,7 @@ describe("TaskToastManager", () => {
|
||||
})
|
||||
|
||||
test("should not display skills section when no skills provided", () => {
|
||||
// #given - a task without skills
|
||||
// given - a task without skills
|
||||
const task = {
|
||||
id: "task_2",
|
||||
description: "Test task without skills",
|
||||
@@ -54,10 +54,10 @@ describe("TaskToastManager", () => {
|
||||
isBackground: true,
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast message should not include skills prefix
|
||||
// then - toast message should not include skills prefix
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).not.toContain("Skills:")
|
||||
@@ -66,7 +66,7 @@ describe("TaskToastManager", () => {
|
||||
|
||||
describe("concurrency info in toast message", () => {
|
||||
test("should display concurrency status in toast", () => {
|
||||
// #given - multiple running tasks
|
||||
// given - multiple running tasks
|
||||
toastManager.addTask({
|
||||
id: "task_1",
|
||||
description: "First task",
|
||||
@@ -80,7 +80,7 @@ describe("TaskToastManager", () => {
|
||||
isBackground: true,
|
||||
})
|
||||
|
||||
// #when - third task is added
|
||||
// when - third task is added
|
||||
toastManager.addTask({
|
||||
id: "task_3",
|
||||
description: "Third task",
|
||||
@@ -88,7 +88,7 @@ describe("TaskToastManager", () => {
|
||||
isBackground: true,
|
||||
})
|
||||
|
||||
// #then - toast should show concurrency info
|
||||
// then - toast should show concurrency info
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalledTimes(3)
|
||||
const lastCall = mockClient.tui.showToast.mock.calls[2][0]
|
||||
// Should show "Running (3):" header
|
||||
@@ -96,7 +96,7 @@ describe("TaskToastManager", () => {
|
||||
})
|
||||
|
||||
test("should display concurrency limit info when available", () => {
|
||||
// #given - a concurrency manager with known limit
|
||||
// given - a concurrency manager with known limit
|
||||
const mockConcurrencyWithCounts = {
|
||||
getConcurrencyLimit: mock(() => 5),
|
||||
getRunningCount: mock(() => 2),
|
||||
@@ -106,7 +106,7 @@ describe("TaskToastManager", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const managerWithConcurrency = new TaskToastManager(mockClient as any, mockConcurrencyWithCounts)
|
||||
|
||||
// #when - a task is added
|
||||
// when - a task is added
|
||||
managerWithConcurrency.addTask({
|
||||
id: "task_1",
|
||||
description: "Test task",
|
||||
@@ -114,7 +114,7 @@ describe("TaskToastManager", () => {
|
||||
isBackground: true,
|
||||
})
|
||||
|
||||
// #then - toast should show concurrency status like "2/5 slots"
|
||||
// then - toast should show concurrency status like "2/5 slots"
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toMatch(/\d+\/\d+/)
|
||||
@@ -123,7 +123,7 @@ describe("TaskToastManager", () => {
|
||||
|
||||
describe("combined skills and concurrency display", () => {
|
||||
test("should display both skills and concurrency info together", () => {
|
||||
// #given - a task with skills and concurrency manager
|
||||
// given - a task with skills and concurrency manager
|
||||
const task = {
|
||||
id: "task_1",
|
||||
description: "Full info task",
|
||||
@@ -132,10 +132,10 @@ describe("TaskToastManager", () => {
|
||||
skills: ["frontend-ui-ux"],
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should include both skills and task count
|
||||
// then - toast should include both skills and task count
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("frontend-ui-ux")
|
||||
@@ -145,7 +145,7 @@ describe("TaskToastManager", () => {
|
||||
|
||||
describe("model fallback info in toast message", () => {
|
||||
test("should NOT display warning when model is category-default (normal behavior)", () => {
|
||||
// #given - category-default is the intended behavior, not a fallback
|
||||
// given - category-default is the intended behavior, not a fallback
|
||||
const task = {
|
||||
id: "task_1",
|
||||
description: "Task with category default model",
|
||||
@@ -154,10 +154,10 @@ describe("TaskToastManager", () => {
|
||||
modelInfo: { model: "google/gemini-3-pro", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should NOT show warning - category default is expected
|
||||
// then - toast should NOT show warning - category default is expected
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).not.toContain("[FALLBACK]")
|
||||
@@ -165,7 +165,7 @@ describe("TaskToastManager", () => {
|
||||
})
|
||||
|
||||
test("should display warning when model falls back to system-default", () => {
|
||||
// #given - system-default is a fallback (no category default, no user config)
|
||||
// given - system-default is a fallback (no category default, no user config)
|
||||
const task = {
|
||||
id: "task_1b",
|
||||
description: "Task with system default model",
|
||||
@@ -174,10 +174,10 @@ describe("TaskToastManager", () => {
|
||||
modelInfo: { model: "anthropic/claude-sonnet-4-5", type: "system-default" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should show fallback warning
|
||||
// then - toast should show fallback warning
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("[FALLBACK]")
|
||||
@@ -186,7 +186,7 @@ describe("TaskToastManager", () => {
|
||||
})
|
||||
|
||||
test("should display warning when model is inherited from parent", () => {
|
||||
// #given - inherited is a fallback (custom category without model definition)
|
||||
// given - inherited is a fallback (custom category without model definition)
|
||||
const task = {
|
||||
id: "task_2",
|
||||
description: "Task with inherited model",
|
||||
@@ -195,10 +195,10 @@ describe("TaskToastManager", () => {
|
||||
modelInfo: { model: "cliproxy/claude-opus-4-5", type: "inherited" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should show fallback warning
|
||||
// then - toast should show fallback warning
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("[FALLBACK]")
|
||||
@@ -207,7 +207,7 @@ describe("TaskToastManager", () => {
|
||||
})
|
||||
|
||||
test("should not display model info when user-defined", () => {
|
||||
// #given - a task with user-defined model
|
||||
// given - a task with user-defined model
|
||||
const task = {
|
||||
id: "task_3",
|
||||
description: "Task with user model",
|
||||
@@ -216,10 +216,10 @@ describe("TaskToastManager", () => {
|
||||
modelInfo: { model: "my-provider/my-model", type: "user-defined" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should NOT show model warning
|
||||
// then - toast should NOT show model warning
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).not.toContain("[FALLBACK] Model:")
|
||||
@@ -229,7 +229,7 @@ describe("TaskToastManager", () => {
|
||||
})
|
||||
|
||||
test("should not display model info when not provided", () => {
|
||||
// #given - a task without model info
|
||||
// given - a task without model info
|
||||
const task = {
|
||||
id: "task_4",
|
||||
description: "Task without model info",
|
||||
@@ -237,10 +237,10 @@ describe("TaskToastManager", () => {
|
||||
isBackground: true,
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
// when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should NOT show model warning
|
||||
// then - toast should NOT show model warning
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).not.toContain("[FALLBACK] Model:")
|
||||
|
||||
@@ -25,46 +25,46 @@ describe("canSplitPane", () => {
|
||||
})
|
||||
|
||||
it("returns true for horizontal split when width >= 2*MIN+1", () => {
|
||||
//#given - pane with exactly minimum splittable width (107)
|
||||
// given - pane with exactly minimum splittable width (107)
|
||||
const pane = createPane(MIN_SPLIT_WIDTH, 20)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = canSplitPane(pane, "-h")
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for horizontal split when width < 2*MIN+1", () => {
|
||||
//#given - pane just below minimum splittable width
|
||||
// given - pane just below minimum splittable width
|
||||
const pane = createPane(MIN_SPLIT_WIDTH - 1, 20)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = canSplitPane(pane, "-h")
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true for vertical split when height >= 2*MIN+1", () => {
|
||||
//#given - pane with exactly minimum splittable height (23)
|
||||
// given - pane with exactly minimum splittable height (23)
|
||||
const pane = createPane(50, MIN_SPLIT_HEIGHT)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = canSplitPane(pane, "-v")
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for vertical split when height < 2*MIN+1", () => {
|
||||
//#given - pane just below minimum splittable height
|
||||
// given - pane just below minimum splittable height
|
||||
const pane = createPane(50, MIN_SPLIT_HEIGHT - 1)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = canSplitPane(pane, "-v")
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -81,35 +81,35 @@ describe("canSplitPaneAnyDirection", () => {
|
||||
})
|
||||
|
||||
it("returns true when can split horizontally but not vertically", () => {
|
||||
//#given
|
||||
// given
|
||||
const pane = createPane(MIN_SPLIT_WIDTH, MIN_SPLIT_HEIGHT - 1)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = canSplitPaneAnyDirection(pane)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true when can split vertically but not horizontally", () => {
|
||||
//#given
|
||||
// given
|
||||
const pane = createPane(MIN_SPLIT_WIDTH - 1, MIN_SPLIT_HEIGHT)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = canSplitPaneAnyDirection(pane)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when cannot split in any direction", () => {
|
||||
//#given - pane too small in both dimensions
|
||||
// given - pane too small in both dimensions
|
||||
const pane = createPane(MIN_SPLIT_WIDTH - 1, MIN_SPLIT_HEIGHT - 1)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = canSplitPaneAnyDirection(pane)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -126,57 +126,57 @@ describe("getBestSplitDirection", () => {
|
||||
})
|
||||
|
||||
it("returns -h when only horizontal split possible", () => {
|
||||
//#given
|
||||
// given
|
||||
const pane = createPane(MIN_SPLIT_WIDTH, MIN_SPLIT_HEIGHT - 1)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = getBestSplitDirection(pane)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe("-h")
|
||||
})
|
||||
|
||||
it("returns -v when only vertical split possible", () => {
|
||||
//#given
|
||||
// given
|
||||
const pane = createPane(MIN_SPLIT_WIDTH - 1, MIN_SPLIT_HEIGHT)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = getBestSplitDirection(pane)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe("-v")
|
||||
})
|
||||
|
||||
it("returns null when no split possible", () => {
|
||||
//#given
|
||||
// given
|
||||
const pane = createPane(MIN_SPLIT_WIDTH - 1, MIN_SPLIT_HEIGHT - 1)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = getBestSplitDirection(pane)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
|
||||
it("returns -h when width >= height and both splits possible", () => {
|
||||
//#given - wider than tall
|
||||
// given - wider than tall
|
||||
const pane = createPane(MIN_SPLIT_WIDTH + 10, MIN_SPLIT_HEIGHT)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = getBestSplitDirection(pane)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe("-h")
|
||||
})
|
||||
|
||||
it("returns -v when height > width and both splits possible", () => {
|
||||
//#given - taller than wide (height needs to be > width for -v)
|
||||
// given - taller than wide (height needs to be > width for -v)
|
||||
const pane = createPane(MIN_SPLIT_WIDTH, MIN_SPLIT_WIDTH + 10)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = getBestSplitDirection(pane)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result).toBe("-v")
|
||||
})
|
||||
})
|
||||
@@ -204,32 +204,32 @@ describe("decideSpawnActions", () => {
|
||||
|
||||
describe("minimum size enforcement", () => {
|
||||
it("returns canSpawn=false when window too small", () => {
|
||||
//#given - window smaller than minimum pane size
|
||||
// given - window smaller than minimum pane size
|
||||
const state = createWindowState(50, 5)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.canSpawn).toBe(false)
|
||||
expect(result.reason).toContain("too small")
|
||||
})
|
||||
|
||||
it("returns canSpawn=true when main pane can be split", () => {
|
||||
//#given - main pane width >= 2*MIN_PANE_WIDTH+1 = 107
|
||||
// given - main pane width >= 2*MIN_PANE_WIDTH+1 = 107
|
||||
const state = createWindowState(220, 44)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.canSpawn).toBe(true)
|
||||
expect(result.actions.length).toBe(1)
|
||||
expect(result.actions[0].type).toBe("spawn")
|
||||
})
|
||||
|
||||
it("closes oldest pane when existing panes are too small to split", () => {
|
||||
//#given - existing pane is below minimum splittable size
|
||||
// given - existing pane is below minimum splittable size
|
||||
const state = createWindowState(220, 30, [
|
||||
{ paneId: "%1", width: 50, height: 15, left: 110, top: 0 },
|
||||
])
|
||||
@@ -237,10 +237,10 @@ describe("decideSpawnActions", () => {
|
||||
{ sessionId: "old-ses", paneId: "%1", createdAt: new Date("2024-01-01") },
|
||||
]
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, mappings)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.canSpawn).toBe(true)
|
||||
expect(result.actions.length).toBe(2)
|
||||
expect(result.actions[0].type).toBe("close")
|
||||
@@ -248,15 +248,15 @@ describe("decideSpawnActions", () => {
|
||||
})
|
||||
|
||||
it("can spawn when existing pane is large enough to split", () => {
|
||||
//#given - existing pane is above minimum splittable size
|
||||
// given - existing pane is above minimum splittable size
|
||||
const state = createWindowState(320, 50, [
|
||||
{ paneId: "%1", width: MIN_SPLIT_WIDTH + 10, height: MIN_SPLIT_HEIGHT + 10, left: 160, top: 0 },
|
||||
])
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.canSpawn).toBe(true)
|
||||
expect(result.actions.length).toBe(1)
|
||||
expect(result.actions[0].type).toBe("spawn")
|
||||
@@ -265,28 +265,28 @@ describe("decideSpawnActions", () => {
|
||||
|
||||
describe("basic spawn decisions", () => {
|
||||
it("returns canSpawn=true when capacity allows new pane", () => {
|
||||
//#given - 220x44 window, mainPane width=110 >= MIN_SPLIT_WIDTH(107)
|
||||
// given - 220x44 window, mainPane width=110 >= MIN_SPLIT_WIDTH(107)
|
||||
const state = createWindowState(220, 44)
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.canSpawn).toBe(true)
|
||||
expect(result.actions.length).toBe(1)
|
||||
expect(result.actions[0].type).toBe("spawn")
|
||||
})
|
||||
|
||||
it("spawns with splitDirection", () => {
|
||||
//#given
|
||||
// given
|
||||
const state = createWindowState(212, 44, [
|
||||
{ paneId: "%1", width: MIN_SPLIT_WIDTH, height: MIN_SPLIT_HEIGHT, left: 106, top: 0 },
|
||||
])
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.canSpawn).toBe(true)
|
||||
expect(result.actions[0].type).toBe("spawn")
|
||||
if (result.actions[0].type === "spawn") {
|
||||
@@ -296,13 +296,13 @@ describe("decideSpawnActions", () => {
|
||||
})
|
||||
|
||||
it("returns canSpawn=false when no main pane", () => {
|
||||
//#given
|
||||
// given
|
||||
const state: WindowState = { windowWidth: 212, windowHeight: 44, mainPane: null, agentPanes: [] }
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = decideSpawnActions(state, "ses1", "test", defaultConfig, [])
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(result.canSpawn).toBe(false)
|
||||
expect(result.reason).toBe("no main pane found")
|
||||
})
|
||||
@@ -311,42 +311,42 @@ describe("decideSpawnActions", () => {
|
||||
|
||||
describe("calculateCapacity", () => {
|
||||
it("calculates 2D grid capacity (cols x rows)", () => {
|
||||
//#given - 212x44 window (user's actual screen)
|
||||
//#when
|
||||
// given - 212x44 window (user's actual screen)
|
||||
// when
|
||||
const capacity = calculateCapacity(212, 44)
|
||||
|
||||
//#then - availableWidth=106, cols=(106+1)/(52+1)=2, rows=(44+1)/(11+1)=3 (accounting for dividers)
|
||||
// then - availableWidth=106, cols=(106+1)/(52+1)=2, rows=(44+1)/(11+1)=3 (accounting for dividers)
|
||||
expect(capacity.cols).toBe(2)
|
||||
expect(capacity.rows).toBe(3)
|
||||
expect(capacity.total).toBe(6)
|
||||
})
|
||||
|
||||
it("returns 0 cols when agent area too narrow", () => {
|
||||
//#given - window too narrow for even 1 agent pane
|
||||
//#when
|
||||
// given - window too narrow for even 1 agent pane
|
||||
// when
|
||||
const capacity = calculateCapacity(100, 44)
|
||||
|
||||
//#then - availableWidth=50, cols=50/53=0
|
||||
// then - availableWidth=50, cols=50/53=0
|
||||
expect(capacity.cols).toBe(0)
|
||||
expect(capacity.total).toBe(0)
|
||||
})
|
||||
|
||||
it("returns 0 rows when window too short", () => {
|
||||
//#given - window too short
|
||||
//#when
|
||||
// given - window too short
|
||||
// when
|
||||
const capacity = calculateCapacity(212, 10)
|
||||
|
||||
//#then - rows=10/11=0
|
||||
// then - rows=10/11=0
|
||||
expect(capacity.rows).toBe(0)
|
||||
expect(capacity.total).toBe(0)
|
||||
})
|
||||
|
||||
it("scales with larger screens but caps at MAX_GRID_SIZE=4", () => {
|
||||
//#given - larger 4K-like screen (400x100)
|
||||
//#when
|
||||
// given - larger 4K-like screen (400x100)
|
||||
// when
|
||||
const capacity = calculateCapacity(400, 100)
|
||||
|
||||
//#then - cols capped at 4, rows capped at 4 (MAX_GRID_SIZE)
|
||||
// then - cols capped at 4, rows capped at 4 (MAX_GRID_SIZE)
|
||||
expect(capacity.cols).toBe(3)
|
||||
expect(capacity.rows).toBe(4)
|
||||
expect(capacity.total).toBe(12)
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('TmuxSessionManager', () => {
|
||||
|
||||
describe('constructor', () => {
|
||||
test('enabled when config.enabled=true and isInsideTmux=true', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
@@ -157,15 +157,15 @@ describe('TmuxSessionManager', () => {
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(manager).toBeDefined()
|
||||
})
|
||||
|
||||
test('disabled when config.enabled=true but isInsideTmux=false', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(false)
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
@@ -177,15 +177,15 @@ describe('TmuxSessionManager', () => {
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(manager).toBeDefined()
|
||||
})
|
||||
|
||||
test('disabled when config.enabled=false', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
@@ -197,17 +197,17 @@ describe('TmuxSessionManager', () => {
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(manager).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onSessionCreated', () => {
|
||||
test('first agent spawns from source pane via decision engine', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||
|
||||
@@ -227,10 +227,10 @@ describe('TmuxSessionManager', () => {
|
||||
'Background: Test Task'
|
||||
)
|
||||
|
||||
//#when
|
||||
// when
|
||||
await manager.onSessionCreated(event)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(mockQueryWindowState).toHaveBeenCalledTimes(1)
|
||||
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('TmuxSessionManager', () => {
|
||||
})
|
||||
|
||||
test('second agent spawns with correct split direction', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
|
||||
let callCount = 0
|
||||
@@ -283,18 +283,18 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when - first agent
|
||||
// when - first agent
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1')
|
||||
)
|
||||
mockExecuteActions.mockClear()
|
||||
|
||||
//#when - second agent
|
||||
// when - second agent
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_2', 'ses_parent', 'Task 2')
|
||||
)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
|
||||
const call = mockExecuteActions.mock.calls[0]
|
||||
expect(call).toBeDefined()
|
||||
@@ -304,7 +304,7 @@ describe('TmuxSessionManager', () => {
|
||||
})
|
||||
|
||||
test('does NOT spawn pane when session has no parentID', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
@@ -318,15 +318,15 @@ describe('TmuxSessionManager', () => {
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent('ses_root', undefined, 'Root Session')
|
||||
|
||||
//#when
|
||||
// when
|
||||
await manager.onSessionCreated(event)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test('does NOT spawn pane when disabled', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
@@ -344,15 +344,15 @@ describe('TmuxSessionManager', () => {
|
||||
'Background: Test Task'
|
||||
)
|
||||
|
||||
//#when
|
||||
// when
|
||||
await manager.onSessionCreated(event)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test('does NOT spawn pane for non session.created event type', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
@@ -371,15 +371,15 @@ describe('TmuxSessionManager', () => {
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
await manager.onSessionCreated(event)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test('replaces oldest agent when unsplittable (small window)', async () => {
|
||||
//#given - small window where split is not possible
|
||||
// given - small window where split is not possible
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
mockQueryWindowState.mockImplementation(async () =>
|
||||
createWindowState({
|
||||
@@ -410,12 +410,12 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when
|
||||
// when
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_new', 'ses_parent', 'New Task')
|
||||
)
|
||||
|
||||
//#then - with small window, replace action is used instead of close+spawn
|
||||
// then - with small window, replace action is used instead of close+spawn
|
||||
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
|
||||
const call = mockExecuteActions.mock.calls[0]
|
||||
expect(call).toBeDefined()
|
||||
@@ -427,7 +427,7 @@ describe('TmuxSessionManager', () => {
|
||||
|
||||
describe('onSessionDeleted', () => {
|
||||
test('closes pane when tracked session is deleted', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
|
||||
let stateCallCount = 0
|
||||
@@ -471,10 +471,10 @@ describe('TmuxSessionManager', () => {
|
||||
)
|
||||
mockExecuteAction.mockClear()
|
||||
|
||||
//#when
|
||||
// when
|
||||
await manager.onSessionDeleted({ sessionID: 'ses_child' })
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(mockExecuteAction).toHaveBeenCalledTimes(1)
|
||||
const call = mockExecuteAction.mock.calls[0]
|
||||
expect(call).toBeDefined()
|
||||
@@ -486,7 +486,7 @@ describe('TmuxSessionManager', () => {
|
||||
})
|
||||
|
||||
test('does nothing when untracked session is deleted', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
const { TmuxSessionManager } = await import('./manager')
|
||||
const ctx = createMockContext()
|
||||
@@ -499,17 +499,17 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when
|
||||
// when
|
||||
await manager.onSessionDeleted({ sessionID: 'ses_unknown' })
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(mockExecuteAction).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
test('closes all tracked panes', async () => {
|
||||
//#given
|
||||
// given
|
||||
mockIsInsideTmux.mockReturnValue(true)
|
||||
|
||||
let callCount = 0
|
||||
@@ -542,10 +542,10 @@ describe('TmuxSessionManager', () => {
|
||||
|
||||
mockExecuteAction.mockClear()
|
||||
|
||||
//#when
|
||||
// when
|
||||
await manager.cleanup()
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(mockExecuteAction).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -554,26 +554,26 @@ describe('TmuxSessionManager', () => {
|
||||
describe('DecisionEngine', () => {
|
||||
describe('calculateCapacity', () => {
|
||||
test('calculates correct 2D grid capacity', async () => {
|
||||
//#given
|
||||
// given
|
||||
const { calculateCapacity } = await import('./decision-engine')
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = calculateCapacity(212, 44)
|
||||
|
||||
//#then - availableWidth=106, cols=(106+1)/(52+1)=2, rows=(44+1)/(11+1)=3 (accounting for dividers)
|
||||
// then - availableWidth=106, cols=(106+1)/(52+1)=2, rows=(44+1)/(11+1)=3 (accounting for dividers)
|
||||
expect(result.cols).toBe(2)
|
||||
expect(result.rows).toBe(3)
|
||||
expect(result.total).toBe(6)
|
||||
})
|
||||
|
||||
test('returns 0 cols when agent area too narrow', async () => {
|
||||
//#given
|
||||
// given
|
||||
const { calculateCapacity } = await import('./decision-engine')
|
||||
|
||||
//#when
|
||||
// when
|
||||
const result = calculateCapacity(100, 44)
|
||||
|
||||
//#then - availableWidth=50, cols=50/53=0
|
||||
// then - availableWidth=50, cols=50/53=0
|
||||
expect(result.cols).toBe(0)
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
@@ -581,7 +581,7 @@ describe('DecisionEngine', () => {
|
||||
|
||||
describe('decideSpawnActions', () => {
|
||||
test('returns spawn action with splitDirection when under capacity', async () => {
|
||||
//#given
|
||||
// given
|
||||
const { decideSpawnActions } = await import('./decision-engine')
|
||||
const state: WindowState = {
|
||||
windowWidth: 212,
|
||||
@@ -598,7 +598,7 @@ describe('DecisionEngine', () => {
|
||||
agentPanes: [],
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const decision = decideSpawnActions(
|
||||
state,
|
||||
'ses_1',
|
||||
@@ -607,7 +607,7 @@ describe('DecisionEngine', () => {
|
||||
[]
|
||||
)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(decision.canSpawn).toBe(true)
|
||||
expect(decision.actions).toHaveLength(1)
|
||||
expect(decision.actions[0].type).toBe('spawn')
|
||||
@@ -620,7 +620,7 @@ describe('DecisionEngine', () => {
|
||||
})
|
||||
|
||||
test('returns replace when split not possible', async () => {
|
||||
//#given - small window where split is never possible
|
||||
// given - small window where split is never possible
|
||||
const { decideSpawnActions } = await import('./decision-engine')
|
||||
const state: WindowState = {
|
||||
windowWidth: 160,
|
||||
@@ -650,7 +650,7 @@ describe('DecisionEngine', () => {
|
||||
{ sessionId: 'ses_old', paneId: '%1', createdAt: new Date('2024-01-01') },
|
||||
]
|
||||
|
||||
//#when
|
||||
// when
|
||||
const decision = decideSpawnActions(
|
||||
state,
|
||||
'ses_new',
|
||||
@@ -659,14 +659,14 @@ describe('DecisionEngine', () => {
|
||||
sessionMappings
|
||||
)
|
||||
|
||||
//#then - agent area (80) < MIN_SPLIT_WIDTH (105), so replace is used
|
||||
// then - agent area (80) < MIN_SPLIT_WIDTH (105), so replace is used
|
||||
expect(decision.canSpawn).toBe(true)
|
||||
expect(decision.actions).toHaveLength(1)
|
||||
expect(decision.actions[0].type).toBe('replace')
|
||||
})
|
||||
|
||||
test('returns canSpawn=false when window too small', async () => {
|
||||
//#given
|
||||
// given
|
||||
const { decideSpawnActions } = await import('./decision-engine')
|
||||
const state: WindowState = {
|
||||
windowWidth: 60,
|
||||
@@ -683,7 +683,7 @@ describe('DecisionEngine', () => {
|
||||
agentPanes: [],
|
||||
}
|
||||
|
||||
//#when
|
||||
// when
|
||||
const decision = decideSpawnActions(
|
||||
state,
|
||||
'ses_1',
|
||||
@@ -692,7 +692,7 @@ describe('DecisionEngine', () => {
|
||||
[]
|
||||
)
|
||||
|
||||
//#then
|
||||
// then
|
||||
expect(decision.canSpawn).toBe(false)
|
||||
expect(decision.reason).toContain('too small')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user