* chore: pin bun-types to 1.3.6 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * chore: exclude test files and script from tsconfig 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * refactor: remove sisyphus-swarm feature Remove mailbox types and swarm config schema. Update docs. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * refactor: remove legacy sisyphus-tasks feature Remove old storage and types implementation, replaced by claude-tasks. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(claude-tasks): add task schema and storage utilities - Task schema with Zod validation (pending, in_progress, completed, deleted) - Storage utilities: getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock - Atomic writes with temp file + rename - File-based locking with 30s stale threshold 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools/task): add task object schemas Add Zod schemas for task CRUD operations input validation. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools): add TaskCreate tool Create new tasks with sequential ID generation and lock-based concurrency. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools): add TaskGet tool Retrieve task by ID with null-safe handling. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools): add TaskUpdate tool with claim validation Update tasks with status transitions and owner claim validation. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(tools): add TaskList tool and exports - TaskList for summary view of all tasks - Export all claude-tasks tool factories from index 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(hooks): add task-reminder hook Remind agents to use task tools after 10 turns without task operations. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(config): add disabled_tools setting and tasks-todowrite-disabler hook - Add disabled_tools config option to disable specific tools by name - Register tasks-todowrite-disabler hook name in schema 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(config-handler): add task_* and teammate tool permissions Grant task_* and teammate permissions to atlas, sisyphus, prometheus, and sisyphus-junior agents. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(delegate-task): add execute option for task execution Add optional execute field with task_id and task_dir for task-based delegation. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * fix(truncator): add type guard for non-string outputs Prevent crashes when output is not a string by adding typeof checks. 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * chore: export config types and update task-resume-info - Export SisyphusConfig and SisyphusTasksConfig types - Add task_tool to TARGET_TOOLS list 🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * refactor(storage): remove team namespace, use flat task directory * feat(task): implement unified task tool with all 5 actions * fix(hooks): update task-reminder to track unified task tool * refactor(tools): register unified task tool, remove 4 separate tools * chore(cleanup): remove old 4-tool task implementation * refactor(config): use new_task_system_enabled as top-level flag - Add new_task_system_enabled to OhMyOpenCodeConfigSchema - Remove enabled from SisyphusTasksConfigSchema (keep storage_path, claude_code_compat) - Update index.ts to gate on new_task_system_enabled - Update plugin-config.ts default for config initialization - Update test configs in task.test.ts and storage.test.ts Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: resolve typecheck and test failures - Add explicit ToolDefinition return type to createTask function - Fix planDemoteConfig to use 'subagent' mode instead of 'all' --------- Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
362 lines
8.9 KiB
TypeScript
362 lines
8.9 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"
|
|
import { join } from "path"
|
|
import { z } from "zod"
|
|
import { getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock, generateTaskId, listTaskFiles } from "./storage"
|
|
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
|
|
|
const TEST_DIR = ".test-claude-tasks"
|
|
const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
|
|
|
|
describe("getTaskDir", () => {
|
|
test("returns correct path for default config", () => {
|
|
//#given
|
|
const config: Partial<OhMyOpenCodeConfig> = {}
|
|
|
|
//#when
|
|
const result = getTaskDir(config)
|
|
|
|
//#then
|
|
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks"))
|
|
})
|
|
|
|
test("returns correct path with custom storage_path", () => {
|
|
//#given
|
|
const config: Partial<OhMyOpenCodeConfig> = {
|
|
sisyphus: {
|
|
tasks: {
|
|
storage_path: ".custom/tasks",
|
|
claude_code_compat: false,
|
|
},
|
|
},
|
|
}
|
|
|
|
//#when
|
|
const result = getTaskDir(config)
|
|
|
|
//#then
|
|
expect(result).toBe(join(process.cwd(), ".custom/tasks"))
|
|
})
|
|
|
|
test("returns correct path with default config parameter", () => {
|
|
//#when
|
|
const result = getTaskDir()
|
|
|
|
//#then
|
|
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks"))
|
|
})
|
|
})
|
|
|
|
describe("generateTaskId", () => {
|
|
test("generates task ID with T- prefix and UUID", () => {
|
|
//#when
|
|
const taskId = generateTaskId()
|
|
|
|
//#then
|
|
expect(taskId).toMatch(/^T-[a-f0-9-]{36}$/)
|
|
})
|
|
|
|
test("generates unique task IDs", () => {
|
|
//#when
|
|
const id1 = generateTaskId()
|
|
const id2 = generateTaskId()
|
|
|
|
//#then
|
|
expect(id1).not.toBe(id2)
|
|
})
|
|
})
|
|
|
|
describe("listTaskFiles", () => {
|
|
beforeEach(() => {
|
|
if (existsSync(TEST_DIR_ABS)) {
|
|
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (existsSync(TEST_DIR_ABS)) {
|
|
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("returns empty array for non-existent directory", () => {
|
|
//#given
|
|
const config: Partial<OhMyOpenCodeConfig> = {
|
|
new_task_system_enabled: false,
|
|
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
|
|
}
|
|
|
|
//#when
|
|
const result = listTaskFiles(config)
|
|
|
|
//#then
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
test("returns empty array for directory with no task files", () => {
|
|
//#given
|
|
const config: Partial<OhMyOpenCodeConfig> = {
|
|
new_task_system_enabled: false,
|
|
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
|
|
}
|
|
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
|
writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8")
|
|
|
|
//#when
|
|
const result = listTaskFiles(config)
|
|
|
|
//#then
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
test("lists task files with T- prefix and .json extension", () => {
|
|
//#given
|
|
const config: Partial<OhMyOpenCodeConfig> = {
|
|
new_task_system_enabled: false,
|
|
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
|
|
}
|
|
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
|
writeFileSync(join(TEST_DIR_ABS, "T-abc123.json"), "{}", "utf-8")
|
|
writeFileSync(join(TEST_DIR_ABS, "T-def456.json"), "{}", "utf-8")
|
|
writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8")
|
|
writeFileSync(join(TEST_DIR_ABS, "notes.md"), "# notes", "utf-8")
|
|
|
|
//#when
|
|
const result = listTaskFiles(config)
|
|
|
|
//#then
|
|
expect(result).toHaveLength(2)
|
|
expect(result).toContain("T-abc123")
|
|
expect(result).toContain("T-def456")
|
|
})
|
|
|
|
test("returns task IDs without .json extension", () => {
|
|
//#given
|
|
const config: Partial<OhMyOpenCodeConfig> = {
|
|
new_task_system_enabled: false,
|
|
sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } }
|
|
}
|
|
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
|
writeFileSync(join(TEST_DIR_ABS, "T-test-id.json"), "{}", "utf-8")
|
|
|
|
//#when
|
|
const result = listTaskFiles(config)
|
|
|
|
//#then
|
|
expect(result[0]).toBe("T-test-id")
|
|
expect(result[0]).not.toContain(".json")
|
|
})
|
|
})
|
|
|
|
describe("readJsonSafe", () => {
|
|
const testSchema = z.object({
|
|
id: z.string(),
|
|
value: z.number(),
|
|
})
|
|
|
|
beforeEach(() => {
|
|
if (existsSync(TEST_DIR_ABS)) {
|
|
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
|
}
|
|
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (existsSync(TEST_DIR_ABS)) {
|
|
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("returns null for non-existent file", () => {
|
|
//#given
|
|
const filePath = join(TEST_DIR_ABS, "nonexistent.json")
|
|
|
|
//#when
|
|
const result = readJsonSafe(filePath, testSchema)
|
|
|
|
//#then
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
test("returns parsed data for valid file", () => {
|
|
//#given
|
|
const filePath = join(TEST_DIR_ABS, "valid.json")
|
|
const data = { id: "test", value: 42 }
|
|
writeFileSync(filePath, JSON.stringify(data), "utf-8")
|
|
|
|
//#when
|
|
const result = readJsonSafe(filePath, testSchema)
|
|
|
|
//#then
|
|
expect(result).toEqual(data)
|
|
})
|
|
|
|
test("returns null for invalid JSON", () => {
|
|
//#given
|
|
const filePath = join(TEST_DIR_ABS, "invalid.json")
|
|
writeFileSync(filePath, "{ invalid json", "utf-8")
|
|
|
|
//#when
|
|
const result = readJsonSafe(filePath, testSchema)
|
|
|
|
//#then
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
test("returns null for data that fails schema validation", () => {
|
|
//#given
|
|
const filePath = join(TEST_DIR_ABS, "invalid-schema.json")
|
|
const data = { id: "test", value: "not-a-number" }
|
|
writeFileSync(filePath, JSON.stringify(data), "utf-8")
|
|
|
|
//#when
|
|
const result = readJsonSafe(filePath, testSchema)
|
|
|
|
//#then
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe("writeJsonAtomic", () => {
|
|
beforeEach(() => {
|
|
if (existsSync(TEST_DIR_ABS)) {
|
|
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (existsSync(TEST_DIR_ABS)) {
|
|
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("creates directory if it does not exist", () => {
|
|
//#given
|
|
const filePath = join(TEST_DIR_ABS, "nested", "dir", "file.json")
|
|
const data = { test: "data" }
|
|
|
|
//#when
|
|
writeJsonAtomic(filePath, data)
|
|
|
|
//#then
|
|
expect(existsSync(filePath)).toBe(true)
|
|
})
|
|
|
|
test("writes data atomically", async () => {
|
|
//#given
|
|
const filePath = join(TEST_DIR_ABS, "atomic.json")
|
|
const data = { id: "test", value: 123 }
|
|
|
|
//#when
|
|
writeJsonAtomic(filePath, data)
|
|
|
|
//#then
|
|
expect(existsSync(filePath)).toBe(true)
|
|
const content = await Bun.file(filePath).text()
|
|
expect(JSON.parse(content)).toEqual(data)
|
|
})
|
|
|
|
test("overwrites existing file", async () => {
|
|
//#given
|
|
const filePath = join(TEST_DIR_ABS, "overwrite.json")
|
|
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
|
writeFileSync(filePath, JSON.stringify({ old: "data" }), "utf-8")
|
|
|
|
//#when
|
|
const newData = { new: "data" }
|
|
writeJsonAtomic(filePath, newData)
|
|
|
|
//#then
|
|
const content = await Bun.file(filePath).text()
|
|
expect(JSON.parse(content)).toEqual(newData)
|
|
})
|
|
})
|
|
|
|
describe("acquireLock", () => {
|
|
beforeEach(() => {
|
|
if (existsSync(TEST_DIR_ABS)) {
|
|
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
|
}
|
|
mkdirSync(TEST_DIR_ABS, { recursive: true })
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (existsSync(TEST_DIR_ABS)) {
|
|
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test("acquires lock when no lock exists", () => {
|
|
//#given
|
|
const dirPath = TEST_DIR_ABS
|
|
|
|
//#when
|
|
const lock = acquireLock(dirPath)
|
|
|
|
//#then
|
|
expect(lock.acquired).toBe(true)
|
|
expect(existsSync(join(dirPath, ".lock"))).toBe(true)
|
|
|
|
//#cleanup
|
|
lock.release()
|
|
})
|
|
|
|
test("fails to acquire lock when fresh lock exists", () => {
|
|
//#given
|
|
const dirPath = TEST_DIR
|
|
const firstLock = acquireLock(dirPath)
|
|
|
|
//#when
|
|
const secondLock = acquireLock(dirPath)
|
|
|
|
//#then
|
|
expect(secondLock.acquired).toBe(false)
|
|
|
|
//#cleanup
|
|
firstLock.release()
|
|
})
|
|
|
|
test("acquires lock when stale lock exists (>30s)", () => {
|
|
//#given
|
|
const dirPath = TEST_DIR
|
|
const lockPath = join(dirPath, ".lock")
|
|
const staleTimestamp = Date.now() - 31000 // 31 seconds ago
|
|
writeFileSync(lockPath, JSON.stringify({ timestamp: staleTimestamp }), "utf-8")
|
|
|
|
//#when
|
|
const lock = acquireLock(dirPath)
|
|
|
|
//#then
|
|
expect(lock.acquired).toBe(true)
|
|
|
|
//#cleanup
|
|
lock.release()
|
|
})
|
|
|
|
test("release removes lock file", () => {
|
|
//#given
|
|
const dirPath = TEST_DIR
|
|
const lock = acquireLock(dirPath)
|
|
const lockPath = join(dirPath, ".lock")
|
|
|
|
//#when
|
|
lock.release()
|
|
|
|
//#then
|
|
expect(existsSync(lockPath)).toBe(false)
|
|
})
|
|
|
|
test("release is safe to call multiple times", () => {
|
|
//#given
|
|
const dirPath = TEST_DIR
|
|
const lock = acquireLock(dirPath)
|
|
|
|
//#when
|
|
lock.release()
|
|
lock.release()
|
|
|
|
//#then
|
|
expect(existsSync(join(dirPath, ".lock"))).toBe(false)
|
|
})
|
|
})
|