merge(dev): resolve background-agent delegated fallback conflicts

Reconcile the latest dev branch changes with the delegated child-session fallback work. Preserve the upstream background-agent updates while keeping the delegated bootstrap cleanup and compatibility wiring fixes intact, then re-verify the affected regression suites and typecheck.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
tw-yshuang
2026-05-11 03:02:14 +08:00
668 changed files with 44608 additions and 6160 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/delegate-task/ — Task Delegation Engine
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
@@ -45,6 +45,9 @@ describe("executeBackgroundContinuation - subagent metadata", () => {
expect(result).toContain("<task_metadata>")
expect(result).toContain("subagent: oracle")
expect(result).toContain("session_id: ses_resumed_123")
expect(result).toContain("background_task_id: bg_task_001")
expect(result).not.toContain("task_id: ses_resumed_123")
expect(result).toContain("Background Task ID: bg_task_001")
})
test("omits subagent from task_metadata when task agent is undefined", async () => {
@@ -60,7 +60,7 @@ export async function executeBackgroundContinuation(
return `Background task continued.
Task ID: ${backgroundTaskId}
Background Task ID: ${backgroundTaskId}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
@@ -72,7 +72,6 @@ Do NOT call background_output now. Wait for <system-reminder> notification first
${buildTaskMetadataBlock({
sessionId,
taskId: sessionId,
backgroundTaskId,
agent: task.agent,
category: task.category,
@@ -104,7 +104,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
//#then - output and metadata should include canonical session linkage
expectFn(result).toContain("<task_metadata>")
expectFn(result).toContain("session_id: ses_sub_123")
expectFn(result).toContain("task_id: ses_sub_123")
expectFn(result).not.toContain("task_id: ses_sub_123")
expectFn(result).toContain("background_task_id: bg_resolved")
expectFn(result).toContain("subagent: explore")
expectFn(result).toContain("Background Task ID: bg_resolved")
@@ -114,6 +114,49 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
expectFn(metadataCalls[0].metadata.backgroundTaskId).toBe("bg_resolved")
})
testFn("keeps continuation taskId out of visible background metadata", async () => {
//#given - launched background task with both a background id and session id
const metadataCalls: Array<{ metadata: Record<string, unknown> }> = []
const manager = {
launch: async () => ({
id: "bg_visible_contract",
sessionId: "ses_visible_contract",
description: "Visible contract",
agent: "explore",
status: "running",
}),
getTask: () => ({ sessionId: "ses_visible_contract" }),
}
const result = await executeBackgroundTask(
{
description: "Visible contract",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_visible_contract",
metadata: async (value: { metadata: Record<string, unknown> }) => metadataCalls.push(value),
abort: new AbortController().signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_visible_contract" },
"explore",
undefined,
undefined,
undefined,
)
//#then - machine metadata keeps OpenCode compatibility, visible text avoids the overloaded task_id label
expectFn(result).toContain("session_id: ses_visible_contract")
expectFn(result).toContain("background_task_id: bg_visible_contract")
expectFn(result).not.toContain("task_id: ses_visible_contract")
expectFn(metadataCalls[0].metadata.taskId).toBe("ses_visible_contract")
expectFn(metadataCalls[0].metadata.backgroundTaskId).toBe("bg_visible_contract")
})
testFn("captures late-resolved session id and emits synced metadata", async () => {
//#given - background task session id appears after launch via manager polling
const metadataCalls: any[] = []
@@ -155,7 +198,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
//#then - late session id still propagates to task metadata contract
expectFn(result).toContain("session_id: ses_late_123")
expectFn(result).toContain("task_id: ses_late_123")
expectFn(result).not.toContain("task_id: ses_late_123")
expectFn(result).toContain("background_task_id: bg_late")
expectFn(metadataCalls).toHaveLength(1)
expectFn(metadataCalls[0].metadata.sessionId).toBe("ses_late_123")
@@ -190,7 +190,6 @@ export async function executeBackgroundTask(
const taskMetadataBlock = sessionId
? `\n\n${buildTaskMetadataBlock({
sessionId,
taskId: sessionId,
backgroundTaskId: task.id,
agent: task.agent,
category: args.category,
@@ -0,0 +1,63 @@
const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"minimal",
"none",
"auto",
"thinking",
])
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
const trimmedModelID = rawModelID.trim()
if (!trimmedModelID) {
return { modelID: "" }
}
const parenthesizedVariant = trimmedModelID.match(/^(.*)\(([^()]+)\)\s*$/)
if (parenthesizedVariant) {
const modelID = parenthesizedVariant[1]?.trim() ?? ""
const variant = parenthesizedVariant[2]?.trim()
return variant ? { modelID, variant } : { modelID }
}
const spaceVariant = trimmedModelID.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
if (spaceVariant) {
const modelID = spaceVariant[1]?.trim() ?? ""
const variant = spaceVariant[2]?.trim().toLowerCase()
if (variant && KNOWN_VARIANTS.has(variant)) {
return { modelID, variant }
}
}
return { modelID: trimmedModelID }
}
export function parseModelString(
model: string,
): { providerID: string; modelID: string; variant?: string } | undefined {
const trimmedModel = model.trim()
if (!trimmedModel) return undefined
const parts = trimmedModel.split("/")
if (parts.length < 2) {
return undefined
}
const providerID = parts[0]?.trim()
const rawModelID = parts.slice(1).join("/").trim()
if (!providerID || !rawModelID) {
return undefined
}
const parsedModel = parseVariantFromModelID(rawModelID)
if (!parsedModel.modelID) {
return undefined
}
return parsedModel.variant
? { providerID, modelID: parsedModel.modelID, variant: parsedModel.variant }
: { providerID, modelID: parsedModel.modelID }
}
@@ -3,6 +3,7 @@ const { describe, test, expect } = require("bun:test")
import {
DEEP_CATEGORY_PROMPT_APPEND,
DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX,
DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5,
OPENAI_CATEGORIES,
resolveDeepCategoryPromptAppend,
@@ -52,6 +53,59 @@ describe("DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5", () => {
})
})
describe("DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX", () => {
test("uses Category_Context wrapper with name=\"deep\"", () => {
//#given
const prompt = DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
//#then
expect(prompt).toContain('<Category_Context name="deep">')
expect(prompt).toContain("</Category_Context>")
})
test("contains GPT-5.3-Codex-specific style markers", () => {
//#given
const prompt = DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
//#then
expect(prompt).toContain("GPT-5.3-Codex")
expect(prompt).toContain("Autonomy and persistence")
expect(prompt).toContain("Goal, not plan")
expect(prompt).toContain("Code implementation")
expect(prompt).toContain("Worktree safety")
expect(prompt).toContain("Completion bar")
expect(prompt).toContain("Final message")
})
test("preserves legacy DEEP knowledge from both default and 5.5 variants", () => {
//#given
const prompt = DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
//#then
expect(prompt).toContain("atomic task")
expect(prompt).toContain("root cause")
expect(prompt).toContain("Bias to action")
expect(prompt).toContain("complete mental model")
expect(prompt).toContain("Ambition scaled")
})
test("uses parallel-batch exploration framing instead of legacy silent-exploration", () => {
//#given
const prompt = DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
//#then
expect(prompt).toContain("Batch everything")
expect(prompt).toContain("maximize parallelism")
expect(prompt).not.toContain("five to fifteen minutes")
})
test("is materially different from both DEEP_CATEGORY_PROMPT_APPEND and DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5", () => {
//#then
expect(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX).not.toBe(DEEP_CATEGORY_PROMPT_APPEND)
expect(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX).not.toBe(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5)
})
})
describe("resolveDeepCategoryPromptAppend", () => {
test("returns GPT-5.5 prompt for openai/gpt-5.5", () => {
//#when
@@ -85,12 +139,20 @@ describe("resolveDeepCategoryPromptAppend", () => {
expect(result).toBe(DEEP_CATEGORY_PROMPT_APPEND)
})
test("returns legacy prompt for openai/gpt-5.3-codex", () => {
test("returns GPT-5.3-codex prompt for openai/gpt-5.3-codex", () => {
//#when
const result = resolveDeepCategoryPromptAppend("openai/gpt-5.3-codex")
//#then
expect(result).toBe(DEEP_CATEGORY_PROMPT_APPEND)
expect(result).toBe(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX)
})
test("returns GPT-5.3-codex prompt for the gpt-5-3-codex hyphenated form", () => {
//#when
const result = resolveDeepCategoryPromptAppend("openai/gpt-5-3-codex")
//#then
expect(result).toBe(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX)
})
test("returns legacy prompt for undefined model", () => {
+71 -2
View File
@@ -1,4 +1,4 @@
import { isGpt5_5Model } from "../../agents/types"
import { isGpt5_3CodexModel, isGpt5_5Model } from "../../agents/types"
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
@@ -44,6 +44,72 @@ Approach: explore extensively, understand deeply, then act decisively. Prefer co
Minimal status updates. Focus on results, not play-by-play. Report completion with summary of changes.
</Category_Context>`
export const DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX = `<Category_Context name="deep">
You are operating in DEEP mode on GPT-5.3-Codex. This category is reserved for goal-oriented autonomous coding work on hairy problems that reward depth over speed and a complete solution over a quick patch.
The orchestrator routed you here for autonomous execution. Do not stop to ask the orchestrator for permission, do not produce an upfront plan and wait for approval, do not stop at a proof of concept.
# Autonomy and persistence
- Once the goal is given, gather context, implement, verify, and explain outcomes within this turn whenever feasible.
- Persist end-to-end: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation unless you hit a genuine blocker (missing secret, design decision only the user can make, three materially different attempts all failed).
- Bias to action: default to implementing with reasonable assumptions. Do not end your turn with clarifying questions unless truly blocked. Document assumptions in the final message instead.
- Avoid excessive looping. If you find yourself re-reading or re-editing the same files without clear progress, stop and end the turn with a concise summary and any clarifying questions needed.
# Goal, not plan
You receive a GOAL describing the desired outcome. You figure out HOW. The orchestrator deliberately did not hand you a step-by-step plan; producing one and pausing for approval is not what was asked.
When the goal contains numbered steps or phases, treat them as sub-steps of ONE atomic task and execute them all in this turn. Splitting them across turns is wrong unless they reveal an architectural blocker that requires the user's input. If the steps turn out to be genuinely independent tasks that should have been separate delegations, flag that in your final message and refuse the ones beyond scope.
# Exploration
- Think first. Before any tool call, decide ALL files and resources you will need.
- Batch everything. If you need multiple files (even from different places), read them together using parallel tool calls.
- Always maximize parallelism: never read files one-by-one unless logically unavoidable. For broader questions fire 2-5 explore/librarian sub-agents in parallel.
- Workflow: (a) plan all needed reads, (b) issue one parallel batch, (c) analyze results, (d) repeat if new unpredictable reads arise. Sequential reads only when you truly cannot know the next file without seeing a prior result first.
Build a complete mental model before the first edit. Exploration is an investment, not overhead - the orchestrator routed depth tasks here specifically because rushing to implementation is the failure mode.
# Code implementation
- Discerning engineer mindset: optimize for correctness, clarity, and reliability over speed. Cover the root cause, not just a symptom or a narrow slice. Trace at least two levels up before settling - a null check around \`foo()\` is a symptom; fixing what causes \`foo()\` to return unexpected values is the root.
- Conform to codebase conventions: follow existing patterns, helpers, naming, formatting, localization. If you must diverge, state why.
- Behavior-safe defaults: preserve intended behavior and UX; gate or flag intentional changes; add tests when behavior shifts.
- Tight error handling: no broad try/catch blocks, no success-shaped fallbacks; propagate or surface errors explicitly. No silent failures - do not early-return on invalid input without logging consistent with repo patterns.
- Efficient, coherent edits: read enough context before changing a file; batch logical edits together rather than thrashing with many tiny patches.
- Type safety: changes must pass build and type-check; avoid \`as any\` or \`as unknown as ...\`; prefer proper types and guards; reuse existing helpers.
- Reuse / DRY: search for prior art before adding helpers; reuse or extract a shared helper instead of duplicating.
- Ambition scaled to context: greenfield = strong defaults, avoid AI-slop, produce work you would hand to another senior engineer. Existing codebase = surgical, respect existing patterns. Depth does not mean invasiveness.
# Completion bar
"Simplified version", "proof of concept", and "you can extend this later" are not acceptable for a deep task. The orchestrator routed here specifically for a complete solution. If you hit a genuine blocker, document it and return; otherwise, finish the task.
# Worktree safety
- NEVER revert existing changes you did not make unless explicitly requested - those changes were made by the user.
- If asked to commit and there are unrelated changes in those files, do not revert them.
- If you notice unexpected changes you did not make in unrelated files, ignore them.
- If you notice unexpected mid-rollout changes you did not make and are not sure how to proceed, stop and ask.
- NEVER use destructive commands like \`git reset --hard\` or \`git checkout --\` unless explicitly requested.
# Status cadence
The user is not on the other side of this conversation; the orchestrator is, and they will synthesize your progress. Send commentary only at meaningful phase transitions (starting exploration, starting implementation, starting verification, hitting a genuine blocker). Do not narrate every tool call; silence during focused work is expected.
If you used a planning tool, mark every previously stated intention as Done, Blocked (one-sentence reason + targeted question), or Cancelled (with reason) before finishing. Do not end with in_progress or pending items.
# Final message
- Be concise; pragmatic, not chatty. Higher actionable information per token; fewer social flourishes.
- Lead with a quick explanation of the change, then context covering where and why. Do not start with "Summary"; jump in.
- Reference paths only - do not dump file contents. Do not say "save/copy this file" - the user is on the same machine.
- For substantial work, summarize clearly with high-level headings.
- File references: inline code with standalone path. Examples: \`src/app.ts\`, \`src/app.ts:42\`. Do not use \`file://\`, \`vscode://\`, or \`https://\` URIs. Do not provide line ranges.
- Suggest natural next steps (tests, commits, build) only if there are real ones; otherwise omit.
</Category_Context>`
export const DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5 = `<Category_Context name="deep">
You are operating in DEEP mode. This is the category reserved for goal-oriented autonomous work on hairy problems that reward thorough exploration and comprehensive solutions.
@@ -67,6 +133,9 @@ The orchestrator chose this category because the task benefits from depth over s
</Category_Context>`
export function resolveDeepCategoryPromptAppend(model: string | undefined): string {
if (model && isGpt5_3CodexModel(model)) {
return DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
}
if (model && isGpt5_5Model(model)) {
return DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5
}
@@ -134,7 +203,7 @@ export const OPENAI_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "deep",
config: { model: "openai/gpt-5.5", variant: "medium" },
description: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
description: "Goal-oriented autonomous problem-solving on hairy problems requiring deep research. ONE goal + ONE deliverable per call — multiple goals must fan out as parallel `deep` calls, never bundled into one.",
promptAppend: DEEP_CATEGORY_PROMPT_APPEND,
resolvePromptAppend: resolveDeepCategoryPromptAppend,
},
@@ -0,0 +1,40 @@
import { describe, test, expect } from "bun:test"
import { resolveCallID } from "./resolve-call-id"
import type { ToolContextWithMetadata } from "./types"
describe("resolveCallID", () => {
function makeCtx(overrides: Partial<ToolContextWithMetadata> = {}): ToolContextWithMetadata {
return {
sessionID: "ses_test",
messageID: "msg_test",
agent: "sisyphus",
abort: new AbortController().signal,
...overrides,
}
}
test("#given callID is set #then returns callID", () => {
const ctx = makeCtx({ callID: "call_abc" })
expect(resolveCallID(ctx)).toBe("call_abc")
})
test("#given only callId is set #then returns callId", () => {
const ctx = makeCtx({ callId: "call_def" })
expect(resolveCallID(ctx)).toBe("call_def")
})
test("#given only call_id is set #then returns call_id", () => {
const ctx = makeCtx({ call_id: "call_ghi" })
expect(resolveCallID(ctx)).toBe("call_ghi")
})
test("#given callID and callId are both set #then prefers callID", () => {
const ctx = makeCtx({ callID: "preferred", callId: "fallback" })
expect(resolveCallID(ctx)).toBe("preferred")
})
test("#given no call ID variants are set #then returns undefined", () => {
const ctx = makeCtx()
expect(resolveCallID(ctx)).toBeUndefined()
})
})
@@ -0,0 +1,5 @@
import type { ToolContextWithMetadata } from "./types"
export function resolveCallID(ctx: ToolContextWithMetadata): string | undefined {
return ctx.callID ?? ctx.callId ?? ctx.call_id
}
+7 -1
View File
@@ -4,7 +4,13 @@ import { discoverSkills } from "../../features/opencode-skill-loader"
export async function resolveSkillContent(
skills: string[],
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string>, directory?: string }
options: {
gitMasterConfig?: GitMasterConfig
browserProvider?: BrowserAutomationProvider
disabledSkills?: Set<string>
teamModeEnabled?: boolean
directory?: string
}
): Promise<{ content: string | undefined; contents: string[]; error: string | null }> {
if (skills.length === 0) {
return { content: undefined, contents: [], error: null }
+24 -6
View File
@@ -26,11 +26,17 @@ import type { FallbackEntry } from "../../shared/model-requirements"
import { resolveModelForDelegateTask } from "./model-selection"
import { fuzzyMatchModel } from "../../shared/model-availability"
export interface ResolveSubagentExecutionOptions {
allowSisyphusJuniorDirect?: boolean
allowPrimaryAgentDelegation?: boolean
}
export async function resolveSubagentExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
parentAgent: string | undefined,
categoryExamples: string
categoryExamples: string,
options: ResolveSubagentExecutionOptions = {},
): Promise<{ agentToUse: string; categoryModel: DelegatedModelConfig | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
const { client, agentOverrides, userCategories } = executorCtx
@@ -40,11 +46,17 @@ export async function resolveSubagentExecution(
const agentName = sanitizeSubagentType(args.subagent_type)
if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) {
if (
!options.allowSisyphusJuniorDirect &&
agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()
) {
const exampleHint = categoryExamples.trim() !== ""
? `Use category parameter instead (e.g., ${categoryExamples}).`
: `Use the category parameter instead (pick one of: quick, deep, ultrabrain, visual-engineering, artistry, writing).`
return {
agentToUse: "",
categoryModel: undefined,
error: `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}).
error: `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. ${exampleHint}
Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`,
}
@@ -73,7 +85,7 @@ Create the work plan directly - that's your job as the planning agent.`,
const mergedAgents = mergeWithClaudeCodeAgents(agents, executorCtx.directory)
const matchedPrimaryAgent = findPrimaryAgentMatch(mergedAgents, agentToUse)
if (matchedPrimaryAgent) {
if (matchedPrimaryAgent && !options.allowPrimaryAgentDelegation) {
return {
agentToUse: "",
categoryModel: undefined,
@@ -81,7 +93,11 @@ Create the work plan directly - that's your job as the planning agent.`,
}
}
const matchedAgent = findCallableAgentMatch(mergedAgents, agentToUse)
const usePrimary = options.allowPrimaryAgentDelegation && matchedPrimaryAgent !== undefined
const matchedAgent = usePrimary
? matchedPrimaryAgent
: findCallableAgentMatch(mergedAgents, agentToUse)
if (!matchedAgent) {
return {
agentToUse: "",
@@ -90,7 +106,9 @@ Create the work plan directly - that's your job as the planning agent.`,
}
}
agentToUse = stripAgentListSortPrefix(matchedAgent.name)
agentToUse = usePrimary
? matchedAgent.name
: stripAgentListSortPrefix(matchedAgent.name)
const agentConfigKey = getAgentConfigKey(agentToUse)
const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
@@ -1,5 +1,20 @@
const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
const TEAM_TOOL_DENIALS = {
team_create: false,
team_delete: false,
team_shutdown_request: false,
team_approve_shutdown: false,
team_reject_shutdown: false,
team_send_message: false,
team_task_create: false,
team_task_list: false,
team_task_update: false,
team_task_get: false,
team_status: false,
team_list: false,
}
describe("executeSyncContinuation - toast cleanup error paths", () => {
let removeTaskCalls: string[] = []
let addTaskCalls: any[] = []
@@ -532,6 +547,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
question: false,
write: false,
edit: false,
...TEAM_TOOL_DENIALS,
})
})
@@ -602,6 +618,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
question: false,
write: false,
edit: false,
...TEAM_TOOL_DENIALS,
})
})
@@ -670,6 +687,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
task: true,
call_omo_agent: true,
question: false,
...TEAM_TOOL_DENIALS,
})
})
})
@@ -75,10 +75,56 @@ describe("syncPollTimeoutMs threading", () => {
taskId: undefined,
}, 120_000)
expect(result).toBe("Poll timeout reached after 120000ms for session ses_custom")
expect(result).toBe("Poll inactivity timeout reached after 120000ms without active OpenCode status for session ses_custom")
expect(abortCount).toBe(1)
})
})
test("#then active OpenCode statuses do not consume the inactivity timeout", async () => {
const { pollSyncSession } = require("./sync-session-poller")
let abortCount = 0
let statusCallCount = 0
let messageCallCount = 0
const mockClient = {
session: {
abort: async () => {
abortCount++
},
messages: async () => {
messageCallCount++
return {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "text", text: "done" }],
},
],
}
},
status: async () => {
statusCallCount++
if (statusCallCount === 1) return { data: { ses_active: { type: "busy" } } }
if (statusCallCount === 2) return { data: { ses_active: { type: "retry" } } }
return { data: { ses_active: { type: "idle" } } }
},
},
}
await withMockedDateNow(60_000, async () => {
const result = await pollSyncSession(createMockCtx(), mockClient, {
sessionID: "ses_active",
agentToUse: "oracle",
toastManager: null,
taskId: undefined,
}, 120_000)
expect(result).toBeNull()
expect(abortCount).toBe(0)
expect(statusCallCount).toBe(3)
expect(messageCallCount).toBe(1)
})
})
})
describe("#when timeoutMs is omitted", () => {
@@ -95,7 +141,7 @@ describe("syncPollTimeoutMs threading", () => {
taskId: undefined,
})
expect(result).toBe(`Poll timeout reached after ${MAX_POLL_TIME_MS}ms for session ses_default`)
expect(result).toBe(`Poll inactivity timeout reached after ${MAX_POLL_TIME_MS}ms without active OpenCode status for session ses_default`)
})
})
@@ -113,7 +159,7 @@ describe("syncPollTimeoutMs threading", () => {
taskId: undefined,
})
expect(result).toBe("Poll timeout reached after 120000ms for session ses_legacy")
expect(result).toBe("Poll inactivity timeout reached after 120000ms without active OpenCode status for session ses_legacy")
})
})
})
@@ -131,7 +177,7 @@ describe("syncPollTimeoutMs threading", () => {
taskId: undefined,
}, 10)
expect(result).toBe("Poll timeout reached after 50ms for session ses_guard")
expect(result).toBe("Poll inactivity timeout reached after 50ms without active OpenCode status for session ses_guard")
})
})
})
@@ -100,7 +100,7 @@ describe("pollSyncSession", () => {
}, 50)
// then: times out (ignores stale error)
expect(result).toContain("Poll timeout reached")
expect(result).toContain("Poll inactivity timeout reached")
})
test("detects completion when assistant message has terminal finish reason", async () => {
@@ -459,7 +459,7 @@ describe("pollSyncSession", () => {
}, 0)
// then: returns timeout error
expect(result).toBe("Poll timeout reached after 50ms for session ses_timeout")
expect(result).toBe("Poll inactivity timeout reached after 50ms without active OpenCode status for session ses_timeout")
expect(abortCount).toBe(1)
})
})
+21 -6
View File
@@ -7,6 +7,7 @@ import { extractErrorMessage } from "../../features/background-agent/error-class
const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"])
const PENDING_TOOL_PART_TYPES = new Set(["tool", "tool_use", "tool-call"])
const ACTIVE_SESSION_STATUSES = new Set(["busy", "retry", "running"])
function wait(milliseconds: number): Promise<void> {
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)
@@ -24,6 +25,10 @@ function abortSyncSession(client: OpencodeClient, sessionID: string, reason: str
})
}
function isActiveSessionStatus(status: { type: string } | undefined): boolean {
return status !== undefined && ACTIVE_SESSION_STATUSES.has(status.type)
}
async function fetchSessionMessages(
client: OpencodeClient,
sessionID: string
@@ -84,6 +89,7 @@ export async function pollSyncSession(
const maxPollTimeMs = Math.max(timeoutMs ?? getDefaultSyncPollTimeoutMs(), 50)
const maxTurns = input.maxAssistantTurns ?? DEFAULT_MAX_ASSISTANT_TURNS
const pollStart = Date.now()
let inactiveStart = pollStart
let pollCount = 0
let timedOut = false
let assistantTurnCount = 0
@@ -91,7 +97,13 @@ export async function pollSyncSession(
log("[task] Starting poll loop", { sessionID: input.sessionID, agentToUse: input.agentToUse, maxTurns })
while (Date.now() - pollStart < maxPollTimeMs) {
while (true) {
const inactiveElapsedMs = Date.now() - inactiveStart
if (inactiveElapsedMs >= maxPollTimeMs) {
timedOut = true
break
}
if (ctx.abort?.aborted) {
try {
const messages = await fetchSessionMessages(client, input.sessionID)
@@ -132,11 +144,13 @@ export async function pollSyncSession(
sessionID: input.sessionID,
pollCount,
elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s",
inactiveElapsed: Math.floor(inactiveElapsedMs / 1000) + "s",
sessionStatus: sessionStatus?.type ?? "not_in_status",
})
}
if (sessionStatus && sessionStatus.type !== "idle") {
if (isActiveSessionStatus(sessionStatus)) {
inactiveStart = Date.now()
continue
}
@@ -199,11 +213,12 @@ export async function pollSyncSession(
}
}
if (Date.now() - pollStart >= maxPollTimeMs) {
timedOut = true
log("[task] Poll timeout reached", { sessionID: input.sessionID, pollCount })
if (timedOut) {
log("[task] Poll inactivity timeout reached", { sessionID: input.sessionID, pollCount })
abortSyncSession(client, input.sessionID, "poll_timeout")
}
return timedOut ? `Poll timeout reached after ${maxPollTimeMs}ms for session ${input.sessionID}` : null
return timedOut
? `Poll inactivity timeout reached after ${maxPollTimeMs}ms without active OpenCode status for session ${input.sessionID}`
: null
}
+5 -5
View File
@@ -299,7 +299,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
{ providers: ["opencode-go"], model: "kimi-k2.6" },
]
//#when
@@ -309,10 +309,10 @@ describe("executeSyncTask - cleanup on error paths", () => {
//#then
expect(result).toContain("Task completed")
expect(result).toContain("Model: opencode-go/kimi-k2.5")
expect(result).toContain("Model: opencode-go/kimi-k2.6")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-7", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
{ providerID: "opencode-go", modelID: "kimi-k2.6", variant: undefined },
])
expect(setSessionFallbackChain).toHaveBeenCalledWith("ses_test_12345678", fallbackChain)
expect(bootstrapSnapshots[0]?.retryParts[0]?.text).toContain("test prompt")
@@ -374,7 +374,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
{ providers: ["opencode-go"], model: "kimi-k2.6" },
{ providers: ["openai"], model: "gpt-5.4", variant: "medium" },
]
@@ -387,7 +387,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
expect(result).toBe("Final failure")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-7", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
{ providerID: "opencode-go", modelID: "kimi-k2.6", variant: undefined },
{ providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
])
})
+2 -2
View File
@@ -3,7 +3,7 @@ const { describe, expect, test } = require("bun:test")
import { __resetTimingConfig, __setTimingConfig, getDefaultSyncPollTimeoutMs, getTimingConfig } from "./timing"
describe("timing sync poll timeout defaults", () => {
test("default sync timeout is 30 minutes", () => {
test("default sync inactivity timeout is 30 minutes", () => {
// #given
__resetTimingConfig()
@@ -14,7 +14,7 @@ describe("timing sync poll timeout defaults", () => {
expect(timeout).toBe(30 * 60 * 1000)
})
test("default sync timeout accessor follows MAX_POLL_TIME_MS config", () => {
test("default sync inactivity timeout accessor follows MAX_POLL_TIME_MS config", () => {
// #given
__resetTimingConfig()
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test"
import { createDelegateTaskPresentation } from "./tool-description"
describe("createDelegateTaskPresentation", () => {
test("#given sync task usage #when description is rendered #then timeout is described as inactivity based", () => {
//#given
const presentation = createDelegateTaskPresentation({})
//#when
const description = presentation.description
//#then
expect(description).toContain("30-minute inactivity window")
expect(description).toContain("busy/retry/running")
expect(description).toContain("not a total wall-clock limit")
})
})
@@ -67,6 +67,7 @@ export function createDelegateTaskPresentation(options: DelegateTaskToolOptions)
${categoryList}
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
Sync waits use a 30-minute inactivity window: OpenCode busy/retry/running status resets the window, so this is not a total wall-clock limit.
- task_id: Existing task to continue (from previous task output). Continues the same subagent session with FULL CONTEXT PRESERVED.
- command: The command that triggered this task (optional, for slash command tracking).
+12 -13
View File
@@ -381,7 +381,7 @@ describe("sisyphus-task", () => {
}
//#when
await tool.execute(args as DelegateTaskArgs, toolContext)
await tool.execute(args, toolContext)
//#then
expect(args.load_skills).toEqual(["playwright", "git-master"])
@@ -444,7 +444,7 @@ describe("sisyphus-task", () => {
}
//#when
await tool.execute(args as DelegateTaskArgs, toolContext)
await tool.execute(args, toolContext)
//#then
expect(args.load_skills).toEqual([])
@@ -755,8 +755,8 @@ describe("sisyphus-task", () => {
expect(result).toBeNull()
})
test("blocks requiresModel when availability is known and missing the required model", () => {
// given - artistry has requiresModel: gemini-3.1-pro
test("allows artistry to use its fallback chain when gemini is missing", () => {
// given - artistry can fall back from gemini to another capable model
const categoryName = "artistry"
const availableModels = new Set<string>(["anthropic/claude-opus-4-7"])
@@ -767,11 +767,12 @@ describe("sisyphus-task", () => {
})
// then
expect(result).toBeNull()
expect(result).not.toBeNull()
expect(result?.model).toBe("google/gemini-3.1-pro")
})
test("blocks requiresModel when availability is empty", () => {
// given - artistry has requiresModel: gemini-3.1-pro
test("allows artistry when availability is empty", () => {
// given - empty availability should not disable fallback-capable categories
const categoryName = "artistry"
const availableModels = new Set<string>()
@@ -782,7 +783,8 @@ describe("sisyphus-task", () => {
})
// then
expect(result).toBeNull()
expect(result).not.toBeNull()
expect(result?.model).toBe("google/gemini-3.1-pro")
})
test("bypasses requiresModel when explicit user config provided", () => {
@@ -1825,7 +1827,7 @@ describe("sisyphus-task", () => {
//#given a session with a previous message that has variant "max"
const { createDelegateTask } = require("./tools")
const promptMock = mock(async (input: any) => {
const promptMock = mock(async () => {
return { data: {} }
})
@@ -3144,8 +3146,6 @@ describe("sisyphus-task", () => {
test("should resolve agent-browser skill even when browserProvider is not set", async () => {
// given - delegate_task without browserProvider
const { createDelegateTask } = require("./tools")
let promptBody: any
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
@@ -3153,8 +3153,7 @@ describe("sisyphus-task", () => {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_no_browser_provider" } }),
prompt: async (input: any) => {
promptBody = input.body
prompt: async () => {
return { data: {} }
},
messages: async () => ({
+1
View File
@@ -47,6 +47,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
gitMasterConfig: options.gitMasterConfig,
browserProvider: options.browserProvider,
disabledSkills: options.disabledSkills,
teamModeEnabled: options.teamModeEnabled,
directory: options.directory,
})
if (skillError) {
+1
View File
@@ -62,6 +62,7 @@ export interface DelegateTaskToolOptions {
sisyphusJuniorModel?: string
browserProvider?: BrowserAutomationProvider
disabledSkills?: Set<string>
teamModeEnabled?: boolean
availableCategories?: AvailableCategory[]
availableSkills?: AvailableSkill[]
agentOverrides?: AgentOverrides
@@ -89,7 +89,6 @@ export async function executeUnstableAgentTask(
const taskMetadataBlock = buildTaskMetadataBlock({
sessionId: sessionID,
taskId: sessionID,
backgroundTaskId: task.id,
agent: agentToUse,
category: args.category,
@@ -168,6 +168,69 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBe('Cannot delegate to primary agent "Prometheus - Plan Builder" via task. Select that agent directly instead.')
})
test("allows delegating to a primary agent when allowPrimaryAgentDelegation is enabled (team-mode path)", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-opus-4-7"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "sisyphus" })
const executorCtx = createExecutorContext(async () => ([
{ name: "\u200BSisyphus - Ultraworker", mode: "primary", model: "anthropic/claude-opus-4-7" },
{ name: "oracle", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep", {
allowPrimaryAgentDelegation: true,
})
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("\u200BSisyphus - Ultraworker")
})
test("allows delegating to Sisyphus-Junior when allowSisyphusJuniorDirect is enabled (team-mode path)", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-sonnet-4-6"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "sisyphus-junior" })
const executorCtx = createExecutorContext(async () => ([
{ name: "Sisyphus-Junior", mode: "subagent", model: "anthropic/claude-sonnet-4-6" },
{ name: "oracle", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep", {
allowSisyphusJuniorDirect: true,
})
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("Sisyphus-Junior")
})
test("renders a usable fallback hint when categoryExamples is empty for the default Sisyphus-Junior block", async () => {
//#given
const args = createBaseArgs({ subagent_type: "sisyphus-junior" })
const executorCtx = createExecutorContext(async () => ([
{ name: "Sisyphus-Junior", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "")
//#then
expect(result.agentToUse).toBe("")
expect(result.error).toBeDefined()
expect(result.error).not.toContain("(e.g., )")
expect(result.error).toContain("pick one of: quick, deep, ultrabrain")
})
test("requires explicit all or subagent mode for task-callable agents", async () => {
//#given
const args = createBaseArgs({ subagent_type: "custom-worker" })