Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50afb6b2de | ||
|
|
41d790dc04 | ||
|
|
2ac2241367 | ||
|
|
283c7e6cb7 | ||
|
|
95aa7595f8 | ||
|
|
c6349dc38a | ||
|
|
17b475eefd | ||
|
|
3a019792e9 | ||
|
|
1ceaaa4311 | ||
|
|
ff8a5f343a | ||
|
|
118150035c | ||
|
|
157952f293 | ||
|
|
d358e6e48e | ||
|
|
9afd0d1d41 | ||
|
|
e4be8cea75 | ||
|
|
306c7f4c8e | ||
|
|
c12c6fa0c0 | ||
|
|
ef1baea163 | ||
|
|
d33af1d27f | ||
|
|
b2f019a987 | ||
|
|
ce7fb00847 | ||
|
|
63d3fa7439 |
28
bun.lock
28
bun.lock
@@ -28,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.3.1",
|
||||
"oh-my-opencode-darwin-x64": "3.3.1",
|
||||
"oh-my-opencode-linux-arm64": "3.3.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.3.1",
|
||||
"oh-my-opencode-linux-x64": "3.3.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.3.1",
|
||||
"oh-my-opencode-windows-x64": "3.3.1",
|
||||
"oh-my-opencode-darwin-arm64": "3.5.2",
|
||||
"oh-my-opencode-darwin-x64": "3.5.2",
|
||||
"oh-my-opencode-linux-arm64": "3.5.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.5.2",
|
||||
"oh-my-opencode-linux-x64": "3.5.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.5.2",
|
||||
"oh-my-opencode-windows-x64": "3.5.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -226,19 +226,19 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.3.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-R+o42Km6bsIaW6D3I8uu2HCF3BjIWqa/fg38W5y4hJEOw4mL0Q7uV4R+0vtrXRHo9crXTK9ag0fqVQUm+Y6iAQ=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oIS3lB2F9/N+3mF5wCKk6/EPVSz516XWN+mNdquSSeddw+xqMxGdhKY6K/XeYbHJzeN2Z8IOikNEJ6psR2/a8g=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7VTbpR1vH3OEkoJxBKtYuxFPX8M3IbJKoeHWME9iK6FpT11W1ASsjyuhvzB1jcxSeqF8ddMnjitlG5ub6h5EVw=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OAdXo4ZCCYO4kRWtnyz3tdmaGYPUB3WcXimXAxp+/sEZxAnh7n1RQkpLn6UxWX4AIAdRT9dfrOfRic6VoCYv2g=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BZ/r/CFlvbOxkdZZrRoT16xFOjibRZHuwQnaE4f0JvOzgK6/HWp3zJI1+2/aX/oK5GA6lZxNWRrJC/SKUi8LEg=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5XXNMFhp1VsyrGNRBoXcOyoaUeVkbrWkBRPDGZfpiq+kRXH3aaSWdR5G7Pl/TadOQv9Bl8/8YaxsuHRTFT1aXw=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-U90Wruf21h+CJbtcrS7MeTAc/5VOF6RI+5jr7qj/cCxjXNJtjhyJdz/maehArjtgf304+lYCM/Mh1i+G2D3YFQ=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/woIpqvEI85MgJvEVnz4g5FBLeiQNK7srRsueIFPBmtTahh42HFleCDaIltOl/ndjsE5nCHacQVJHkC9W9/F3Q=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sYzohSNdwsAhivbXcbhPdF1qqQi2CCI7FSgbmvvfBOMyZ8HAgqOFqYW2r3GPdmtywzkjOTvCzTG56FZwEjx15w=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vTL2A+6zzGhi+m7sC8peLDq5OAp2dRR0UEb4RbZAOHtlEruF7qFEmcK3ccWxwc3+Z3G/ITfwn5VNa72ZS4pNTg=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aG5pZ4eWS0YSGUicOnjMkUPrIqQV4poYF+d9SIvrfvlaMcK6WlQn7jXzgNCwJsfGn5lyhSmjshZBEU+v79Ua3w=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bOAA55snLsK2QB00IkQy8le0Oqh/GJ7pxEHtm1oUezlQrW/nX5SS/hJ7dPHMmOd9FoiqnqyqWZxNkLmFoG463A=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-FGH7cnzBqNwjSkzCDglMsVttaq+MsykAxa7ehaFK+0dnBZArvllS3W13a3dGaANHMZzfK0vz8hNDUdVi7Z63cA=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-fnHiAPYglw3unPckmQBoCT6+VqjSWCE3S3J551mRo0ZFrxuEP2ZKyHZeFMMOtKwDepCvmKgd1W040+KmuVUXOA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -74,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.5.2",
|
||||
"oh-my-opencode-darwin-x64": "3.5.2",
|
||||
"oh-my-opencode-linux-arm64": "3.5.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.5.2",
|
||||
"oh-my-opencode-linux-x64": "3.5.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.5.2",
|
||||
"oh-my-opencode-windows-x64": "3.5.2"
|
||||
"oh-my-opencode-darwin-arm64": "3.5.3",
|
||||
"oh-my-opencode-darwin-x64": "3.5.3",
|
||||
"oh-my-opencode-linux-arm64": "3.5.3",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.5.3",
|
||||
"oh-my-opencode-linux-x64": "3.5.3",
|
||||
"oh-my-opencode-linux-x64-musl": "3.5.3",
|
||||
"oh-my-opencode-windows-x64": "3.5.3"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1359,6 +1359,86 @@
|
||||
"created_at": "2026-02-11T05:29:51Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1750
|
||||
},
|
||||
{
|
||||
"name": "uyu423",
|
||||
"id": 8033320,
|
||||
"comment_id": 3884127858,
|
||||
"created_at": "2026-02-11T12:30:37Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1762
|
||||
},
|
||||
{
|
||||
"name": "WietRob",
|
||||
"id": 203506602,
|
||||
"comment_id": 3859280254,
|
||||
"created_at": "2026-02-06T10:00:03Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1529
|
||||
},
|
||||
{
|
||||
"name": "COLDTURNIP",
|
||||
"id": 46220,
|
||||
"comment_id": 3884966424,
|
||||
"created_at": "2026-02-11T14:54:46Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1765
|
||||
},
|
||||
{
|
||||
"name": "tcarac",
|
||||
"id": 64477810,
|
||||
"comment_id": 3885026481,
|
||||
"created_at": "2026-02-11T15:03:25Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1766
|
||||
},
|
||||
{
|
||||
"name": "youngbinkim0",
|
||||
"id": 64558592,
|
||||
"comment_id": 3887466814,
|
||||
"created_at": "2026-02-11T22:03:00Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1777
|
||||
},
|
||||
{
|
||||
"name": "raki-1203",
|
||||
"id": 52475378,
|
||||
"comment_id": 3889111683,
|
||||
"created_at": "2026-02-12T07:27:39Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1790
|
||||
},
|
||||
{
|
||||
"name": "G36maid",
|
||||
"id": 53391375,
|
||||
"comment_id": 3889208379,
|
||||
"created_at": "2026-02-12T07:56:21Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1791
|
||||
},
|
||||
{
|
||||
"name": "solssak",
|
||||
"id": 107416133,
|
||||
"comment_id": 3889740003,
|
||||
"created_at": "2026-02-12T09:28:09Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1794
|
||||
},
|
||||
{
|
||||
"name": "bvanderhorn",
|
||||
"id": 9591412,
|
||||
"comment_id": 3890297580,
|
||||
"created_at": "2026-02-12T11:17:38Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1799
|
||||
},
|
||||
{
|
||||
"name": "jardo5",
|
||||
"id": 22041729,
|
||||
"comment_id": 3890810423,
|
||||
"created_at": "2026-02-12T12:57:06Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1802
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -45,6 +45,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
})
|
||||
|
||||
//#then - exits with 0 but only after 3 consecutive checks
|
||||
@@ -53,6 +54,30 @@ describe("pollForCompletion", () => {
|
||||
expect(todoCallCount).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it("does not check completion during stabilization period after first meaningful work", async () => {
|
||||
//#given - session idle, meaningful work done, but stabilization period not elapsed
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
eventState.hasReceivedMeaningfulWork = true
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when - abort after 50ms (within the 60ms stabilization period)
|
||||
setTimeout(() => abortController.abort(), 50)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 60,
|
||||
})
|
||||
|
||||
//#then - should be aborted, not completed (stabilization blocked completion check)
|
||||
expect(result).toBe(130)
|
||||
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
|
||||
expect(todoCallCount).toBe(0)
|
||||
})
|
||||
|
||||
it("does not exit when currentTool is set - resets consecutive counter", async () => {
|
||||
//#given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
@@ -110,6 +135,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
})
|
||||
const elapsedMs = Date.now() - startMs
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ import { checkCompletionConditions } from "./completion"
|
||||
const DEFAULT_POLL_INTERVAL_MS = 500
|
||||
const DEFAULT_REQUIRED_CONSECUTIVE = 3
|
||||
const ERROR_GRACE_CYCLES = 3
|
||||
const MIN_STABILIZATION_MS = 10_000
|
||||
|
||||
export interface PollOptions {
|
||||
pollIntervalMs?: number
|
||||
requiredConsecutive?: number
|
||||
minStabilizationMs?: number
|
||||
}
|
||||
|
||||
export async function pollForCompletion(
|
||||
@@ -21,8 +23,11 @@ export async function pollForCompletion(
|
||||
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
||||
const requiredConsecutive =
|
||||
options.requiredConsecutive ?? DEFAULT_REQUIRED_CONSECUTIVE
|
||||
const minStabilizationMs =
|
||||
options.minStabilizationMs ?? MIN_STABILIZATION_MS
|
||||
let consecutiveCompleteChecks = 0
|
||||
let errorCycleCount = 0
|
||||
let firstWorkTimestamp: number | null = null
|
||||
|
||||
while (!abortController.signal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
@@ -61,6 +66,17 @@ export async function pollForCompletion(
|
||||
continue
|
||||
}
|
||||
|
||||
// Track when first meaningful work was received
|
||||
if (firstWorkTimestamp === null) {
|
||||
firstWorkTimestamp = Date.now()
|
||||
}
|
||||
|
||||
// Don't check completion during stabilization period
|
||||
if (Date.now() - firstWorkTimestamp < minStabilizationMs) {
|
||||
consecutiveCompleteChecks = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
if (shouldExit) {
|
||||
consecutiveCompleteChecks++
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
console.log(pc.dim(`Session: ${sessionID}`))
|
||||
|
||||
const ctx: RunContext = { client, sessionID, directory, abortController }
|
||||
const events = await client.event.subscribe()
|
||||
const events = await client.event.subscribe({ query: { directory } })
|
||||
const eventState = createEventState()
|
||||
const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
|
||||
() => {},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { log } from "../../shared"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { cleanupTaskAfterSessionEnds } from "./session-task-cleanup"
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
|
||||
type Event = { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
@@ -18,6 +18,7 @@ export function handleBackgroundEvent(args: {
|
||||
event: Event
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
getAllDescendantTasks: (sessionID: string) => BackgroundTask[]
|
||||
releaseConcurrencyKey?: (key: string) => void
|
||||
cancelTask: (
|
||||
taskId: string,
|
||||
options: { source: string; reason: string; skipNotification: true }
|
||||
@@ -36,6 +37,7 @@ export function handleBackgroundEvent(args: {
|
||||
event,
|
||||
findBySession,
|
||||
getAllDescendantTasks,
|
||||
releaseConcurrencyKey,
|
||||
cancelTask,
|
||||
tryCompleteTask,
|
||||
validateSessionHasOutput,
|
||||
@@ -78,6 +80,19 @@ export function handleBackgroundEvent(args: {
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
if (!props || !isRecord(props)) return
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: props,
|
||||
findBySession,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
tryCompleteTask,
|
||||
emitIdleEvent,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
if (!props || !isRecord(props)) return
|
||||
const sessionID = getString(props, "sessionID")
|
||||
if (!sessionID) return
|
||||
@@ -85,64 +100,26 @@ export function handleBackgroundEvent(args: {
|
||||
const task = findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) return
|
||||
const errorRaw = props["error"]
|
||||
const dataRaw = isRecord(errorRaw) ? errorRaw["data"] : undefined
|
||||
const message =
|
||||
(isRecord(dataRaw) ? getString(dataRaw, "message") : undefined) ??
|
||||
(isRecord(errorRaw) ? getString(errorRaw, "message") : undefined) ??
|
||||
"Session error"
|
||||
|
||||
const elapsedMs = Date.now() - startedAt.getTime()
|
||||
if (elapsedMs < MIN_IDLE_TIME_MS) {
|
||||
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
|
||||
if (!idleDeferralTimers.has(task.id)) {
|
||||
log("[background-agent] Deferring early session.idle:", {
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
taskId: task.id,
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
idleDeferralTimers.delete(task.id)
|
||||
emitIdleEvent(sessionID)
|
||||
}, remainingMs)
|
||||
idleDeferralTimers.set(task.id, timer)
|
||||
} else {
|
||||
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
task.status = "error"
|
||||
task.error = message
|
||||
task.completedAt = new Date()
|
||||
|
||||
validateSessionHasOutput(sessionID)
|
||||
.then(async (hasValidOutput) => {
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during validation, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
const hasIncompleteTodos = await checkSessionTodos(sessionID)
|
||||
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during todo check, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
await tryCompleteTask(task, "session.idle event")
|
||||
})
|
||||
.catch((err) => {
|
||||
log("[background-agent] Error in session.idle handler:", err)
|
||||
})
|
||||
cleanupTaskAfterSessionEnds({
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
@@ -176,24 +153,15 @@ export function handleBackgroundEvent(args: {
|
||||
})
|
||||
}
|
||||
|
||||
const completionTimer = completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
tasks.delete(task.id)
|
||||
clearNotificationsForTask(task.id)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
cleanupTaskAfterSessionEnds({
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,22 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
|
||||
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
|
||||
}
|
||||
|
||||
function getQueuesByKey(
|
||||
manager: BackgroundManager
|
||||
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
|
||||
return (manager as unknown as {
|
||||
queuesByKey: Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>>
|
||||
}).queuesByKey
|
||||
}
|
||||
|
||||
async function processKeyForTest(manager: BackgroundManager, key: string): Promise<void> {
|
||||
return (manager as unknown as { processKey: (key: string) => Promise<void> }).processKey(key)
|
||||
}
|
||||
|
||||
function pruneStaleTasksAndNotificationsForTest(manager: BackgroundManager): void {
|
||||
;(manager as unknown as { pruneStaleTasksAndNotifications: () => void }).pruneStaleTasksAndNotifications()
|
||||
}
|
||||
|
||||
async function tryCompleteTaskForTest(manager: BackgroundManager, task: BackgroundTask): Promise<boolean> {
|
||||
return (manager as unknown as { tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean> })
|
||||
.tryCompleteTask(task, "test")
|
||||
@@ -2505,6 +2521,198 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
test("sets task to error, releases concurrency, and cleans up", async () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
const concurrencyKey = "test-provider/test-model"
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
|
||||
const sessionID = "ses_error_1"
|
||||
const task = createMockTask({
|
||||
id: "task-session-error",
|
||||
sessionID,
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "task that errors",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
concurrencyKey,
|
||||
})
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: { message: "Model not found: kimi-for-coding/k2p5." },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("error")
|
||||
expect(task.error).toBe("Model not found: kimi-for-coding/k2p5.")
|
||||
expect(task.completedAt).toBeInstanceOf(Date)
|
||||
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
|
||||
expect(getTaskMap(manager).has(task.id)).toBe(false)
|
||||
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("ignores session.error for non-running tasks", () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
const sessionID = "ses_error_ignored"
|
||||
const task = createMockTask({
|
||||
id: "task-non-running",
|
||||
sessionID,
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "task already done",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
})
|
||||
task.completedAt = new Date()
|
||||
task.error = "previous"
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "UnknownError", message: "should not matter" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(task.status).toBe("completed")
|
||||
expect(task.error).toBe("previous")
|
||||
expect(getTaskMap(manager).has(task.id)).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("ignores session.error for unknown session", () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
|
||||
//#when
|
||||
const handler = () =>
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "ses_unknown",
|
||||
error: { name: "UnknownError", message: "Model not found" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(handler).not.toThrow()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager queue processing - error tasks are skipped", () => {
|
||||
test("does not start tasks with status=error", async () => {
|
||||
//#given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
promptAsync: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ client, directory: tmpdir() } as unknown as PluginInput,
|
||||
{ defaultConcurrency: 1 }
|
||||
)
|
||||
|
||||
const key = "test-key"
|
||||
const task: BackgroundTask = {
|
||||
id: "task-error-queued",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "queued error task",
|
||||
prompt: "test",
|
||||
agent: "test-agent",
|
||||
status: "error",
|
||||
queuedAt: new Date(),
|
||||
}
|
||||
|
||||
const input: import("./types").LaunchInput = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
}
|
||||
|
||||
let startCalled = false
|
||||
;(manager as unknown as { startTask: (item: unknown) => Promise<void> }).startTask = async () => {
|
||||
startCalled = true
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(key, [{ task, input }])
|
||||
|
||||
//#when
|
||||
await processKeyForTest(manager, key)
|
||||
|
||||
//#then
|
||||
expect(startCalled).toBe(false)
|
||||
expect(getQueuesByKey(manager).get(key)?.length ?? 0).toBe(0)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tasks from queuesByKey", () => {
|
||||
test("removes stale pending task from queue", () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
const queuedAt = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-stale-pending",
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "msg-1",
|
||||
description: "stale pending",
|
||||
prompt: "test",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
queuedAt,
|
||||
}
|
||||
const key = task.agent
|
||||
|
||||
const input: import("./types").LaunchInput = {
|
||||
description: task.description,
|
||||
prompt: task.prompt,
|
||||
agent: task.agent,
|
||||
parentSessionID: task.parentSessionID,
|
||||
parentMessageID: task.parentMessageID,
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
getQueuesByKey(manager).set(key, [{ task, input }])
|
||||
|
||||
//#when
|
||||
pruneStaleTasksAndNotificationsForTest(manager)
|
||||
|
||||
//#then
|
||||
expect(getQueuesByKey(manager).get(key)).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
|
||||
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
||||
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
|
||||
|
||||
@@ -192,7 +192,7 @@ export class BackgroundManager {
|
||||
|
||||
await this.concurrencyManager.acquire(key)
|
||||
|
||||
if (item.task.status === "cancelled") {
|
||||
if (item.task.status === "cancelled" || item.task.status === "error") {
|
||||
this.concurrencyManager.release(key)
|
||||
queue.shift()
|
||||
continue
|
||||
@@ -729,6 +729,44 @@ export class BackgroundManager {
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const errorMessage = props ? this.getSessionErrorMessage(props) : undefined
|
||||
|
||||
task.status = "error"
|
||||
task.error = errorMessage ?? "Session error"
|
||||
task.completedAt = new Date()
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
const completionTimer = this.completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
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)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const info = props?.info
|
||||
if (!info || typeof info.id !== "string") return
|
||||
@@ -1281,6 +1319,24 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
return ""
|
||||
}
|
||||
|
||||
private isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
private getSessionErrorMessage(properties: EventProperties): string | undefined {
|
||||
const errorRaw = properties["error"]
|
||||
if (!this.isRecord(errorRaw)) return undefined
|
||||
|
||||
const dataRaw = errorRaw["data"]
|
||||
if (this.isRecord(dataRaw)) {
|
||||
const message = dataRaw["message"]
|
||||
if (typeof message === "string") return message
|
||||
}
|
||||
|
||||
const message = errorRaw["message"]
|
||||
return typeof message === "string" ? message : undefined
|
||||
}
|
||||
|
||||
private hasRunningTasks(): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === "running") return true
|
||||
@@ -1292,6 +1348,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
const now = Date.now()
|
||||
|
||||
for (const [taskId, task] of this.tasks.entries()) {
|
||||
const wasPending = task.status === "pending"
|
||||
const timestamp = task.status === "pending"
|
||||
? task.queuedAt?.getTime()
|
||||
: task.startedAt?.getTime()
|
||||
@@ -1316,6 +1373,21 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
if (wasPending) {
|
||||
const key = task.model
|
||||
? `${task.model.providerID}/${task.model.modelID}`
|
||||
: task.agent
|
||||
const queue = this.queuesByKey.get(key)
|
||||
if (queue) {
|
||||
const index = queue.findIndex((item) => item.task.id === taskId)
|
||||
if (index !== -1) {
|
||||
queue.splice(index, 1)
|
||||
if (queue.length === 0) {
|
||||
this.queuesByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
if (task.sessionID) {
|
||||
|
||||
93
src/features/background-agent/session-idle-event-handler.ts
Normal file
93
src/features/background-agent/session-idle-event-handler.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { log } from "../../shared"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
function getString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = obj[key]
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export function handleSessionIdleBackgroundEvent(args: {
|
||||
properties: Record<string, unknown>
|
||||
findBySession: (sessionID: string) => BackgroundTask | undefined
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
|
||||
checkSessionTodos: (sessionID: string) => Promise<boolean>
|
||||
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
|
||||
emitIdleEvent: (sessionID: string) => void
|
||||
}): void {
|
||||
const {
|
||||
properties,
|
||||
findBySession,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput,
|
||||
checkSessionTodos,
|
||||
tryCompleteTask,
|
||||
emitIdleEvent,
|
||||
} = args
|
||||
|
||||
const sessionID = getString(properties, "sessionID")
|
||||
if (!sessionID) return
|
||||
|
||||
const task = findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) return
|
||||
|
||||
const elapsedMs = Date.now() - startedAt.getTime()
|
||||
if (elapsedMs < MIN_IDLE_TIME_MS) {
|
||||
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
|
||||
if (!idleDeferralTimers.has(task.id)) {
|
||||
log("[background-agent] Deferring early session.idle:", {
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
taskId: task.id,
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
idleDeferralTimers.delete(task.id)
|
||||
emitIdleEvent(sessionID)
|
||||
}, remainingMs)
|
||||
idleDeferralTimers.set(task.id, timer)
|
||||
} else {
|
||||
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
validateSessionHasOutput(sessionID)
|
||||
.then(async (hasValidOutput) => {
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during validation, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
const hasIncompleteTodos = await checkSessionTodos(sessionID)
|
||||
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during todo check, skipping:", {
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
await tryCompleteTask(task, "session.idle event")
|
||||
})
|
||||
.catch((err) => {
|
||||
log("[background-agent] Error in session.idle handler:", err)
|
||||
})
|
||||
}
|
||||
46
src/features/background-agent/session-task-cleanup.ts
Normal file
46
src/features/background-agent/session-task-cleanup.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
export function cleanupTaskAfterSessionEnds(args: {
|
||||
task: BackgroundTask
|
||||
tasks: Map<string, BackgroundTask>
|
||||
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
completionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
releaseConcurrencyKey?: (key: string) => void
|
||||
}): void {
|
||||
const {
|
||||
task,
|
||||
tasks,
|
||||
idleDeferralTimers,
|
||||
completionTimers,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
releaseConcurrencyKey,
|
||||
} = args
|
||||
|
||||
const completionTimer = completionTimers.get(task.id)
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
completionTimers.delete(task.id)
|
||||
}
|
||||
|
||||
const idleTimer = idleDeferralTimers.get(task.id)
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
idleDeferralTimers.delete(task.id)
|
||||
}
|
||||
|
||||
if (task.concurrencyKey && releaseConcurrencyKey) {
|
||||
releaseConcurrencyKey(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
clearNotificationsForTask(task.id)
|
||||
tasks.delete(task.id)
|
||||
if (task.sessionID) {
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import { TASK_TTL_MS } from "./constants"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
|
||||
import type { BackgroundTask } from "./types"
|
||||
import type { BackgroundTask, LaunchInput } from "./types"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
type QueueItem = { task: BackgroundTask; input: LaunchInput }
|
||||
|
||||
export function pruneStaleState(args: {
|
||||
tasks: Map<string, BackgroundTask>
|
||||
notifications: Map<string, BackgroundTask[]>
|
||||
queuesByKey: Map<string, QueueItem[]>
|
||||
concurrencyManager: ConcurrencyManager
|
||||
cleanupPendingByParent: (task: BackgroundTask) => void
|
||||
clearNotificationsForTask: (taskId: string) => void
|
||||
@@ -17,6 +20,7 @@ export function pruneStaleState(args: {
|
||||
const {
|
||||
tasks,
|
||||
notifications,
|
||||
queuesByKey,
|
||||
concurrencyManager,
|
||||
cleanupPendingByParent,
|
||||
clearNotificationsForTask,
|
||||
@@ -26,6 +30,7 @@ export function pruneStaleState(args: {
|
||||
tasks,
|
||||
notifications,
|
||||
onTaskPruned: (taskId, task, errorMessage) => {
|
||||
const wasPending = task.status === "pending"
|
||||
const now = Date.now()
|
||||
const timestamp = task.status === "pending"
|
||||
? task.queuedAt?.getTime()
|
||||
@@ -47,6 +52,21 @@ export function pruneStaleState(args: {
|
||||
}
|
||||
|
||||
cleanupPendingByParent(task)
|
||||
if (wasPending) {
|
||||
const key = task.model
|
||||
? `${task.model.providerID}/${task.model.modelID}`
|
||||
: task.agent
|
||||
const queue = queuesByKey.get(key)
|
||||
if (queue) {
|
||||
const index = queue.findIndex((item) => item.task.id === taskId)
|
||||
if (index !== -1) {
|
||||
queue.splice(index, 1)
|
||||
if (queue.length === 0) {
|
||||
queuesByKey.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clearNotificationsForTask(taskId)
|
||||
tasks.delete(taskId)
|
||||
if (task.sessionID) {
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function processConcurrencyKeyQueue(args: {
|
||||
|
||||
await concurrencyManager.acquire(key)
|
||||
|
||||
if (item.task.status === "cancelled") {
|
||||
if (item.task.status === "cancelled" || item.task.status === "error") {
|
||||
concurrencyManager.release(key)
|
||||
queue.shift()
|
||||
continue
|
||||
|
||||
@@ -202,7 +202,7 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
|
||||
error: parsed.command.includes(":") ? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.` : `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
5
src/hooks/prometheus-md-only/agent-matcher.ts
Normal file
5
src/hooks/prometheus-md-only/agent-matcher.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PROMETHEUS_AGENT } from "./constants"
|
||||
|
||||
export function isPrometheusAgent(agentName: string | undefined): boolean {
|
||||
return agentName?.toLowerCase().includes(PROMETHEUS_AGENT) ?? false
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { HOOK_NAME, PROMETHEUS_AGENT, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
|
||||
import { HOOK_NAME, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { getAgentFromSession } from "./agent-resolution"
|
||||
import { isPrometheusAgent } from "./agent-matcher"
|
||||
import { isAllowedFile } from "./path-policy"
|
||||
|
||||
const TASK_TOOLS = ["task", "call_omo_agent"]
|
||||
@@ -16,7 +17,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
): Promise<void> => {
|
||||
const agentName = getAgentFromSession(input.sessionID, ctx.directory)
|
||||
|
||||
if (agentName !== PROMETHEUS_AGENT) {
|
||||
if (!isPrometheusAgent(agentName)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ describe("prometheus-md-only", () => {
|
||||
} as never
|
||||
}
|
||||
|
||||
function setupMessageStorage(sessionID: string, agent: string): void {
|
||||
function setupMessageStorage(sessionID: string, agent: string | undefined): void {
|
||||
testMessageDir = join(MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(testMessageDir, { recursive: true })
|
||||
const messageContent = {
|
||||
agent,
|
||||
...(agent ? { agent } : {}),
|
||||
model: { providerID: "test", modelID: "test-model" },
|
||||
}
|
||||
writeFileSync(
|
||||
@@ -55,6 +55,122 @@ describe("prometheus-md-only", () => {
|
||||
rmSync(TEST_STORAGE_ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("agent name matching", () => {
|
||||
test("should enforce md-only restriction for exact prometheus agent name", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should enforce md-only restriction for Prometheus display name Plan Builder", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Plan Builder)")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should enforce md-only restriction for Prometheus display name Planner", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should enforce md-only restriction for uppercase PROMETHEUS", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "PROMETHEUS")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should not enforce restriction for non-Prometheus agent", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, "sisyphus")
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should not enforce restriction when agent name is undefined", async () => {
|
||||
//#given
|
||||
setupMessageStorage(TEST_SESSION_ID, undefined)
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
//#when //#then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("with Prometheus agent in message storage", () => {
|
||||
beforeEach(() => {
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
|
||||
@@ -53,24 +53,194 @@ describe("opencode-server-auth", () => {
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedConfig: { headers: Record<string, string> } | undefined
|
||||
let receivedHeadersConfig: { headers: Record<string, string> } | undefined
|
||||
const client = {
|
||||
_client: {
|
||||
setConfig: (config: { headers: Record<string, string> }) => {
|
||||
receivedConfig = config
|
||||
setConfig: (config: { headers?: Record<string, string> }) => {
|
||||
if (config.headers) {
|
||||
receivedHeadersConfig = { headers: config.headers }
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
injectServerAuthIntoClient(client)
|
||||
|
||||
expect(receivedConfig).toEqual({
|
||||
expect(receivedHeadersConfig).toEqual({
|
||||
headers: {
|
||||
Authorization: "Basic b3BlbmNvZGU6c2VjcmV0",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("#given server password #when injecting wraps internal fetch #then wrapped fetch adds Authorization header", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
type InternalConfig = {
|
||||
fetch?: (request: Request) => Promise<Response>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
let currentConfig: InternalConfig = {
|
||||
fetch: baseFetch,
|
||||
headers: {},
|
||||
}
|
||||
|
||||
const client = {
|
||||
_client: {
|
||||
getConfig: (): InternalConfig => ({ ...currentConfig }),
|
||||
setConfig: (config: InternalConfig): InternalConfig => {
|
||||
currentConfig = { ...currentConfig, ...config }
|
||||
return { ...currentConfig }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
if (!currentConfig.fetch) {
|
||||
throw new Error("expected fetch to be set")
|
||||
}
|
||||
await currentConfig.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given server password #when internal has _config.fetch but no setConfig #then fetch is wrapped and injects Authorization", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
const internal = {
|
||||
_config: {
|
||||
fetch: baseFetch,
|
||||
},
|
||||
}
|
||||
|
||||
const client = {
|
||||
_client: internal,
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
await internal._config.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given server password #when client has top-level fetch #then fetch is wrapped and injects Authorization", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
const client = {
|
||||
fetch: baseFetch,
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
await client.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(receivedAuthorization ?? "").toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given server password #when interceptors are available #then request interceptor injects Authorization", async () => {
|
||||
//#given
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let registeredInterceptor:
|
||||
| ((request: Request, options: { headers?: Headers }) => Promise<Request> | Request)
|
||||
| undefined
|
||||
|
||||
const client = {
|
||||
_client: {
|
||||
interceptors: {
|
||||
request: {
|
||||
use: (
|
||||
interceptor: (request: Request, options: { headers?: Headers }) => Promise<Request> | Request
|
||||
): number => {
|
||||
registeredInterceptor = interceptor
|
||||
return 0
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
if (!registeredInterceptor) {
|
||||
throw new Error("expected interceptor to be registered")
|
||||
}
|
||||
const request = new Request("http://example.com")
|
||||
const result = await registeredInterceptor(request, {})
|
||||
|
||||
//#then
|
||||
expect(result.headers.get("Authorization")).toBe("Basic b3BlbmNvZGU6c2VjcmV0")
|
||||
})
|
||||
|
||||
test("#given no server password #when injecting into client with fetch #then does not wrap fetch", async () => {
|
||||
//#given
|
||||
delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
delete process.env.OPENCODE_SERVER_USERNAME
|
||||
|
||||
let receivedAuthorization: string | null = null
|
||||
const baseFetch = async (request: Request): Promise<Response> => {
|
||||
receivedAuthorization = request.headers.get("Authorization")
|
||||
return new Response("ok")
|
||||
}
|
||||
|
||||
type InternalConfig = { fetch?: (request: Request) => Promise<Response> }
|
||||
let currentConfig: InternalConfig = { fetch: baseFetch }
|
||||
let setConfigCalled = false
|
||||
|
||||
const client = {
|
||||
_client: {
|
||||
getConfig: (): InternalConfig => ({ ...currentConfig }),
|
||||
setConfig: (config: InternalConfig): InternalConfig => {
|
||||
setConfigCalled = true
|
||||
currentConfig = { ...currentConfig, ...config }
|
||||
return { ...currentConfig }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
injectServerAuthIntoClient(client)
|
||||
if (!currentConfig.fetch) {
|
||||
throw new Error("expected fetch to exist")
|
||||
}
|
||||
await currentConfig.fetch(new Request("http://example.com"))
|
||||
|
||||
//#then
|
||||
expect(setConfigCalled).toBe(false)
|
||||
expect(receivedAuthorization).toBeNull()
|
||||
})
|
||||
|
||||
test("#given server password #when client has no _client #then does not throw", () => {
|
||||
process.env.OPENCODE_SERVER_PASSWORD = "secret"
|
||||
const client = {}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { log } from "./logger"
|
||||
|
||||
/**
|
||||
* Builds HTTP Basic Auth header from environment variables.
|
||||
*
|
||||
@@ -15,6 +17,132 @@ export function getServerBasicAuthHeader(): string | undefined {
|
||||
return `Basic ${token}`
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function isRequestFetch(value: unknown): value is (request: Request) => Promise<Response> {
|
||||
return typeof value === "function"
|
||||
}
|
||||
|
||||
function wrapRequestFetch(
|
||||
baseFetch: (request: Request) => Promise<Response>,
|
||||
auth: string
|
||||
): (request: Request) => Promise<Response> {
|
||||
return async (request: Request): Promise<Response> => {
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("Authorization", auth)
|
||||
return baseFetch(new Request(request, { headers }))
|
||||
}
|
||||
}
|
||||
|
||||
function getInternalClient(client: unknown): UnknownRecord | null {
|
||||
if (!isRecord(client)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const internal = client["_client"]
|
||||
return isRecord(internal) ? internal : null
|
||||
}
|
||||
|
||||
function tryInjectViaSetConfigHeaders(internal: UnknownRecord, auth: string): boolean {
|
||||
const setConfig = internal["setConfig"]
|
||||
if (typeof setConfig !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
setConfig({
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaInterceptors(internal: UnknownRecord, auth: string): boolean {
|
||||
const interceptors = internal["interceptors"]
|
||||
if (!isRecord(interceptors)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const requestInterceptors = interceptors["request"]
|
||||
if (!isRecord(requestInterceptors)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const use = requestInterceptors["use"]
|
||||
if (typeof use !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
use((request: Request): Request => {
|
||||
if (!request.headers.get("Authorization")) {
|
||||
request.headers.set("Authorization", auth)
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaFetchWrapper(internal: UnknownRecord, auth: string): boolean {
|
||||
const getConfig = internal["getConfig"]
|
||||
const setConfig = internal["setConfig"]
|
||||
if (typeof getConfig !== "function" || typeof setConfig !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
const config = getConfig()
|
||||
if (!isRecord(config)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchValue = config["fetch"]
|
||||
if (!isRequestFetch(fetchValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
setConfig({
|
||||
fetch: wrapRequestFetch(fetchValue, auth),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaMutableInternalConfig(internal: UnknownRecord, auth: string): boolean {
|
||||
const configValue = internal["_config"]
|
||||
if (!isRecord(configValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchValue = configValue["fetch"]
|
||||
if (!isRequestFetch(fetchValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
configValue["fetch"] = wrapRequestFetch(fetchValue, auth)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function tryInjectViaTopLevelFetch(client: unknown, auth: string): boolean {
|
||||
if (!isRecord(client)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchValue = client["fetch"]
|
||||
if (!isRequestFetch(fetchValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
client["fetch"] = wrapRequestFetch(fetchValue, auth)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects HTTP Basic Auth header into the OpenCode SDK client.
|
||||
*
|
||||
@@ -34,36 +162,29 @@ export function injectServerAuthIntoClient(client: unknown): void {
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof client !== "object" ||
|
||||
client === null ||
|
||||
!("_client" in client) ||
|
||||
typeof (client as { _client: unknown })._client !== "object" ||
|
||||
(client as { _client: unknown })._client === null
|
||||
) {
|
||||
throw new Error(
|
||||
"[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible. " +
|
||||
"This may indicate an OpenCode SDK version mismatch."
|
||||
)
|
||||
const internal = getInternalClient(client)
|
||||
if (internal) {
|
||||
const injectedHeaders = tryInjectViaSetConfigHeaders(internal, auth)
|
||||
const injectedInterceptors = tryInjectViaInterceptors(internal, auth)
|
||||
const injectedFetch = tryInjectViaFetchWrapper(internal, auth)
|
||||
const injectedMutable = tryInjectViaMutableInternalConfig(internal, auth)
|
||||
|
||||
const injected = injectedHeaders || injectedInterceptors || injectedFetch || injectedMutable
|
||||
|
||||
if (!injected) {
|
||||
log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client structure is incompatible", {
|
||||
keys: Object.keys(internal),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const internal = (client as { _client: { setConfig?: (config: { headers: Record<string, string> }) => void } })
|
||||
._client
|
||||
|
||||
if (typeof internal.setConfig !== "function") {
|
||||
throw new Error(
|
||||
"[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but SDK client._client.setConfig is not a function. " +
|
||||
"This may indicate an OpenCode SDK version mismatch."
|
||||
)
|
||||
const injected = tryInjectViaTopLevelFetch(client, auth)
|
||||
if (!injected) {
|
||||
log("[opencode-server-auth] OPENCODE_SERVER_PASSWORD is set but no compatible SDK client found")
|
||||
}
|
||||
|
||||
internal.setConfig({
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.warn(`[opencode-server-auth] Failed to inject server auth: ${message}`)
|
||||
log("[opencode-server-auth] Failed to inject server auth", { message })
|
||||
}
|
||||
}
|
||||
|
||||
95
src/tools/delegate-task/background-continuation.test.ts
Normal file
95
src/tools/delegate-task/background-continuation.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
const { describe, test, expect, mock } = require("bun:test")
|
||||
|
||||
describe("executeBackgroundContinuation - subagent metadata", () => {
|
||||
test("includes subagent in task_metadata when task has agent", async () => {
|
||||
//#given - mock manager.resume returning task with agent info
|
||||
const mockManager = {
|
||||
resume: async () => ({
|
||||
id: "bg_task_001",
|
||||
description: "oracle consultation",
|
||||
agent: "oracle",
|
||||
status: "running",
|
||||
sessionID: "ses_resumed_123",
|
||||
}),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-456",
|
||||
metadata: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
manager: mockManager,
|
||||
}
|
||||
|
||||
const parentContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "msg-parent",
|
||||
agent: "sisyphus",
|
||||
}
|
||||
|
||||
const args = {
|
||||
session_id: "ses_resumed_123",
|
||||
prompt: "continue working",
|
||||
description: "resume oracle",
|
||||
load_skills: [],
|
||||
run_in_background: true,
|
||||
}
|
||||
|
||||
//#when - executeBackgroundContinuation completes
|
||||
const { executeBackgroundContinuation } = require("./background-continuation")
|
||||
const result = await executeBackgroundContinuation(args, mockCtx, mockExecutorCtx, parentContext)
|
||||
|
||||
//#then - task_metadata should contain subagent field
|
||||
expect(result).toContain("<task_metadata>")
|
||||
expect(result).toContain("subagent: oracle")
|
||||
expect(result).toContain("session_id: ses_resumed_123")
|
||||
})
|
||||
|
||||
test("omits subagent from task_metadata when task agent is undefined", async () => {
|
||||
//#given - mock manager.resume returning task without agent
|
||||
const mockManager = {
|
||||
resume: async () => ({
|
||||
id: "bg_task_002",
|
||||
description: "unknown task",
|
||||
agent: undefined,
|
||||
status: "running",
|
||||
sessionID: "ses_resumed_456",
|
||||
}),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-789",
|
||||
metadata: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
manager: mockManager,
|
||||
}
|
||||
|
||||
const parentContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "msg-parent",
|
||||
agent: "sisyphus",
|
||||
}
|
||||
|
||||
const args = {
|
||||
session_id: "ses_resumed_456",
|
||||
prompt: "continue",
|
||||
description: "resume task",
|
||||
load_skills: [],
|
||||
run_in_background: true,
|
||||
}
|
||||
|
||||
//#when - executeBackgroundContinuation completes without agent
|
||||
const { executeBackgroundContinuation } = require("./background-continuation")
|
||||
const result = await executeBackgroundContinuation(args, mockCtx, mockExecutorCtx, parentContext)
|
||||
|
||||
//#then - task_metadata should NOT contain subagent field
|
||||
expect(result).toContain("<task_metadata>")
|
||||
expect(result).toContain("session_id: ses_resumed_456")
|
||||
expect(result).not.toContain("subagent:")
|
||||
})
|
||||
})
|
||||
@@ -50,7 +50,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.
|
||||
|
||||
<task_metadata>
|
||||
session_id: ${task.sessionID}
|
||||
</task_metadata>`
|
||||
${task.agent ? `subagent: ${task.agent}\n` : ""}</task_metadata>`
|
||||
} catch (error) {
|
||||
return formatDetailedError(error, {
|
||||
operation: "Continue background task",
|
||||
|
||||
@@ -356,4 +356,112 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
|
||||
expect(addTaskCalls.length).toBe(0)
|
||||
expect(removeTaskCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("includes subagent in task_metadata when agent info is present in session messages", async () => {
|
||||
//#given - mock session messages with agent info on the last assistant message
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 }, agent: "oracle" } },
|
||||
{
|
||||
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn", agent: "oracle", providerID: "openai", modelID: "gpt-5.2" },
|
||||
parts: [{ type: "text", text: "Response" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({
|
||||
data: { ses_test: { type: "idle" } },
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const { executeSyncContinuation } = require("./sync-continuation")
|
||||
|
||||
const deps = {
|
||||
pollSyncSession: async () => null,
|
||||
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-123",
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
client: mockClient,
|
||||
}
|
||||
|
||||
const args = {
|
||||
session_id: "ses_test_12345678",
|
||||
prompt: "continue working",
|
||||
description: "resume oracle task",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
}
|
||||
|
||||
//#when - executeSyncContinuation completes with agent info in messages
|
||||
const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, deps)
|
||||
|
||||
//#then - task_metadata should contain subagent field with the agent name
|
||||
expect(result).toContain("<task_metadata>")
|
||||
expect(result).toContain("subagent: oracle")
|
||||
expect(result).toContain("session_id: ses_test_12345678")
|
||||
})
|
||||
|
||||
test("omits subagent from task_metadata when no agent info in session messages", async () => {
|
||||
//#given - mock session messages without any agent info
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" },
|
||||
parts: [{ type: "text", text: "Response" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({
|
||||
data: { ses_test: { type: "idle" } },
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const { executeSyncContinuation } = require("./sync-continuation")
|
||||
|
||||
const deps = {
|
||||
pollSyncSession: async () => null,
|
||||
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-123",
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
client: mockClient,
|
||||
}
|
||||
|
||||
const args = {
|
||||
session_id: "ses_test_12345678",
|
||||
prompt: "continue working",
|
||||
description: "resume task",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
}
|
||||
|
||||
//#when - executeSyncContinuation completes without agent info
|
||||
const result = await executeSyncContinuation(args, mockCtx, mockExecutorCtx, deps)
|
||||
|
||||
//#then - task_metadata should NOT contain subagent field
|
||||
expect(result).toContain("<task_metadata>")
|
||||
expect(result).toContain("session_id: ses_test_12345678")
|
||||
expect(result).not.toContain("subagent:")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,7 +128,7 @@ ${result.textContent || "(No text output)"}
|
||||
|
||||
<task_metadata>
|
||||
session_id: ${args.session_id}
|
||||
</task_metadata>`
|
||||
${resumeAgent ? `subagent: ${resumeAgent}\n` : ""}</task_metadata>`
|
||||
} finally {
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
|
||||
@@ -88,7 +88,9 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
|
||||
return `No exact match for "/${commandName}". Did you mean: ${matchList}?\n\n${formatCommandList(allItems)}`
|
||||
}
|
||||
|
||||
return `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.`
|
||||
return commandName.includes(":")
|
||||
? `Marketplace plugin commands like "/${commandName}" are not supported. Use .claude/commands/ for custom commands.\n\n${formatCommandList(allItems)}`
|
||||
: `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.`
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user