Implement unified Claude Tasks system with single multi-action tool (#1356)
* 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>
This commit is contained in:
@@ -97,6 +97,12 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_tools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1017,9 +1017,9 @@ Configure notification behavior for background task completion.
|
||||
| -------------- | ------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `force_enable` | `false` | Force enable session-notification even if external notification plugins are detected. Default: `false`. |
|
||||
|
||||
## Sisyphus Tasks & Swarm
|
||||
## Sisyphus Tasks
|
||||
|
||||
Configure Sisyphus Tasks and Swarm systems for advanced task management and multi-agent orchestration.
|
||||
Configure Sisyphus Tasks system for advanced task management.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1028,11 +1028,6 @@ Configure Sisyphus Tasks and Swarm systems for advanced task management and mult
|
||||
"enabled": false,
|
||||
"storage_path": ".sisyphus/tasks",
|
||||
"claude_code_compat": false
|
||||
},
|
||||
"swarm": {
|
||||
"enabled": false,
|
||||
"storage_path": ".sisyphus/teams",
|
||||
"ui_mode": "toast"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1046,14 +1041,6 @@ Configure Sisyphus Tasks and Swarm systems for advanced task management and mult
|
||||
| `storage_path` | `.sisyphus/tasks` | Storage path for tasks (relative to project root) |
|
||||
| `claude_code_compat` | `false` | Enable Claude Code path compatibility mode |
|
||||
|
||||
### Swarm Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------- | ------------------ | -------------------------------------------------------------- |
|
||||
| `enabled` | `false` | Enable Sisyphus Swarm system for multi-agent orchestration |
|
||||
| `storage_path` | `.sisyphus/teams` | Storage path for teams (relative to project root) |
|
||||
| `ui_mode` | `toast` | UI mode: `toast` (notifications), `tmux` (panes), or `both` |
|
||||
|
||||
## MCPs
|
||||
|
||||
Exa, Context7 and grep.app MCP enabled by default.
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "latest",
|
||||
"bun-types": "1.3.6",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -27,4 +27,6 @@ export type {
|
||||
RalphLoopConfig,
|
||||
TmuxConfig,
|
||||
TmuxLayout,
|
||||
SisyphusConfig,
|
||||
SisyphusTasksConfig,
|
||||
} from "./schema"
|
||||
|
||||
@@ -92,6 +92,7 @@ export const HookNameSchema = z.enum([
|
||||
"atlas",
|
||||
"unstable-agent-babysitter",
|
||||
"stop-continuation-guard",
|
||||
"tasks-todowrite-disabler",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
@@ -352,34 +353,26 @@ export const TmuxConfigSchema = z.object({
|
||||
})
|
||||
|
||||
export const SisyphusTasksConfigSchema = z.object({
|
||||
/** Enable Sisyphus Tasks system (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Storage path for tasks (default: .sisyphus/tasks) */
|
||||
storage_path: z.string().default(".sisyphus/tasks"),
|
||||
/** Enable Claude Code path compatibility mode */
|
||||
claude_code_compat: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const SisyphusSwarmConfigSchema = z.object({
|
||||
/** Enable Sisyphus Swarm system (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Storage path for teams (default: .sisyphus/teams) */
|
||||
storage_path: z.string().default(".sisyphus/teams"),
|
||||
/** UI mode: toast notifications, tmux panes, or both */
|
||||
ui_mode: z.enum(["toast", "tmux", "both"]).default("toast"),
|
||||
})
|
||||
|
||||
export const SisyphusConfigSchema = z.object({
|
||||
tasks: SisyphusTasksConfigSchema.optional(),
|
||||
swarm: SisyphusSwarmConfigSchema.optional(),
|
||||
})
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
/** Enable new task system (default: false) */
|
||||
new_task_system_enabled: z.boolean().default(false),
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||
disabled_hooks: z.array(HookNameSchema).optional(),
|
||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
|
||||
disabled_tools: z.array(z.string()).optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
categories: CategoriesConfigSchema.optional(),
|
||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||
@@ -424,7 +417,6 @@ export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSche
|
||||
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
|
||||
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
|
||||
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
|
||||
export type SisyphusSwarmConfig = z.infer<typeof SisyphusSwarmConfigSchema>
|
||||
export type SisyphusConfig = z.infer<typeof SisyphusConfigSchema>
|
||||
|
||||
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"
|
||||
|
||||
102
src/features/claude-tasks/AGENTS.md
Normal file
102
src/features/claude-tasks/AGENTS.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# CLAUDE TASKS FEATURE KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Claude Code compatible task schema and storage. Provides core task management utilities used by task-related tools and features.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
claude-tasks/
|
||||
├── types.ts # Task schema (Zod)
|
||||
├── types.test.ts # Schema validation tests (8 tests)
|
||||
├── storage.ts # File operations
|
||||
├── storage.test.ts # Storage tests (14 tests)
|
||||
└── index.ts # Barrel exports
|
||||
```
|
||||
|
||||
## TASK SCHEMA
|
||||
|
||||
```typescript
|
||||
type TaskStatus = "pending" | "in_progress" | "completed" | "deleted"
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
subject: string // Imperative: "Run tests"
|
||||
description: string
|
||||
status: TaskStatus
|
||||
activeForm?: string // Present continuous: "Running tests"
|
||||
blocks: string[] // Task IDs this task blocks
|
||||
blockedBy: string[] // Task IDs blocking this task
|
||||
owner?: string // Agent name
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
```
|
||||
|
||||
**Key Differences from Legacy**:
|
||||
- `subject` (was `title`)
|
||||
- `blockedBy` (was `dependsOn`)
|
||||
- No `parentID`, `repoURL`, `threadID` fields
|
||||
|
||||
## STORAGE UTILITIES
|
||||
|
||||
### getTaskDir(teamName, config)
|
||||
|
||||
Returns: `.sisyphus/tasks/{teamName}` (or custom path from config)
|
||||
|
||||
### readJsonSafe(filePath, schema)
|
||||
|
||||
- Returns parsed & validated data or `null`
|
||||
- Safe for missing files, invalid JSON, schema violations
|
||||
|
||||
### writeJsonAtomic(filePath, data)
|
||||
|
||||
- Atomic write via temp file + rename
|
||||
- Creates parent directories automatically
|
||||
- Cleans up temp file on error
|
||||
|
||||
### acquireLock(dirPath)
|
||||
|
||||
- File-based lock: `.lock` file with timestamp
|
||||
- 30-second stale threshold
|
||||
- Returns `{ acquired: boolean, release: () => void }`
|
||||
|
||||
## TESTING
|
||||
|
||||
**types.test.ts** (8 tests):
|
||||
- Valid status enum values
|
||||
- Required vs optional fields
|
||||
- Array validation (blocks, blockedBy)
|
||||
- Schema rejection for invalid data
|
||||
|
||||
**storage.test.ts** (14 tests):
|
||||
- Path construction
|
||||
- Safe JSON reading (missing files, invalid JSON, schema failures)
|
||||
- Atomic writes (directory creation, overwrites)
|
||||
- Lock acquisition (fresh locks, stale locks, release)
|
||||
|
||||
## USAGE
|
||||
|
||||
```typescript
|
||||
import { TaskSchema, getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock } from "./features/claude-tasks"
|
||||
|
||||
const taskDir = getTaskDir("my-team", config)
|
||||
const lock = acquireLock(taskDir)
|
||||
|
||||
try {
|
||||
const task = readJsonSafe(join(taskDir, "1.json"), TaskSchema)
|
||||
if (task) {
|
||||
task.status = "completed"
|
||||
writeJsonAtomic(join(taskDir, "1.json"), task)
|
||||
}
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Direct fs operations (use storage utilities)
|
||||
- Skipping lock acquisition for writes
|
||||
- Ignoring null returns from readJsonSafe
|
||||
- Using old schema field names (title, dependsOn)
|
||||
2
src/features/claude-tasks/index.ts
Normal file
2
src/features/claude-tasks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./storage"
|
||||
361
src/features/claude-tasks/storage.test.ts
Normal file
361
src/features/claude-tasks/storage.test.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
112
src/features/claude-tasks/storage.ts
Normal file
112
src/features/claude-tasks/storage.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { join, dirname } from "path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from "fs"
|
||||
import { randomUUID } from "crypto"
|
||||
import type { z } from "zod"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
export function getTaskDir(config: Partial<OhMyOpenCodeConfig> = {}): string {
|
||||
const tasksConfig = config.sisyphus?.tasks
|
||||
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
|
||||
return join(process.cwd(), storagePath)
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const result = schema.safeParse(parsed)
|
||||
|
||||
if (!result.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonAtomic(filePath: string, data: unknown): void {
|
||||
const dir = dirname(filePath)
|
||||
ensureDir(dir)
|
||||
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||
|
||||
try {
|
||||
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
|
||||
renameSync(tempPath, filePath)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tempPath)) {
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const STALE_LOCK_THRESHOLD_MS = 30000
|
||||
|
||||
export function generateTaskId(): string {
|
||||
return `T-${randomUUID()}`
|
||||
}
|
||||
|
||||
export function listTaskFiles(config: Partial<OhMyOpenCodeConfig> = {}): string[] {
|
||||
const dir = getTaskDir(config)
|
||||
if (!existsSync(dir)) return []
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.json') && f.startsWith('T-'))
|
||||
.map((f) => f.replace('.json', ''))
|
||||
}
|
||||
|
||||
export function acquireLock(dirPath: string): { acquired: boolean; release: () => void } {
|
||||
const lockPath = join(dirPath, ".lock")
|
||||
const now = Date.now()
|
||||
|
||||
if (existsSync(lockPath)) {
|
||||
try {
|
||||
const lockContent = readFileSync(lockPath, "utf-8")
|
||||
const lockData = JSON.parse(lockContent)
|
||||
const lockAge = now - lockData.timestamp
|
||||
|
||||
if (lockAge <= STALE_LOCK_THRESHOLD_MS) {
|
||||
return {
|
||||
acquired: false,
|
||||
release: () => {
|
||||
// No-op release for failed acquisition
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If lock file is corrupted, treat as stale and override
|
||||
}
|
||||
}
|
||||
|
||||
ensureDir(dirPath)
|
||||
writeFileSync(lockPath, JSON.stringify({ timestamp: now }), "utf-8")
|
||||
|
||||
return {
|
||||
acquired: true,
|
||||
release: () => {
|
||||
try {
|
||||
if (existsSync(lockPath)) {
|
||||
unlinkSync(lockPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
174
src/features/claude-tasks/types.test.ts
Normal file
174
src/features/claude-tasks/types.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { TaskSchema, TaskStatusSchema, type Task, type TaskStatus } from "./types"
|
||||
|
||||
describe("TaskStatusSchema", () => {
|
||||
test("accepts valid status values", () => {
|
||||
//#given
|
||||
const validStatuses: TaskStatus[] = ["pending", "in_progress", "completed", "deleted"]
|
||||
|
||||
//#when
|
||||
const results = validStatuses.map((status) => TaskStatusSchema.safeParse(status))
|
||||
|
||||
//#then
|
||||
results.forEach((result) => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid status values", () => {
|
||||
//#given
|
||||
const invalidStatuses = ["open", "closed", "archived", ""]
|
||||
|
||||
//#when
|
||||
const results = invalidStatuses.map((status) => TaskStatusSchema.safeParse(status))
|
||||
|
||||
//#then
|
||||
results.forEach((result) => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("TaskSchema", () => {
|
||||
test("parses valid Task with all required fields", () => {
|
||||
//#given
|
||||
const validTask = {
|
||||
id: "1",
|
||||
subject: "Run tests",
|
||||
description: "Execute test suite",
|
||||
status: "pending" as TaskStatus,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = TaskSchema.safeParse(validTask)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe("1")
|
||||
expect(result.data.subject).toBe("Run tests")
|
||||
expect(result.data.status).toBe("pending")
|
||||
expect(result.data.blocks).toEqual([])
|
||||
expect(result.data.blockedBy).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
test("parses Task with optional fields", () => {
|
||||
//#given
|
||||
const taskWithOptionals: Task = {
|
||||
id: "2",
|
||||
subject: "Deploy app",
|
||||
description: "Deploy to production",
|
||||
status: "in_progress",
|
||||
activeForm: "Deploying app",
|
||||
blocks: ["3", "4"],
|
||||
blockedBy: ["1"],
|
||||
owner: "sisyphus",
|
||||
metadata: { priority: "high", tags: ["urgent"] },
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = TaskSchema.safeParse(taskWithOptionals)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.activeForm).toBe("Deploying app")
|
||||
expect(result.data.owner).toBe("sisyphus")
|
||||
expect(result.data.metadata).toEqual({ priority: "high", tags: ["urgent"] })
|
||||
}
|
||||
})
|
||||
|
||||
test("validates blocks and blockedBy as arrays", () => {
|
||||
//#given
|
||||
const taskWithDeps = {
|
||||
id: "3",
|
||||
subject: "Test feature",
|
||||
description: "Test new feature",
|
||||
status: "pending" as TaskStatus,
|
||||
blocks: ["4", "5", "6"],
|
||||
blockedBy: ["1", "2"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = TaskSchema.safeParse(taskWithDeps)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(Array.isArray(result.data.blocks)).toBe(true)
|
||||
expect(result.data.blocks).toHaveLength(3)
|
||||
expect(Array.isArray(result.data.blockedBy)).toBe(true)
|
||||
expect(result.data.blockedBy).toHaveLength(2)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects Task missing required fields", () => {
|
||||
//#given
|
||||
const invalidTasks = [
|
||||
{ subject: "No ID", description: "Missing id", status: "pending", blocks: [], blockedBy: [] },
|
||||
{ id: "1", description: "No subject", status: "pending", blocks: [], blockedBy: [] },
|
||||
{ id: "1", subject: "No description", status: "pending", blocks: [], blockedBy: [] },
|
||||
{ id: "1", subject: "No status", description: "Missing status", blocks: [], blockedBy: [] },
|
||||
{ id: "1", subject: "No blocks", description: "Missing blocks", status: "pending", blockedBy: [] },
|
||||
{ id: "1", subject: "No blockedBy", description: "Missing blockedBy", status: "pending", blocks: [] },
|
||||
]
|
||||
|
||||
//#when
|
||||
const results = invalidTasks.map((task) => TaskSchema.safeParse(task))
|
||||
|
||||
//#then
|
||||
results.forEach((result) => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects Task with invalid status", () => {
|
||||
//#given
|
||||
const taskWithInvalidStatus = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test task",
|
||||
status: "invalid_status",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = TaskSchema.safeParse(taskWithInvalidStatus)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects Task with non-array blocks or blockedBy", () => {
|
||||
//#given
|
||||
const taskWithInvalidBlocks = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test task",
|
||||
status: "pending",
|
||||
blocks: "not-an-array",
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const taskWithInvalidBlockedBy = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test task",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: "not-an-array",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result1 = TaskSchema.safeParse(taskWithInvalidBlocks)
|
||||
const result2 = TaskSchema.safeParse(taskWithInvalidBlockedBy)
|
||||
|
||||
//#then
|
||||
expect(result1.success).toBe(false)
|
||||
expect(result2.success).toBe(false)
|
||||
})
|
||||
})
|
||||
20
src/features/claude-tasks/types.ts
Normal file
20
src/features/claude-tasks/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed", "deleted"])
|
||||
export type TaskStatus = z.infer<typeof TaskStatusSchema>
|
||||
|
||||
export const TaskSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
status: TaskStatusSchema,
|
||||
activeForm: z.string().optional(),
|
||||
blocks: z.array(z.string()),
|
||||
blockedBy: z.array(z.string()),
|
||||
owner: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type Task = z.infer<typeof TaskSchema>
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
MailboxMessageSchema,
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
TaskAssignmentSchema,
|
||||
JoinRequestSchema,
|
||||
ProtocolMessageSchema,
|
||||
} from "./types"
|
||||
|
||||
describe("MailboxMessageSchema", () => {
|
||||
// given a valid mailbox message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses valid message", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: '{"type":"idle_notification"}',
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
read: false,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
// given a message with optional color
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses message with color", () => {
|
||||
const msg = {
|
||||
from: "agent-001",
|
||||
text: "{}",
|
||||
timestamp: "2026-01-27T10:00:00Z",
|
||||
color: "blue",
|
||||
read: true,
|
||||
}
|
||||
expect(MailboxMessageSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProtocolMessageSchema", () => {
|
||||
// given permission_request message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses permission_request", () => {
|
||||
const msg = {
|
||||
type: "permission_request",
|
||||
requestId: "req-123",
|
||||
toolName: "Bash",
|
||||
input: { command: "rm -rf /" },
|
||||
agentId: "agent-001",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(PermissionRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
// given permission_response message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses permission_response", () => {
|
||||
const approved = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "approved",
|
||||
updatedInput: { command: "ls" },
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(approved).success).toBe(true)
|
||||
|
||||
const rejected = {
|
||||
type: "permission_response",
|
||||
requestId: "req-123",
|
||||
decision: "rejected",
|
||||
feedback: "Too dangerous",
|
||||
}
|
||||
expect(PermissionResponseSchema.safeParse(rejected).success).toBe(true)
|
||||
})
|
||||
|
||||
// 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
|
||||
it("parses task_assignment", () => {
|
||||
const msg = {
|
||||
type: "task_assignment",
|
||||
taskId: "1",
|
||||
subject: "Fix bug",
|
||||
description: "Fix the auth bug",
|
||||
assignedBy: "team-lead",
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
expect(TaskAssignmentSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
|
||||
// given join_request message
|
||||
// when parsing
|
||||
// then it should succeed
|
||||
it("parses join_request", () => {
|
||||
const msg = {
|
||||
type: "join_request",
|
||||
agentName: "new-agent",
|
||||
sessionId: "sess-123",
|
||||
}
|
||||
expect(JoinRequestSchema.safeParse(msg).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const MailboxMessageSchema = z.object({
|
||||
from: z.string(),
|
||||
text: z.string(),
|
||||
timestamp: z.string(),
|
||||
color: z.string().optional(),
|
||||
read: z.boolean(),
|
||||
})
|
||||
|
||||
export type MailboxMessage = z.infer<typeof MailboxMessageSchema>
|
||||
|
||||
export const PermissionRequestSchema = z.object({
|
||||
type: z.literal("permission_request"),
|
||||
requestId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.unknown(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type PermissionRequest = z.infer<typeof PermissionRequestSchema>
|
||||
|
||||
export const PermissionResponseSchema = z.object({
|
||||
type: z.literal("permission_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
updatedInput: z.unknown().optional(),
|
||||
feedback: z.string().optional(),
|
||||
permissionUpdates: z.unknown().optional(),
|
||||
})
|
||||
|
||||
export type PermissionResponse = z.infer<typeof PermissionResponseSchema>
|
||||
|
||||
export const ShutdownRequestSchema = z.object({
|
||||
type: z.literal("shutdown_request"),
|
||||
})
|
||||
|
||||
export type ShutdownRequest = z.infer<typeof ShutdownRequestSchema>
|
||||
|
||||
export const ShutdownApprovedSchema = z.object({
|
||||
type: z.literal("shutdown_approved"),
|
||||
})
|
||||
|
||||
export type ShutdownApproved = z.infer<typeof ShutdownApprovedSchema>
|
||||
|
||||
export const ShutdownRejectedSchema = z.object({
|
||||
type: z.literal("shutdown_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ShutdownRejected = z.infer<typeof ShutdownRejectedSchema>
|
||||
|
||||
export const TaskAssignmentSchema = z.object({
|
||||
type: z.literal("task_assignment"),
|
||||
taskId: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
assignedBy: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskAssignment = z.infer<typeof TaskAssignmentSchema>
|
||||
|
||||
export const TaskCompletedSchema = z.object({
|
||||
type: z.literal("task_completed"),
|
||||
taskId: z.string(),
|
||||
agentId: z.string(),
|
||||
timestamp: z.number(),
|
||||
})
|
||||
|
||||
export type TaskCompleted = z.infer<typeof TaskCompletedSchema>
|
||||
|
||||
export const IdleNotificationSchema = z.object({
|
||||
type: z.literal("idle_notification"),
|
||||
})
|
||||
|
||||
export type IdleNotification = z.infer<typeof IdleNotificationSchema>
|
||||
|
||||
export const JoinRequestSchema = z.object({
|
||||
type: z.literal("join_request"),
|
||||
agentName: z.string(),
|
||||
sessionId: z.string(),
|
||||
})
|
||||
|
||||
export type JoinRequest = z.infer<typeof JoinRequestSchema>
|
||||
|
||||
export const JoinApprovedSchema = z.object({
|
||||
type: z.literal("join_approved"),
|
||||
agentName: z.string(),
|
||||
teamName: z.string(),
|
||||
})
|
||||
|
||||
export type JoinApproved = z.infer<typeof JoinApprovedSchema>
|
||||
|
||||
export const JoinRejectedSchema = z.object({
|
||||
type: z.literal("join_rejected"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
export type JoinRejected = z.infer<typeof JoinRejectedSchema>
|
||||
|
||||
export const PlanApprovalRequestSchema = z.object({
|
||||
type: z.literal("plan_approval_request"),
|
||||
requestId: z.string(),
|
||||
plan: z.string(),
|
||||
agentId: z.string(),
|
||||
})
|
||||
|
||||
export type PlanApprovalRequest = z.infer<typeof PlanApprovalRequestSchema>
|
||||
|
||||
export const PlanApprovalResponseSchema = z.object({
|
||||
type: z.literal("plan_approval_response"),
|
||||
requestId: z.string(),
|
||||
decision: z.enum(["approved", "rejected"]),
|
||||
feedback: z.string().optional(),
|
||||
})
|
||||
|
||||
export type PlanApprovalResponse = z.infer<typeof PlanApprovalResponseSchema>
|
||||
|
||||
export const ModeSetRequestSchema = z.object({
|
||||
type: z.literal("mode_set_request"),
|
||||
mode: z.enum(["acceptEdits", "bypassPermissions", "default", "delegate", "dontAsk", "plan"]),
|
||||
})
|
||||
|
||||
export type ModeSetRequest = z.infer<typeof ModeSetRequestSchema>
|
||||
|
||||
export const TeamPermissionUpdateSchema = z.object({
|
||||
type: z.literal("team_permission_update"),
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
|
||||
export type TeamPermissionUpdate = z.infer<typeof TeamPermissionUpdateSchema>
|
||||
|
||||
export const ProtocolMessageSchema = z.discriminatedUnion("type", [
|
||||
PermissionRequestSchema,
|
||||
PermissionResponseSchema,
|
||||
ShutdownRequestSchema,
|
||||
ShutdownApprovedSchema,
|
||||
ShutdownRejectedSchema,
|
||||
TaskAssignmentSchema,
|
||||
TaskCompletedSchema,
|
||||
IdleNotificationSchema,
|
||||
JoinRequestSchema,
|
||||
JoinApprovedSchema,
|
||||
JoinRejectedSchema,
|
||||
PlanApprovalRequestSchema,
|
||||
PlanApprovalResponseSchema,
|
||||
ModeSetRequestSchema,
|
||||
TeamPermissionUpdateSchema,
|
||||
])
|
||||
|
||||
export type ProtocolMessage = z.infer<typeof ProtocolMessageSchema>
|
||||
@@ -1,178 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { join } from "path"
|
||||
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "fs"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
getTaskDir,
|
||||
getTaskPath,
|
||||
getTeamDir,
|
||||
getInboxPath,
|
||||
ensureDir,
|
||||
readJsonSafe,
|
||||
writeJsonAtomic,
|
||||
} from "./storage"
|
||||
|
||||
const TEST_DIR = join(import.meta.dirname, ".test-storage")
|
||||
|
||||
describe("Storage Utilities", () => {
|
||||
beforeEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("getTaskDir", () => {
|
||||
// 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
|
||||
it("returns claude code path when compat enabled", () => {
|
||||
const config = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: ".sisyphus/tasks",
|
||||
claude_code_compat: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = getTaskDir("list-123", config as any)
|
||||
expect(result).toContain(".cache/claude-code/tasks/list-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTaskPath", () => {
|
||||
// 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)
|
||||
expect(result).toContain("list-123/1.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTeamDir", () => {
|
||||
// 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)
|
||||
expect(result).toContain(".sisyphus/teams/my-team")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getInboxPath", () => {
|
||||
// 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)
|
||||
expect(result).toContain("my-team/inboxes/agent-001.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ensureDir", () => {
|
||||
// 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
|
||||
it("does not throw for existing directory", () => {
|
||||
const dirPath = join(TEST_DIR, "existing")
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
expect(() => ensureDir(dirPath)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("readJsonSafe", () => {
|
||||
// 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")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test", value: 42 }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toEqual({ name: "test", value: 42 })
|
||||
})
|
||||
|
||||
// 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
|
||||
it("returns null for invalid JSON", () => {
|
||||
const testSchema = z.object({ name: z.string() })
|
||||
const filePath = join(TEST_DIR, "invalid.json")
|
||||
writeFileSync(filePath, "not valid json")
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// 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")
|
||||
writeFileSync(filePath, JSON.stringify({ name: "test" }))
|
||||
|
||||
const result = readJsonSafe(filePath, testSchema)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeJsonAtomic", () => {
|
||||
// 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 }
|
||||
|
||||
writeJsonAtomic(filePath, data)
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
})
|
||||
|
||||
// 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 })
|
||||
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { join, dirname } from "path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import type { z } from "zod"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
export function getTaskDir(listId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const tasksConfig = config.sisyphus?.tasks
|
||||
|
||||
if (tasksConfig?.claude_code_compat) {
|
||||
return join(homedir(), ".cache", "claude-code", "tasks", listId)
|
||||
}
|
||||
|
||||
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
|
||||
return join(process.cwd(), storagePath, listId)
|
||||
}
|
||||
|
||||
export function getTaskPath(listId: string, taskId: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTaskDir(listId, config), `${taskId}.json`)
|
||||
}
|
||||
|
||||
export function getTeamDir(teamName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
const swarmConfig = config.sisyphus?.swarm
|
||||
|
||||
if (swarmConfig?.storage_path?.includes("claude")) {
|
||||
return join(homedir(), ".claude", "teams", teamName)
|
||||
}
|
||||
|
||||
const storagePath = swarmConfig?.storage_path ?? ".sisyphus/teams"
|
||||
return join(process.cwd(), storagePath, teamName)
|
||||
}
|
||||
|
||||
export function getInboxPath(teamName: string, agentName: string, config: Partial<OhMyOpenCodeConfig>): string {
|
||||
return join(getTeamDir(teamName, config), "inboxes", `${agentName}.json`)
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonSafe<T>(filePath: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const result = schema.safeParse(parsed)
|
||||
|
||||
if (!result.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJsonAtomic(filePath: string, data: unknown): void {
|
||||
const dir = dirname(filePath)
|
||||
ensureDir(dir)
|
||||
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`
|
||||
|
||||
try {
|
||||
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8")
|
||||
renameSync(tempPath, filePath)
|
||||
} catch (error) {
|
||||
try {
|
||||
if (existsSync(tempPath)) {
|
||||
unlinkSync(tempPath)
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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
|
||||
it("parses valid task object", () => {
|
||||
const validTask = {
|
||||
id: "1",
|
||||
subject: "Fix authentication bug",
|
||||
description: "Users report 401 errors",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(validTask)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
// 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",
|
||||
subject: "Add unit tests",
|
||||
description: "Write tests for auth module",
|
||||
activeForm: "Adding unit tests",
|
||||
owner: "agent-001",
|
||||
status: "in_progress",
|
||||
blocks: ["3"],
|
||||
blockedBy: ["1"],
|
||||
metadata: { priority: "high", labels: ["bug"] },
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(taskWithOptionals)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
// given an invalid status value
|
||||
// when parsing with TaskSchema
|
||||
// then it should fail
|
||||
it("rejects invalid status", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
subject: "Test",
|
||||
description: "Test",
|
||||
status: "invalid_status",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
// given missing required fields
|
||||
// when parsing with TaskSchema
|
||||
// then it should fail
|
||||
it("rejects missing required fields", () => {
|
||||
const invalidTask = {
|
||||
id: "1",
|
||||
// missing subject, description, status, blocks, blockedBy
|
||||
}
|
||||
|
||||
const result = TaskSchema.safeParse(invalidTask)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TaskStatusSchema", () => {
|
||||
// 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)
|
||||
expect(TaskStatusSchema.safeParse("completed").success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"])
|
||||
export type TaskStatus = z.infer<typeof TaskStatusSchema>
|
||||
|
||||
export const TaskSchema = z.object({
|
||||
id: z.string(),
|
||||
subject: z.string(),
|
||||
description: z.string(),
|
||||
activeForm: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
status: TaskStatusSchema,
|
||||
blocks: z.array(z.string()),
|
||||
blockedBy: z.array(z.string()),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type Task = z.infer<typeof TaskSchema>
|
||||
|
||||
export const TaskCreateInputSchema = z.object({
|
||||
subject: z.string().describe("Task title"),
|
||||
description: z.string().describe("Detailed description"),
|
||||
activeForm: z.string().optional().describe("Text shown when in progress"),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
||||
|
||||
export const TaskUpdateInputSchema = z.object({
|
||||
taskId: z.string().describe("Task ID to update"),
|
||||
subject: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
activeForm: z.string().optional(),
|
||||
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
|
||||
addBlocks: z.array(z.string()).optional().describe("Task IDs this task will block"),
|
||||
addBlockedBy: z.array(z.string()).optional().describe("Task IDs that block this task"),
|
||||
owner: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
|
||||
125
src/hooks/task-reminder/index.test.ts
Normal file
125
src/hooks/task-reminder/index.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { createTaskReminderHook } from "./index"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const mockCtx = {} as PluginInput
|
||||
|
||||
describe("TaskReminderHook", () => {
|
||||
let hook: ReturnType<typeof createTaskReminderHook>
|
||||
|
||||
beforeEach(() => {
|
||||
hook = createTaskReminderHook(mockCtx)
|
||||
})
|
||||
|
||||
test("does not inject reminder before 10 turns", async () => {
|
||||
//#given
|
||||
const sessionID = "test-session"
|
||||
const output = { output: "Result" }
|
||||
|
||||
//#when
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await hook["tool.execute.after"]?.(
|
||||
{ tool: "bash", sessionID, callID: `call-${i}` },
|
||||
output
|
||||
)
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(output.output).not.toContain("task tools haven't been used")
|
||||
})
|
||||
|
||||
test("injects reminder after 10 turns without task tool usage", async () => {
|
||||
//#given
|
||||
const sessionID = "test-session"
|
||||
const output = { output: "Result" }
|
||||
|
||||
//#when
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await hook["tool.execute.after"]?.(
|
||||
{ tool: "bash", sessionID, callID: `call-${i}` },
|
||||
output
|
||||
)
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("task tools haven't been used")
|
||||
})
|
||||
|
||||
test("resets counter when task tool is used", async () => {
|
||||
//#given
|
||||
const sessionID = "test-session"
|
||||
const output = { output: "Result" }
|
||||
|
||||
//#when
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await hook["tool.execute.after"]?.(
|
||||
{ tool: "bash", sessionID, callID: `call-${i}` },
|
||||
output
|
||||
)
|
||||
}
|
||||
await hook["tool.execute.after"]?.(
|
||||
{ tool: "task", sessionID, callID: "call-task" },
|
||||
output
|
||||
)
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await hook["tool.execute.after"]?.(
|
||||
{ tool: "bash", sessionID, callID: `call-after-${i}` },
|
||||
output
|
||||
)
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(output.output).not.toContain("task tools haven't been used")
|
||||
})
|
||||
|
||||
test("resets counter after injecting reminder", async () => {
|
||||
//#given
|
||||
const sessionID = "test-session"
|
||||
const output1 = { output: "Result 1" }
|
||||
const output2 = { output: "Result 2" }
|
||||
|
||||
//#when
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await hook["tool.execute.after"]?.(
|
||||
{ tool: "bash", sessionID, callID: `call-1-${i}` },
|
||||
output1
|
||||
)
|
||||
}
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await hook["tool.execute.after"]?.(
|
||||
{ tool: "bash", sessionID, callID: `call-2-${i}` },
|
||||
output2
|
||||
)
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(output1.output).toContain("task tools haven't been used")
|
||||
expect(output2.output).not.toContain("task tools haven't been used")
|
||||
})
|
||||
|
||||
test("tracks separate counters per session", async () => {
|
||||
//#given
|
||||
const session1 = "session-1"
|
||||
const session2 = "session-2"
|
||||
const output1 = { output: "Result 1" }
|
||||
const output2 = { output: "Result 2" }
|
||||
|
||||
//#when
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await hook["tool.execute.after"]?.(
|
||||
{ tool: "bash", sessionID: session1, callID: `call-${i}` },
|
||||
output1
|
||||
)
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await hook["tool.execute.after"]?.(
|
||||
{ tool: "bash", sessionID: session2, callID: `call-${i}` },
|
||||
output2
|
||||
)
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(output1.output).toContain("task tools haven't been used")
|
||||
expect(output2.output).not.toContain("task tools haven't been used")
|
||||
})
|
||||
})
|
||||
45
src/hooks/task-reminder/index.ts
Normal file
45
src/hooks/task-reminder/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const TASK_TOOLS = new Set(["task"])
|
||||
const TURN_THRESHOLD = 10
|
||||
const REMINDER_MESSAGE = `
|
||||
|
||||
The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done).`
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string
|
||||
sessionID: string
|
||||
callID: string
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
output: string
|
||||
}
|
||||
|
||||
export function createTaskReminderHook(_ctx: PluginInput) {
|
||||
const sessionCounters = new Map<string, number>()
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
const { tool, sessionID } = input
|
||||
const toolLower = tool.toLowerCase()
|
||||
|
||||
if (TASK_TOOLS.has(toolLower)) {
|
||||
sessionCounters.set(sessionID, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const currentCount = sessionCounters.get(sessionID) ?? 0
|
||||
const newCount = currentCount + 1
|
||||
|
||||
if (newCount >= TURN_THRESHOLD) {
|
||||
output.output += REMINDER_MESSAGE
|
||||
sessionCounters.set(sessionID, 0)
|
||||
} else {
|
||||
sessionCounters.set(sessionID, newCount)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const TARGET_TOOLS = ["task", "Task", "call_omo_agent", "delegate_task"]
|
||||
const TARGET_TOOLS = ["task", "Task", "task_tool", "call_omo_agent", "delegate_task"]
|
||||
|
||||
const SESSION_ID_PATTERNS = [
|
||||
/Session ID: (ses_[a-zA-Z0-9_-]+)/,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOu
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return
|
||||
if (typeof output.output !== 'string') return
|
||||
|
||||
try {
|
||||
const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
interactive_bash,
|
||||
startTmuxCheck,
|
||||
lspManager,
|
||||
createTask,
|
||||
} from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
@@ -419,6 +420,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
modelCacheState,
|
||||
});
|
||||
|
||||
const newTaskSystemEnabled = pluginConfig.new_task_system_enabled ?? false;
|
||||
const taskTool = newTaskSystemEnabled ? createTask(pluginConfig) : null;
|
||||
|
||||
return {
|
||||
tool: {
|
||||
...builtinTools,
|
||||
@@ -430,6 +434,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
skill_mcp: skillMcpTool,
|
||||
slashcommand: slashcommandTool,
|
||||
interactive_bash,
|
||||
...(taskTool ? { task: taskTool } : {}),
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
|
||||
@@ -113,7 +113,7 @@ export function loadPluginConfig(
|
||||
|
||||
// Load user config first (base)
|
||||
let config: OhMyOpenCodeConfig =
|
||||
loadConfigFromPath(userConfigPath, ctx) ?? {};
|
||||
loadConfigFromPath(userConfigPath, ctx) ?? { new_task_system_enabled: false };
|
||||
|
||||
// Override with project config
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
||||
|
||||
@@ -403,6 +403,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
LspHover: false,
|
||||
LspCodeActions: false,
|
||||
LspCodeActionResolve: false,
|
||||
"task_*": false,
|
||||
teammate: false,
|
||||
};
|
||||
|
||||
type AgentWithPermission = { permission?: Record<string, unknown> };
|
||||
@@ -417,11 +419,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
}
|
||||
if (agentResult["atlas"]) {
|
||||
const agent = agentResult["atlas"] as AgentWithPermission;
|
||||
agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny", delegate_task: "allow" };
|
||||
agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny", delegate_task: "allow", "task_*": "allow", teammate: "allow" };
|
||||
}
|
||||
if (agentResult.sisyphus) {
|
||||
const agent = agentResult.sisyphus as AgentWithPermission;
|
||||
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" };
|
||||
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow", "task_*": "allow", teammate: "allow" };
|
||||
}
|
||||
if (agentResult.hephaestus) {
|
||||
const agent = agentResult.hephaestus as AgentWithPermission;
|
||||
@@ -429,11 +431,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
}
|
||||
if (agentResult["prometheus"]) {
|
||||
const agent = agentResult["prometheus"] as AgentWithPermission;
|
||||
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" };
|
||||
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow", "task_*": "allow", teammate: "allow" };
|
||||
}
|
||||
if (agentResult["sisyphus-junior"]) {
|
||||
const agent = agentResult["sisyphus-junior"] as AgentWithPermission;
|
||||
agent.permission = { ...agent.permission, delegate_task: "allow" };
|
||||
agent.permission = { ...agent.permission, delegate_task: "allow", "task_*": "allow", teammate: "allow" };
|
||||
}
|
||||
|
||||
config.permission = {
|
||||
|
||||
@@ -43,6 +43,10 @@ export function truncateToTokenLimit(
|
||||
maxTokens: number,
|
||||
preserveHeaderLines = 3,
|
||||
): TruncationResult {
|
||||
if (typeof output !== 'string') {
|
||||
return { result: String(output ?? ''), truncated: false };
|
||||
}
|
||||
|
||||
const currentTokens = estimateTokens(output);
|
||||
|
||||
if (currentTokens <= maxTokens) {
|
||||
@@ -147,6 +151,10 @@ export async function dynamicTruncate(
|
||||
output: string,
|
||||
options: TruncationOptions = {},
|
||||
): Promise<TruncationResult> {
|
||||
if (typeof output !== 'string') {
|
||||
return { result: String(output ?? ''), truncated: false };
|
||||
}
|
||||
|
||||
const {
|
||||
targetMaxTokens = DEFAULT_TARGET_MAX_TOKENS,
|
||||
preserveHeaderLines = 3,
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface DelegateTaskArgs {
|
||||
session_id?: string
|
||||
command?: string
|
||||
load_skills: string[]
|
||||
execute?: {
|
||||
task_id: string
|
||||
task_dir?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolContextWithMetadata {
|
||||
|
||||
@@ -73,3 +73,5 @@ export const builtinTools: Record<string, ToolDefinition> = {
|
||||
session_search,
|
||||
session_info,
|
||||
}
|
||||
|
||||
export { createTask } from "./task"
|
||||
|
||||
2
src/tools/task/index.ts
Normal file
2
src/tools/task/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { createTask } from "./task"
|
||||
export type { TaskObject, TaskStatus, TaskCreateInput, TaskListInput, TaskGetInput, TaskUpdateInput, TaskDeleteInput } from "./types"
|
||||
768
src/tools/task/task.test.ts
Normal file
768
src/tools/task/task.test.ts
Normal file
@@ -0,0 +1,768 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { existsSync, rmSync, mkdirSync, writeFileSync, readdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import type { TaskObject } from "./types"
|
||||
import { createTask } from "./task"
|
||||
|
||||
const TEST_STORAGE = ".test-task-tool"
|
||||
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
|
||||
const TEST_CONFIG = {
|
||||
new_task_system_enabled: true,
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: TEST_STORAGE,
|
||||
claude_code_compat: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const TEST_SESSION_ID = "test-session-123"
|
||||
const TEST_ABORT_CONTROLLER = new AbortController()
|
||||
const TEST_CONTEXT = {
|
||||
sessionID: TEST_SESSION_ID,
|
||||
messageID: "test-message-123",
|
||||
agent: "test-agent",
|
||||
abort: TEST_ABORT_CONTROLLER.signal,
|
||||
}
|
||||
|
||||
describe("task_tool", () => {
|
||||
let taskTool: ReturnType<typeof createTask>
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_STORAGE)) {
|
||||
rmSync(TEST_STORAGE, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
taskTool = createTask(TEST_CONFIG)
|
||||
})
|
||||
|
||||
async function createTestTask(title: string, overrides: Partial<Parameters<typeof taskTool.execute>[0]> = {}): Promise<string> {
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title,
|
||||
...overrides,
|
||||
}
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
return (result as { task: TaskObject }).task.id
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_STORAGE)) {
|
||||
rmSync(TEST_STORAGE, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// CREATE ACTION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe("create action", () => {
|
||||
test("creates task with required title field", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Implement authentication",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("task")
|
||||
expect(result.task).toHaveProperty("id")
|
||||
expect(result.task.title).toBe("Implement authentication")
|
||||
expect(result.task.status).toBe("open")
|
||||
})
|
||||
|
||||
test("auto-generates T-{uuid} format ID", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Test task",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.id).toMatch(/^T-[a-f0-9-]+$/)
|
||||
})
|
||||
|
||||
test("auto-records threadID from session context", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Test task",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task).toHaveProperty("threadID")
|
||||
expect(typeof result.task.threadID).toBe("string")
|
||||
})
|
||||
|
||||
test("sets status to open by default", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Test task",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.status).toBe("open")
|
||||
})
|
||||
|
||||
test("stores optional description field", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Test task",
|
||||
description: "Detailed description of the task",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.description).toBe("Detailed description of the task")
|
||||
})
|
||||
|
||||
test("stores dependsOn array", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Test task",
|
||||
dependsOn: ["T-dep1", "T-dep2"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.dependsOn).toEqual(["T-dep1", "T-dep2"])
|
||||
})
|
||||
|
||||
test("stores parentID when provided", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Subtask",
|
||||
parentID: "T-parent123",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.parentID).toBe("T-parent123")
|
||||
})
|
||||
|
||||
test("stores repoURL when provided", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Test task",
|
||||
repoURL: "https://github.com/code-yeongyu/oh-my-opencode",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.repoURL).toBe("https://github.com/code-yeongyu/oh-my-opencode")
|
||||
})
|
||||
|
||||
test("returns result as JSON string with task property", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Test task",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
|
||||
//#then
|
||||
expect(typeof resultStr).toBe("string")
|
||||
const result = JSON.parse(resultStr)
|
||||
expect(result).toHaveProperty("task")
|
||||
})
|
||||
|
||||
test("initializes dependsOn as empty array when not provided", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Test task",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.dependsOn).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// LIST ACTION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe("list action", () => {
|
||||
test("returns all non-completed tasks by default", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "list" as const,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("tasks")
|
||||
expect(Array.isArray(result.tasks)).toBe(true)
|
||||
})
|
||||
|
||||
test("excludes completed tasks from list", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "list" as const,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
const completedTasks = result.tasks.filter((t: TaskObject) => t.status === "completed")
|
||||
expect(completedTasks.length).toBe(0)
|
||||
})
|
||||
|
||||
test("applies ready filter when requested", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "list" as const,
|
||||
ready: true,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("tasks")
|
||||
expect(Array.isArray(result.tasks)).toBe(true)
|
||||
})
|
||||
|
||||
test("respects limit parameter", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "list" as const,
|
||||
limit: 5,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.tasks.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
test("returns result as JSON string with tasks array", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "list" as const,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
|
||||
//#then
|
||||
expect(typeof resultStr).toBe("string")
|
||||
const result = JSON.parse(resultStr)
|
||||
expect(Array.isArray(result.tasks)).toBe(true)
|
||||
})
|
||||
|
||||
test("filters by status when provided", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "list" as const,
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
const allInProgress = result.tasks.every((t: TaskObject) => t.status === "in_progress")
|
||||
expect(allInProgress).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// GET ACTION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe("get action", () => {
|
||||
test("returns task by ID", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "get" as const,
|
||||
id: testId,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("task")
|
||||
})
|
||||
|
||||
test("returns null for non-existent task", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "get" as const,
|
||||
id: "T-nonexistent",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task).toBeNull()
|
||||
})
|
||||
|
||||
test("returns result as JSON string with task property", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "get" as const,
|
||||
id: testId,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
|
||||
//#then
|
||||
expect(typeof resultStr).toBe("string")
|
||||
const result = JSON.parse(resultStr)
|
||||
expect(result).toHaveProperty("task")
|
||||
})
|
||||
|
||||
test("returns complete task object with all fields", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "get" as const,
|
||||
id: "T-test123",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
if (result.task !== null) {
|
||||
expect(result.task).toHaveProperty("id")
|
||||
expect(result.task).toHaveProperty("title")
|
||||
expect(result.task).toHaveProperty("status")
|
||||
expect(result.task).toHaveProperty("threadID")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// UPDATE ACTION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe("update action", () => {
|
||||
test("updates task title", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "update" as const,
|
||||
id: testId,
|
||||
title: "Updated title",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("task")
|
||||
expect(result.task.title).toBe("Updated title")
|
||||
})
|
||||
|
||||
test("updates task description", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task", { description: "Initial description" })
|
||||
const args = {
|
||||
action: "update" as const,
|
||||
id: testId,
|
||||
description: "Updated description",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.description).toBe("Updated description")
|
||||
})
|
||||
|
||||
test("updates task status", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "update" as const,
|
||||
id: testId,
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.status).toBe("in_progress")
|
||||
})
|
||||
|
||||
test("updates dependsOn array", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "update" as const,
|
||||
id: testId,
|
||||
dependsOn: ["T-dep1", "T-dep2", "T-dep3"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.dependsOn).toEqual(["T-dep1", "T-dep2", "T-dep3"])
|
||||
})
|
||||
|
||||
test("returns error for non-existent task", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "update" as const,
|
||||
id: "T-nonexistent",
|
||||
title: "New title",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
expect(result.error).toBe("task_not_found")
|
||||
})
|
||||
|
||||
test("returns result as JSON string with task property", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "update" as const,
|
||||
id: testId,
|
||||
title: "Updated",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
|
||||
//#then
|
||||
expect(typeof resultStr).toBe("string")
|
||||
const result = JSON.parse(resultStr)
|
||||
expect(result).toHaveProperty("task")
|
||||
})
|
||||
|
||||
test("updates multiple fields at once", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "update" as const,
|
||||
id: testId,
|
||||
title: "New title",
|
||||
description: "New description",
|
||||
status: "completed" as const,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.title).toBe("New title")
|
||||
expect(result.task.description).toBe("New description")
|
||||
expect(result.task.status).toBe("completed")
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// DELETE ACTION TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe("delete action", () => {
|
||||
test("removes task file physically", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "delete" as const,
|
||||
id: testId,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("success")
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("returns success true on successful deletion", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "delete" as const,
|
||||
id: testId,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
test("returns error for non-existent task", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "delete" as const,
|
||||
id: "T-nonexistent",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
expect(result.error).toBe("task_not_found")
|
||||
})
|
||||
|
||||
test("returns result as JSON string", async () => {
|
||||
//#given
|
||||
const testId = await createTestTask("Test task")
|
||||
const args = {
|
||||
action: "delete" as const,
|
||||
id: testId,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
|
||||
//#then
|
||||
expect(typeof resultStr).toBe("string")
|
||||
const result = JSON.parse(resultStr)
|
||||
expect(result).toHaveProperty("success")
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// EDGE CASE TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("detects circular dependency (A depends on B, B depends on A)", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Task A",
|
||||
dependsOn: ["T-taskB"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
// Should either prevent creation or mark as circular
|
||||
expect(result).toHaveProperty("task")
|
||||
})
|
||||
|
||||
test("handles task depending on non-existent ID", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Task with missing dependency",
|
||||
dependsOn: ["T-nonexistent"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
// Should either allow or return error
|
||||
expect(result).toHaveProperty("task")
|
||||
})
|
||||
|
||||
test("ready filter returns true for empty dependsOn", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "list" as const,
|
||||
ready: true,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
const tasksWithNoDeps = result.tasks.filter((t: TaskObject) => t.dependsOn.length === 0)
|
||||
expect(tasksWithNoDeps.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test("ready filter includes tasks with all completed dependencies", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "list" as const,
|
||||
ready: true,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(Array.isArray(result.tasks)).toBe(true)
|
||||
})
|
||||
|
||||
test("ready filter excludes tasks with incomplete dependencies", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "list" as const,
|
||||
ready: true,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(Array.isArray(result.tasks)).toBe(true)
|
||||
})
|
||||
|
||||
test("handles empty title gracefully", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
// Should either reject or handle empty title
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
test("handles very long title", async () => {
|
||||
//#given
|
||||
const longTitle = "A".repeat(1000)
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: longTitle,
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
test("handles special characters in title", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Task with special chars: !@#$%^&*()",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
test("handles unicode characters in title", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "任務 🚀 Tâche",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
test("preserves all TaskObject fields in round-trip", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
action: "create" as const,
|
||||
title: "Test task",
|
||||
description: "Test description",
|
||||
dependsOn: ["T-dep1"],
|
||||
parentID: "T-parent",
|
||||
repoURL: "https://example.com",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task).toHaveProperty("id")
|
||||
expect(result.task).toHaveProperty("title")
|
||||
expect(result.task).toHaveProperty("description")
|
||||
expect(result.task).toHaveProperty("status")
|
||||
expect(result.task).toHaveProperty("dependsOn")
|
||||
expect(result.task).toHaveProperty("parentID")
|
||||
expect(result.task).toHaveProperty("repoURL")
|
||||
expect(result.task).toHaveProperty("threadID")
|
||||
})
|
||||
})
|
||||
})
|
||||
253
src/tools/task/task.ts
Normal file
253
src/tools/task/task.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { existsSync, readdirSync, unlinkSync } from "fs"
|
||||
import { join } from "path"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
import type {
|
||||
TaskObject,
|
||||
TaskCreateInput,
|
||||
TaskListInput,
|
||||
TaskGetInput,
|
||||
TaskUpdateInput,
|
||||
TaskDeleteInput,
|
||||
} from "./types"
|
||||
import {
|
||||
TaskObjectSchema,
|
||||
TaskCreateInputSchema,
|
||||
TaskListInputSchema,
|
||||
TaskGetInputSchema,
|
||||
TaskUpdateInputSchema,
|
||||
TaskDeleteInputSchema,
|
||||
} from "./types"
|
||||
import {
|
||||
getTaskDir,
|
||||
readJsonSafe,
|
||||
writeJsonAtomic,
|
||||
acquireLock,
|
||||
generateTaskId,
|
||||
listTaskFiles,
|
||||
} from "../../features/claude-tasks/storage"
|
||||
|
||||
export function createTask(config: Partial<OhMyOpenCodeConfig>): ToolDefinition {
|
||||
return tool({
|
||||
description: `Unified task management tool with create, list, get, update, delete actions.
|
||||
|
||||
**CREATE**: Create a new task. Auto-generates T-{uuid} ID, records threadID, sets status to "open".
|
||||
**LIST**: List tasks. Excludes completed by default. Supports ready filter (all dependencies completed) and limit.
|
||||
**GET**: Retrieve a task by ID.
|
||||
**UPDATE**: Update task fields. Requires task ID.
|
||||
**DELETE**: Physically remove task file.
|
||||
|
||||
All actions return JSON strings.`,
|
||||
args: {
|
||||
action: tool.schema
|
||||
.enum(["create", "list", "get", "update", "delete"])
|
||||
.describe("Action to perform: create, list, get, update, delete"),
|
||||
title: tool.schema.string().optional().describe("Task title (required for create)"),
|
||||
description: tool.schema.string().optional().describe("Task description"),
|
||||
status: tool.schema
|
||||
.enum(["open", "in_progress", "completed"])
|
||||
.optional()
|
||||
.describe("Task status"),
|
||||
dependsOn: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Task IDs this task depends on"),
|
||||
repoURL: tool.schema.string().optional().describe("Repository URL"),
|
||||
parentID: tool.schema.string().optional().describe("Parent task ID"),
|
||||
id: tool.schema.string().optional().describe("Task ID (required for get, update, delete)"),
|
||||
ready: tool.schema.boolean().optional().describe("Filter to tasks with all dependencies completed"),
|
||||
limit: tool.schema.number().optional().describe("Maximum number of tasks to return"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
const action = args.action as "create" | "list" | "get" | "update" | "delete"
|
||||
|
||||
switch (action) {
|
||||
case "create":
|
||||
return handleCreate(args, config, context)
|
||||
case "list":
|
||||
return handleList(args, config)
|
||||
case "get":
|
||||
return handleGet(args, config)
|
||||
case "update":
|
||||
return handleUpdate(args, config)
|
||||
case "delete":
|
||||
return handleDelete(args, config)
|
||||
default:
|
||||
return JSON.stringify({ error: "invalid_action" })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCreate(
|
||||
args: Record<string, unknown>,
|
||||
config: Partial<OhMyOpenCodeConfig>,
|
||||
context: { sessionID: string }
|
||||
): Promise<string> {
|
||||
const validatedArgs = TaskCreateInputSchema.parse(args)
|
||||
const taskDir = getTaskDir(config)
|
||||
const lock = acquireLock(taskDir)
|
||||
|
||||
try {
|
||||
const taskId = generateTaskId()
|
||||
const task: TaskObject = {
|
||||
id: taskId,
|
||||
title: validatedArgs.title,
|
||||
description: validatedArgs.description,
|
||||
status: "open",
|
||||
dependsOn: validatedArgs.dependsOn ?? [],
|
||||
repoURL: validatedArgs.repoURL,
|
||||
parentID: validatedArgs.parentID,
|
||||
threadID: context.sessionID,
|
||||
}
|
||||
|
||||
const validatedTask = TaskObjectSchema.parse(task)
|
||||
writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask)
|
||||
|
||||
return JSON.stringify({ task: validatedTask })
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(
|
||||
args: Record<string, unknown>,
|
||||
config: Partial<OhMyOpenCodeConfig>
|
||||
): Promise<string> {
|
||||
const validatedArgs = TaskListInputSchema.parse(args)
|
||||
const taskDir = getTaskDir(config)
|
||||
|
||||
if (!existsSync(taskDir)) {
|
||||
return JSON.stringify({ tasks: [] })
|
||||
}
|
||||
|
||||
const files = listTaskFiles(config)
|
||||
if (files.length === 0) {
|
||||
return JSON.stringify({ tasks: [] })
|
||||
}
|
||||
|
||||
const allTasks: TaskObject[] = []
|
||||
for (const fileId of files) {
|
||||
const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema)
|
||||
if (task) {
|
||||
allTasks.push(task)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out completed tasks by default
|
||||
let tasks = allTasks.filter((task) => task.status !== "completed")
|
||||
|
||||
// Apply status filter if provided
|
||||
if (validatedArgs.status) {
|
||||
tasks = tasks.filter((task) => task.status === validatedArgs.status)
|
||||
}
|
||||
|
||||
// Apply parentID filter if provided
|
||||
if (validatedArgs.parentID) {
|
||||
tasks = tasks.filter((task) => task.parentID === validatedArgs.parentID)
|
||||
}
|
||||
|
||||
// Apply ready filter if requested
|
||||
if (args.ready) {
|
||||
tasks = tasks.filter((task) => {
|
||||
if (task.dependsOn.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// All dependencies must be completed
|
||||
return task.dependsOn.every((depId) => {
|
||||
const depTask = allTasks.find((t) => t.id === depId)
|
||||
return depTask?.status === "completed"
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Apply limit if provided
|
||||
const limit = args.limit as number | undefined
|
||||
if (limit !== undefined && limit > 0) {
|
||||
tasks = tasks.slice(0, limit)
|
||||
}
|
||||
|
||||
return JSON.stringify({ tasks })
|
||||
}
|
||||
|
||||
async function handleGet(
|
||||
args: Record<string, unknown>,
|
||||
config: Partial<OhMyOpenCodeConfig>
|
||||
): Promise<string> {
|
||||
const validatedArgs = TaskGetInputSchema.parse(args)
|
||||
const taskDir = getTaskDir(config)
|
||||
const taskPath = join(taskDir, `${validatedArgs.id}.json`)
|
||||
|
||||
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
||||
|
||||
return JSON.stringify({ task: task ?? null })
|
||||
}
|
||||
|
||||
async function handleUpdate(
|
||||
args: Record<string, unknown>,
|
||||
config: Partial<OhMyOpenCodeConfig>
|
||||
): Promise<string> {
|
||||
const validatedArgs = TaskUpdateInputSchema.parse(args)
|
||||
const taskDir = getTaskDir(config)
|
||||
const lock = acquireLock(taskDir)
|
||||
|
||||
try {
|
||||
const taskPath = join(taskDir, `${validatedArgs.id}.json`)
|
||||
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
||||
|
||||
if (!task) {
|
||||
return JSON.stringify({ error: "task_not_found" })
|
||||
}
|
||||
|
||||
// Update fields if provided
|
||||
if (validatedArgs.title !== undefined) {
|
||||
task.title = validatedArgs.title
|
||||
}
|
||||
if (validatedArgs.description !== undefined) {
|
||||
task.description = validatedArgs.description
|
||||
}
|
||||
if (validatedArgs.status !== undefined) {
|
||||
task.status = validatedArgs.status
|
||||
}
|
||||
if (validatedArgs.dependsOn !== undefined) {
|
||||
task.dependsOn = validatedArgs.dependsOn
|
||||
}
|
||||
if (validatedArgs.repoURL !== undefined) {
|
||||
task.repoURL = validatedArgs.repoURL
|
||||
}
|
||||
if (validatedArgs.parentID !== undefined) {
|
||||
task.parentID = validatedArgs.parentID
|
||||
}
|
||||
|
||||
const validatedTask = TaskObjectSchema.parse(task)
|
||||
writeJsonAtomic(taskPath, validatedTask)
|
||||
|
||||
return JSON.stringify({ task: validatedTask })
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(
|
||||
args: Record<string, unknown>,
|
||||
config: Partial<OhMyOpenCodeConfig>
|
||||
): Promise<string> {
|
||||
const validatedArgs = TaskDeleteInputSchema.parse(args)
|
||||
const taskDir = getTaskDir(config)
|
||||
const lock = acquireLock(taskDir)
|
||||
|
||||
try {
|
||||
const taskPath = join(taskDir, `${validatedArgs.id}.json`)
|
||||
|
||||
if (!existsSync(taskPath)) {
|
||||
return JSON.stringify({ error: "task_not_found" })
|
||||
}
|
||||
|
||||
unlinkSync(taskPath)
|
||||
|
||||
return JSON.stringify({ success: true })
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
61
src/tools/task/types.ts
Normal file
61
src/tools/task/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const TaskStatusSchema = z.enum(["open", "in_progress", "completed"])
|
||||
export type TaskStatus = z.infer<typeof TaskStatusSchema>
|
||||
|
||||
export const TaskObjectSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
status: TaskStatusSchema,
|
||||
dependsOn: z.array(z.string()).default([]),
|
||||
repoURL: z.string().optional(),
|
||||
parentID: z.string().optional(),
|
||||
threadID: z.string(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type TaskObject = z.infer<typeof TaskObjectSchema>
|
||||
|
||||
// Action input schemas
|
||||
export const TaskCreateInputSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
dependsOn: z.array(z.string()).optional(),
|
||||
repoURL: z.string().optional(),
|
||||
parentID: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TaskCreateInput = z.infer<typeof TaskCreateInputSchema>
|
||||
|
||||
export const TaskListInputSchema = z.object({
|
||||
status: TaskStatusSchema.optional(),
|
||||
parentID: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TaskListInput = z.infer<typeof TaskListInputSchema>
|
||||
|
||||
export const TaskGetInputSchema = z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
export type TaskGetInput = z.infer<typeof TaskGetInputSchema>
|
||||
|
||||
export const TaskUpdateInputSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: TaskStatusSchema.optional(),
|
||||
dependsOn: z.array(z.string()).optional(),
|
||||
repoURL: z.string().optional(),
|
||||
parentID: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TaskUpdateInput = z.infer<typeof TaskUpdateInputSchema>
|
||||
|
||||
export const TaskDeleteInputSchema = z.object({
|
||||
id: z.string(),
|
||||
})
|
||||
|
||||
export type TaskDeleteInput = z.infer<typeof TaskDeleteInputSchema>
|
||||
@@ -16,5 +16,5 @@
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "script"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user