refactor: migrate delegate_task to task tool with metadata fixes
- Rename delegate_task tool to task across codebase (100 files) - Update model references: claude-opus-4-6 → 4-5, gpt-5.3-codex → 5.2-codex - Add tool-metadata-store to restore metadata overwritten by fromPlugin() - Add session ID polling for BackgroundManager task sessions - Await async ctx.metadata() calls in tool executors - Add ses_ prefix guard to getMessageDir for performance - Harden BackgroundManager with idle deferral and error handling - Fix duplicate task key in sisyphus-junior test object literals - Fix unawaited showOutputToUser in ast_grep_replace - Fix background=true → run_in_background=true in ultrawork prompt - Fix duplicate task/task references in docs and comments
This commit is contained in:
@@ -56,7 +56,7 @@ features/
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Sequential delegation**: Use `delegate_task` parallel
|
||||
- **Sequential delegation**: Use `task` parallel
|
||||
- **Trust self-reports**: ALWAYS verify
|
||||
- **Main thread blocks**: No heavy I/O in loader init
|
||||
- **Direct state mutation**: Use managers for boulder/session state
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { afterEach } from "bun:test"
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, beforeEach, afterEach } = require("bun:test")
|
||||
import { tmpdir } from "node:os"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTask, ResumeInput } from "./types"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
@@ -1088,6 +1089,34 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
// #then
|
||||
expect(abortedSessionIDs).toEqual(["session-1"])
|
||||
})
|
||||
|
||||
test("should clean pendingByParent even when notifyParentSession throws", async () => {
|
||||
// given
|
||||
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {
|
||||
throw new Error("notify failed")
|
||||
}
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-pending-cleanup",
|
||||
sessionID: "session-pending-cleanup",
|
||||
parentSessionID: "parent-pending-cleanup",
|
||||
parentMessageID: "msg-1",
|
||||
description: "pending cleanup task",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
|
||||
|
||||
// when
|
||||
await tryCompleteTaskForTest(manager, task)
|
||||
|
||||
// then
|
||||
expect(task.status).toBe("completed")
|
||||
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.trackTask", () => {
|
||||
@@ -1110,7 +1139,7 @@ describe("BackgroundManager.trackTask", () => {
|
||||
sessionID: "session-1",
|
||||
parentSessionID: "parent-session",
|
||||
description: "external task",
|
||||
agent: "delegate_task",
|
||||
agent: "task",
|
||||
concurrencyKey: "external-key",
|
||||
}
|
||||
|
||||
@@ -1145,7 +1174,7 @@ describe("BackgroundManager.resume concurrency key", () => {
|
||||
sessionID: "session-1",
|
||||
parentSessionID: "parent-session",
|
||||
description: "external task",
|
||||
agent: "delegate_task",
|
||||
agent: "task",
|
||||
concurrencyKey: "external-key",
|
||||
})
|
||||
|
||||
@@ -2408,3 +2437,179 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
|
||||
expect(completionTimers.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
|
||||
test("should defer and retry when session.idle fires before MIN_IDLE_TIME_MS", async () => {
|
||||
//#given - a running task started less than MIN_IDLE_TIME_MS ago
|
||||
const sessionID = "session-early-idle"
|
||||
const messagesCalls: string[] = []
|
||||
const realDateNow = Date.now
|
||||
const baseNow = realDateNow()
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
messages: async (args: { path: { id: string } }) => {
|
||||
messagesCalls.push(args.path.id)
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const remainingMs = 1200
|
||||
const task: BackgroundTask = {
|
||||
id: "task-early-idle",
|
||||
sessionID,
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "early idle task",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(baseNow),
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when - session.idle fires
|
||||
try {
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
|
||||
manager.handleEvent({ type: "session.idle", properties: { sessionID } })
|
||||
|
||||
// Advance time so deferred callback (if any) sees elapsed >= MIN_IDLE_TIME_MS
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS + 10)
|
||||
|
||||
//#then - idle should be deferred (not dropped), and task should eventually complete
|
||||
expect(task.status).toBe("running")
|
||||
await new Promise((resolve) => setTimeout(resolve, 220))
|
||||
expect(task.status).toBe("completed")
|
||||
expect(messagesCalls).toEqual([sessionID])
|
||||
} finally {
|
||||
Date.now = realDateNow
|
||||
manager.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
test("should not defer when session.idle fires after MIN_IDLE_TIME_MS", async () => {
|
||||
//#given - a running task started more than MIN_IDLE_TIME_MS ago
|
||||
const sessionID = "session-late-idle"
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-late-idle",
|
||||
sessionID,
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "late idle task",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)),
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
manager.handleEvent({ type: "session.idle", properties: { sessionID } })
|
||||
|
||||
//#then - should be processed immediately
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(task.status).toBe("completed")
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("should not process deferred idle if task already completed by other means", async () => {
|
||||
//#given - a running task
|
||||
const sessionID = "session-deferred-noop"
|
||||
let messagesCallCount = 0
|
||||
const realDateNow = Date.now
|
||||
const baseNow = realDateNow()
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
messages: async () => {
|
||||
messagesCallCount += 1
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const remainingMs = 120
|
||||
const task: BackgroundTask = {
|
||||
id: "task-deferred-noop",
|
||||
sessionID,
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "deferred noop task",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(baseNow),
|
||||
}
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when - session.idle fires early, then task completes via another path before defer timer
|
||||
try {
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - remainingMs)
|
||||
manager.handleEvent({ type: "session.idle", properties: { sessionID } })
|
||||
expect(messagesCallCount).toBe(0)
|
||||
|
||||
await tryCompleteTaskForTest(manager, task)
|
||||
expect(task.status).toBe("completed")
|
||||
|
||||
// Advance time so deferred callback (if any) sees elapsed >= MIN_IDLE_TIME_MS
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS + 10)
|
||||
|
||||
//#then - deferred callback should be a no-op
|
||||
await new Promise((resolve) => setTimeout(resolve, remainingMs + 80))
|
||||
expect(task.status).toBe("completed")
|
||||
expect(messagesCallCount).toBe(0)
|
||||
} finally {
|
||||
Date.now = realDateNow
|
||||
manager.shutdown()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,6 +88,7 @@ export class BackgroundManager {
|
||||
private queuesByKey: Map<string, QueueItem[]> = new Map()
|
||||
private processingKeys: Set<string> = new Set()
|
||||
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
|
||||
constructor(
|
||||
ctx: PluginInput,
|
||||
@@ -328,7 +329,6 @@ export class BackgroundManager {
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
@@ -357,6 +357,7 @@ export class BackgroundManager {
|
||||
}).catch(() => {})
|
||||
|
||||
this.markForNotification(existingTask)
|
||||
this.cleanupPendingByParent(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
log("[background-agent] Failed to notify on error:", err)
|
||||
})
|
||||
@@ -410,7 +411,7 @@ export class BackgroundManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a task created elsewhere (e.g., from delegate_task) for notification tracking.
|
||||
* Track a task created elsewhere (e.g., from task) for notification tracking.
|
||||
* This allows tasks created by other tools to receive the same toast/prompt notifications.
|
||||
*/
|
||||
async trackTask(input: {
|
||||
@@ -458,7 +459,7 @@ export class BackgroundManager {
|
||||
return existingTask
|
||||
}
|
||||
|
||||
const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "delegate_task"
|
||||
const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "task"
|
||||
|
||||
// Acquire concurrency slot if a key is provided
|
||||
if (input.concurrencyKey) {
|
||||
@@ -472,7 +473,7 @@ export class BackgroundManager {
|
||||
parentMessageID: "",
|
||||
description: input.description,
|
||||
prompt: "",
|
||||
agent: input.agent || "delegate_task",
|
||||
agent: input.agent || "task",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
progress: {
|
||||
@@ -587,7 +588,6 @@ export class BackgroundManager {
|
||||
tools: {
|
||||
...getAgentToolRestrictions(existingTask.agent),
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
@@ -614,6 +614,7 @@ export class BackgroundManager {
|
||||
}
|
||||
|
||||
this.markForNotification(existingTask)
|
||||
this.cleanupPendingByParent(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
log("[background-agent] Failed to notify on resume error:", err)
|
||||
})
|
||||
@@ -651,6 +652,13 @@ export class BackgroundManager {
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task) return
|
||||
|
||||
// Clear any pending idle deferral timer since the task is still active
|
||||
const existingTimer = this.idleDeferralTimers.get(task.id)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
if (partInfo?.type === "tool" || partInfo?.tool) {
|
||||
if (!task.progress) {
|
||||
task.progress = {
|
||||
@@ -677,7 +685,17 @@ export class BackgroundManager {
|
||||
// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
|
||||
const elapsedMs = Date.now() - startedAt.getTime()
|
||||
if (elapsedMs < MIN_IDLE_TIME_MS) {
|
||||
log("[background-agent] Ignoring early session.idle, elapsed:", { elapsedMs, taskId: task.id })
|
||||
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
|
||||
if (!this.idleDeferralTimers.has(task.id)) {
|
||||
log("[background-agent] Deferring early session.idle:", { elapsedMs, remainingMs, taskId: task.id })
|
||||
const timer = setTimeout(() => {
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
this.handleEvent({ type: "session.idle", properties: { sessionID } })
|
||||
}, remainingMs)
|
||||
this.idleDeferralTimers.set(task.id, timer)
|
||||
} else {
|
||||
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -736,6 +754,12 @@ export class BackgroundManager {
|
||||
clearTimeout(existingTimer)
|
||||
this.completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = this.idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
this.cleanupPendingByParent(task)
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
@@ -890,6 +914,12 @@ export class BackgroundManager {
|
||||
this.completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = this.idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
this.cleanupPendingByParent(task)
|
||||
|
||||
if (abortSession && task.sessionID) {
|
||||
@@ -1025,6 +1055,15 @@ export class BackgroundManager {
|
||||
|
||||
this.markForNotification(task)
|
||||
|
||||
// Ensure pending tracking is cleaned up even if notification fails
|
||||
this.cleanupPendingByParent(task)
|
||||
|
||||
const idleTimer = this.idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
if (task.sessionID) {
|
||||
this.client.session.abort({
|
||||
path: { id: task.sessionID },
|
||||
@@ -1511,6 +1550,11 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
this.completionTimers.clear()
|
||||
|
||||
for (const timer of this.idleDeferralTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.idleDeferralTimers.clear()
|
||||
|
||||
this.concurrencyManager.clear()
|
||||
this.tasks.clear()
|
||||
this.notifications.clear()
|
||||
|
||||
@@ -146,7 +146,6 @@ export async function startTask(
|
||||
tools: {
|
||||
...getAgentToolRestrictions(input.agent),
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
@@ -231,7 +230,6 @@ export async function resumeTask(
|
||||
tools: {
|
||||
...getAgentToolRestrictions(task.agent),
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
|
||||
@@ -45,12 +45,12 @@ Don't wait—these run async while main session works.
|
||||
|
||||
\`\`\`
|
||||
// Fire all at once, collect results later
|
||||
delegate_task(agent="explore", prompt="Project structure: PREDICT standard patterns for detected language → REPORT deviations only")
|
||||
delegate_task(agent="explore", prompt="Entry points: FIND main files → REPORT non-standard organization")
|
||||
delegate_task(agent="explore", prompt="Conventions: FIND config files (.eslintrc, pyproject.toml, .editorconfig) → REPORT project-specific rules")
|
||||
delegate_task(agent="explore", prompt="Anti-patterns: FIND 'DO NOT', 'NEVER', 'ALWAYS', 'DEPRECATED' comments → LIST forbidden patterns")
|
||||
delegate_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makefile → REPORT non-standard patterns")
|
||||
delegate_task(agent="explore", prompt="Test patterns: FIND test configs, test structure → REPORT unique conventions")
|
||||
task(subagent_type="explore", load_skills=[], description="Explore project structure", run_in_background=true, prompt="Project structure: PREDICT standard patterns for detected language → REPORT deviations only")
|
||||
task(subagent_type="explore", load_skills=[], description="Find entry points", run_in_background=true, prompt="Entry points: FIND main files → REPORT non-standard organization")
|
||||
task(subagent_type="explore", load_skills=[], description="Find conventions", run_in_background=true, prompt="Conventions: FIND config files (.eslintrc, pyproject.toml, .editorconfig) → REPORT project-specific rules")
|
||||
task(subagent_type="explore", load_skills=[], description="Find anti-patterns", run_in_background=true, prompt="Anti-patterns: FIND 'DO NOT', 'NEVER', 'ALWAYS', 'DEPRECATED' comments → LIST forbidden patterns")
|
||||
task(subagent_type="explore", load_skills=[], description="Explore build/CI", run_in_background=true, prompt="Build/CI: FIND .github/workflows, Makefile → REPORT non-standard patterns")
|
||||
task(subagent_type="explore", load_skills=[], description="Find test patterns", run_in_background=true, prompt="Test patterns: FIND test configs, test structure → REPORT unique conventions")
|
||||
\`\`\`
|
||||
|
||||
<dynamic-agents>
|
||||
@@ -76,9 +76,9 @@ max_depth=$(find . -type d -not -path '*/node_modules/*' -not -path '*/.git/*' |
|
||||
Example spawning:
|
||||
\`\`\`
|
||||
// 500 files, 50k lines, depth 6, 15 large files → spawn 5+5+2+1 = 13 additional agents
|
||||
delegate_task(agent="explore", prompt="Large file analysis: FIND files >500 lines, REPORT complexity hotspots")
|
||||
delegate_task(agent="explore", prompt="Deep modules at depth 4+: FIND hidden patterns, internal conventions")
|
||||
delegate_task(agent="explore", prompt="Cross-cutting concerns: FIND shared utilities across directories")
|
||||
task(subagent_type="explore", load_skills=[], description="Analyze large files", run_in_background=true, prompt="Large file analysis: FIND files >500 lines, REPORT complexity hotspots")
|
||||
task(subagent_type="explore", load_skills=[], description="Explore deep modules", run_in_background=true, prompt="Deep modules at depth 4+: FIND hidden patterns, internal conventions")
|
||||
task(subagent_type="explore", load_skills=[], description="Find shared utilities", run_in_background=true, prompt="Cross-cutting concerns: FIND shared utilities across directories")
|
||||
// ... more based on calculation
|
||||
\`\`\`
|
||||
</dynamic-agents>
|
||||
@@ -185,6 +185,11 @@ AGENTS_LOCATIONS = [
|
||||
|
||||
**Mark "generate" as in_progress.**
|
||||
|
||||
<critical>
|
||||
**File Writing Rule**: If AGENTS.md already exists at the target path → use \`Edit\` tool. If it does NOT exist → use \`Write\` tool.
|
||||
NEVER use Write to overwrite an existing file. ALWAYS check existence first via \`Read\` or discovery results.
|
||||
</critical>
|
||||
|
||||
### Root AGENTS.md (Full Treatment)
|
||||
|
||||
\`\`\`markdown
|
||||
@@ -240,7 +245,7 @@ Launch writing tasks for each location:
|
||||
|
||||
\`\`\`
|
||||
for loc in AGENTS_LOCATIONS (except root):
|
||||
delegate_task(category="writing", load_skills=[], run_in_background=false, prompt=\\\`
|
||||
task(category="writing", load_skills=[], run_in_background=false, description="Generate AGENTS.md", prompt=\\\`
|
||||
Generate AGENTS.md for: \${loc.path}
|
||||
- Reason: \${loc.reason}
|
||||
- 30-80 lines max
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: git-master
|
||||
description: "MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with delegate_task(category='quick', load_skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'."
|
||||
description: "MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with task(category='quick', load_skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'."
|
||||
---
|
||||
|
||||
# Git Master Agent
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BuiltinSkill } from "../types"
|
||||
export const gitMasterSkill: BuiltinSkill = {
|
||||
name: "git-master",
|
||||
description:
|
||||
"MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with delegate_task(category='quick', load_skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'.",
|
||||
"MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with task(category='quick', load_skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'.",
|
||||
template: `# Git Master Agent
|
||||
|
||||
You are a Git expert combining three specializations:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import {
|
||||
storeToolMetadata,
|
||||
consumeToolMetadata,
|
||||
getPendingStoreSize,
|
||||
clearPendingStore,
|
||||
} from "./index"
|
||||
|
||||
describe("tool-metadata-store", () => {
|
||||
beforeEach(() => {
|
||||
clearPendingStore()
|
||||
})
|
||||
|
||||
describe("storeToolMetadata", () => {
|
||||
test("#given metadata with title and metadata, #when stored, #then store size increases", () => {
|
||||
//#given
|
||||
const sessionID = "ses_abc123"
|
||||
const callID = "call_001"
|
||||
const data = {
|
||||
title: "Test Task",
|
||||
metadata: { sessionId: "ses_child", agent: "oracle" },
|
||||
}
|
||||
|
||||
//#when
|
||||
storeToolMetadata(sessionID, callID, data)
|
||||
|
||||
//#then
|
||||
expect(getPendingStoreSize()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("consumeToolMetadata", () => {
|
||||
test("#given stored metadata, #when consumed, #then returns the stored data", () => {
|
||||
//#given
|
||||
const sessionID = "ses_abc123"
|
||||
const callID = "call_001"
|
||||
const data = {
|
||||
title: "My Task",
|
||||
metadata: { sessionId: "ses_sub", run_in_background: true },
|
||||
}
|
||||
storeToolMetadata(sessionID, callID, data)
|
||||
|
||||
//#when
|
||||
const result = consumeToolMetadata(sessionID, callID)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
|
||||
test("#given stored metadata, #when consumed twice, #then second call returns undefined", () => {
|
||||
//#given
|
||||
const sessionID = "ses_abc123"
|
||||
const callID = "call_001"
|
||||
storeToolMetadata(sessionID, callID, { title: "Task" })
|
||||
|
||||
//#when
|
||||
consumeToolMetadata(sessionID, callID)
|
||||
const second = consumeToolMetadata(sessionID, callID)
|
||||
|
||||
//#then
|
||||
expect(second).toBeUndefined()
|
||||
expect(getPendingStoreSize()).toBe(0)
|
||||
})
|
||||
|
||||
test("#given no stored metadata, #when consumed, #then returns undefined", () => {
|
||||
//#given
|
||||
const sessionID = "ses_nonexistent"
|
||||
const callID = "call_999"
|
||||
|
||||
//#when
|
||||
const result = consumeToolMetadata(sessionID, callID)
|
||||
|
||||
//#then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isolation", () => {
|
||||
test("#given multiple entries, #when consuming one, #then others remain", () => {
|
||||
//#given
|
||||
storeToolMetadata("ses_1", "call_a", { title: "Task A" })
|
||||
storeToolMetadata("ses_1", "call_b", { title: "Task B" })
|
||||
storeToolMetadata("ses_2", "call_a", { title: "Task C" })
|
||||
|
||||
//#when
|
||||
const resultA = consumeToolMetadata("ses_1", "call_a")
|
||||
|
||||
//#then
|
||||
expect(resultA?.title).toBe("Task A")
|
||||
expect(getPendingStoreSize()).toBe(2)
|
||||
expect(consumeToolMetadata("ses_1", "call_b")?.title).toBe("Task B")
|
||||
expect(consumeToolMetadata("ses_2", "call_a")?.title).toBe("Task C")
|
||||
expect(getPendingStoreSize()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("overwrite", () => {
|
||||
test("#given existing entry, #when stored again with same key, #then overwrites", () => {
|
||||
//#given
|
||||
storeToolMetadata("ses_1", "call_a", { title: "Old" })
|
||||
|
||||
//#when
|
||||
storeToolMetadata("ses_1", "call_a", { title: "New", metadata: { updated: true } })
|
||||
|
||||
//#then
|
||||
const result = consumeToolMetadata("ses_1", "call_a")
|
||||
expect(result?.title).toBe("New")
|
||||
expect(result?.metadata).toEqual({ updated: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Pending tool metadata store.
|
||||
*
|
||||
* OpenCode's `fromPlugin()` wrapper always replaces the metadata returned by
|
||||
* plugin tools with `{ truncated, outputPath }`, discarding any sessionId,
|
||||
* title, or custom metadata set during `execute()`.
|
||||
*
|
||||
* This store captures metadata written via `ctx.metadata()` inside execute(),
|
||||
* then the `tool.execute.after` hook consumes it and merges it back into the
|
||||
* result *before* the processor writes the final part to the session store.
|
||||
*
|
||||
* Flow:
|
||||
* execute() → storeToolMetadata(sessionID, callID, data)
|
||||
* fromPlugin() → overwrites metadata with { truncated }
|
||||
* tool.execute.after → consumeToolMetadata(sessionID, callID) → merges back
|
||||
* processor → Session.updatePart(status:"completed", metadata: result.metadata)
|
||||
*/
|
||||
|
||||
export interface PendingToolMetadata {
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const pendingStore = new Map<string, PendingToolMetadata & { storedAt: number }>()
|
||||
|
||||
const STALE_TIMEOUT_MS = 15 * 60 * 1000
|
||||
|
||||
function makeKey(sessionID: string, callID: string): string {
|
||||
return `${sessionID}:${callID}`
|
||||
}
|
||||
|
||||
function cleanupStaleEntries(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of pendingStore) {
|
||||
if (now - entry.storedAt > STALE_TIMEOUT_MS) {
|
||||
pendingStore.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store metadata to be restored after fromPlugin() overwrites it.
|
||||
* Called from tool execute() functions alongside ctx.metadata().
|
||||
*/
|
||||
export function storeToolMetadata(
|
||||
sessionID: string,
|
||||
callID: string,
|
||||
data: PendingToolMetadata,
|
||||
): void {
|
||||
cleanupStaleEntries()
|
||||
pendingStore.set(makeKey(sessionID, callID), { ...data, storedAt: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume stored metadata (one-time read, removes from store).
|
||||
* Called from tool.execute.after hook.
|
||||
*/
|
||||
export function consumeToolMetadata(
|
||||
sessionID: string,
|
||||
callID: string,
|
||||
): PendingToolMetadata | undefined {
|
||||
const key = makeKey(sessionID, callID)
|
||||
const stored = pendingStore.get(key)
|
||||
if (stored) {
|
||||
pendingStore.delete(key)
|
||||
const { storedAt: _, ...data } = stored
|
||||
return data
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current store size (for testing/debugging).
|
||||
*/
|
||||
export function getPendingStoreSize(): number {
|
||||
return pendingStore.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending metadata (for testing).
|
||||
*/
|
||||
export function clearPendingStore(): void {
|
||||
pendingStore.clear()
|
||||
}
|
||||
Reference in New Issue
Block a user