Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb45b0ecee | ||
|
|
d8e7e4f170 | ||
|
|
2db9accfc7 | ||
|
|
6b4e149881 | ||
|
|
7f4338b6ed | ||
|
|
24a013b867 | ||
|
|
d769b95869 | ||
|
|
72cf908738 | ||
|
|
b88a868173 | ||
|
|
d0bdf521c3 | ||
|
|
7abefcca1f | ||
|
|
139f392d76 | ||
|
|
71ac54c33e | ||
|
|
cbeeee4053 | ||
|
|
737bda680c | ||
|
|
ff94aa3033 | ||
|
|
d0c4085ae1 | ||
|
|
56f9de4652 | ||
|
|
b2661be833 | ||
|
|
3d4ed912d7 | ||
|
|
9a338b16f1 | ||
|
|
471bc6e52d | ||
|
|
0cbbdd566e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,3 +35,6 @@ test-injection/
|
||||
notepad.md
|
||||
oauth-success.html
|
||||
*.bun-build
|
||||
|
||||
# Local test sandbox
|
||||
.test-home/
|
||||
|
||||
28
bun.lock
28
bun.lock
@@ -28,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.2.4",
|
||||
"oh-my-opencode-darwin-x64": "3.2.4",
|
||||
"oh-my-opencode-linux-arm64": "3.2.4",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.2.4",
|
||||
"oh-my-opencode-linux-x64": "3.2.4",
|
||||
"oh-my-opencode-linux-x64-musl": "3.2.4",
|
||||
"oh-my-opencode-windows-x64": "3.2.4",
|
||||
"oh-my-opencode-darwin-arm64": "3.3.0",
|
||||
"oh-my-opencode-darwin-x64": "3.3.0",
|
||||
"oh-my-opencode-linux-arm64": "3.3.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.3.0",
|
||||
"oh-my-opencode-linux-x64": "3.3.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.3.0",
|
||||
"oh-my-opencode-windows-x64": "3.3.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -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.2.4", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-6vG49R/nkbZYhAqN2oStA+8reZRo2KPPHSbhQd4htdEpzS4ipVz6pW/YTj/TDwunQO7hy66AhP9hOR4pJcoDeA=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.3.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-P2kZKJqZaA4j0qtGM3I8+ZeH204ai27ni/OXLjtFdOewRjJgrahxaC1XslgK7q/KU9fXz6BQfEqAjbvyPf/rgQ=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.4", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Utfpclg8xHj93+faX2L4dpkzhM6D58YEtjkVlHq4CxZ8MdpYCs2l4NtY/b9T1GWmtQWFxZQhmIdAcwe1qApgpQ=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-RopOorbW1WyhMQJ+ipuqiOA1GICS+3IkOwNyEe0KZlCLpoEDTyFopIL87HSns+gEQPMxnknroDp8lzxn1AKgjw=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-z4Zlvt1a1PSQVprbgx6bLOeNuILX4d9p80GrTWuuYzqY+OEgbb74LVVUFCsvt8UgnhRTnHuhmphSpIL7UznzZg=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-297iEfuK+05g+q64crPW78Zbgm/j5PGjDDweSPkZ6rI6SEfHMvOIkGxMvN8gugM3zcH8FOCQXoY2nC8b6x3pwQ=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-pCCPM8rsuwMR3a7XIDyYyr/D1HkMPffOYGXeOY8vBaLL8NKFl8d0H5twA3HIiEqcDINHV3kw9zteL2paW+mHSQ=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oVxP0+yn66HQYfrl9QT6I7TumRzciuPB4z24+PwKEVcDjPbWXQqLY1gwOGHZAQBPLf0vwewv9ybEDVD42RRH4g=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vU9l4rS1oRpCgyXalBiUOOFPddIwSmuWoGY1PgO4dr6Db+gtEpmaDpLcEi5j4jFUDRLH6btQvNAp/eAydVgOJQ=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-k9LoLkisLJwJNR1J0Bh1bjGtGBkl5D9WzFPSdZCAlyiT6TgG9w5erPTlXqtl2Lt0We5tYUVYlkEIHRMK/ugNsQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OZ+yRl7tOXoWTHh7zQ8WsTasKqZaIaVO3QeUQhDIS5JXFjbgjMgFeC/XBegsCgfqglWTOlMatmCO1S3nx2vy2w=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7asXCeae7wBxJrzoZ7J6Yo1oaOxwUN3bTO7jWurCTMs5TDHO+pEHysgv/nuF1jvj1T+r1vg1H5ZmopuKy1qvXg=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.4", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-W6TX8OiPCOmu7UZgZESh5DSWat0zH/6WPC3tdvjzwYnik9ZvRiyJGHh9B4uAG3DdqTC+pZJrpuTq1NctqMJiDA=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-ABvwfaXb2xdrpbivzlPPJzIm5vXp+QlVakkaHEQf3TU6Mi/+fehH6Qhq/KMh66FDO2gq3xmxbH7nktHRQp9kNA=="],
|
||||
|
||||
"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.3.0",
|
||||
"version": "3.3.2",
|
||||
"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.3.0",
|
||||
"oh-my-opencode-darwin-x64": "3.3.0",
|
||||
"oh-my-opencode-linux-arm64": "3.3.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.3.0",
|
||||
"oh-my-opencode-linux-x64": "3.3.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.3.0",
|
||||
"oh-my-opencode-windows-x64": "3.3.0"
|
||||
"oh-my-opencode-darwin-arm64": "3.3.2",
|
||||
"oh-my-opencode-darwin-x64": "3.3.2",
|
||||
"oh-my-opencode-linux-arm64": "3.3.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.3.2",
|
||||
"oh-my-opencode-linux-x64": "3.3.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.3.2",
|
||||
"oh-my-opencode-windows-x64": "3.3.2"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.2",
|
||||
"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.3.0",
|
||||
"version": "3.3.2",
|
||||
"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.3.0",
|
||||
"version": "3.3.2",
|
||||
"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.3.0",
|
||||
"version": "3.3.2",
|
||||
"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.3.0",
|
||||
"version": "3.3.2",
|
||||
"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.3.0",
|
||||
"version": "3.3.2",
|
||||
"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.3.0",
|
||||
"version": "3.3.2",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1207,6 +1207,38 @@
|
||||
"created_at": "2026-02-06T06:23:24Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1541
|
||||
},
|
||||
{
|
||||
"name": "itsnebulalol",
|
||||
"id": 18669106,
|
||||
"comment_id": 3864672624,
|
||||
"created_at": "2026-02-07T15:10:54Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1622
|
||||
},
|
||||
{
|
||||
"name": "mkusaka",
|
||||
"id": 24956031,
|
||||
"comment_id": 3864822328,
|
||||
"created_at": "2026-02-07T16:54:36Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1629
|
||||
},
|
||||
{
|
||||
"name": "quantmind-br",
|
||||
"id": 170503374,
|
||||
"comment_id": 3865064441,
|
||||
"created_at": "2026-02-07T18:38:24Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1634
|
||||
},
|
||||
{
|
||||
"name": "QiRaining",
|
||||
"id": 13825001,
|
||||
"comment_id": 3865979224,
|
||||
"created_at": "2026-02-08T02:34:46Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1641
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ program
|
||||
.name("oh-my-opencode")
|
||||
.description("The ultimate OpenCode plugin - multi-model orchestration, LSP tools, and more")
|
||||
.version(VERSION, "-v, --version", "Show version number")
|
||||
.enablePositionalOptions()
|
||||
|
||||
program
|
||||
.command("install")
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
SisyphusAgentConfigSchema,
|
||||
ExperimentalConfigSchema,
|
||||
RalphLoopConfigSchema,
|
||||
TodoContinuationConfigSchema,
|
||||
TmuxConfigSchema,
|
||||
TmuxLayoutSchema,
|
||||
} from "./schema"
|
||||
@@ -25,6 +26,7 @@ export type {
|
||||
ExperimentalConfig,
|
||||
DynamicContextPruningConfig,
|
||||
RalphLoopConfig,
|
||||
TodoContinuationConfig,
|
||||
TmuxConfig,
|
||||
TmuxLayout,
|
||||
SisyphusConfig,
|
||||
|
||||
@@ -322,6 +322,13 @@ export const RalphLoopConfigSchema = z.object({
|
||||
state_dir: z.string().optional(),
|
||||
})
|
||||
|
||||
export const TodoContinuationConfigSchema = z.object({
|
||||
/** Max continuation injections per session before stopping (default: 8) */
|
||||
max_injections: z.number().min(1).max(1000).default(8),
|
||||
/** Max consecutive injections with no todo progress before stopping (default: 3) */
|
||||
max_stale_injections: z.number().min(0).max(1000).default(3),
|
||||
})
|
||||
|
||||
export const BackgroundTaskConfigSchema = z.object({
|
||||
defaultConcurrency: z.number().min(1).optional(),
|
||||
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
@@ -419,6 +426,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
auto_update: z.boolean().optional(),
|
||||
skills: SkillsConfigSchema.optional(),
|
||||
ralph_loop: RalphLoopConfigSchema.optional(),
|
||||
todo_continuation: TodoContinuationConfigSchema.optional(),
|
||||
background_task: BackgroundTaskConfigSchema.optional(),
|
||||
notification: NotificationConfigSchema.optional(),
|
||||
babysitting: BabysittingConfigSchema.optional(),
|
||||
@@ -446,6 +454,7 @@ export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningCo
|
||||
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
|
||||
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
|
||||
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
|
||||
export type TodoContinuationConfig = z.infer<typeof TodoContinuationConfigSchema>
|
||||
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>
|
||||
export type BabysittingConfig = z.infer<typeof BabysittingConfigSchema>
|
||||
export type CategoryConfig = z.infer<typeof CategoryConfigSchema>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { execSync } from "node:child_process"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/ho
|
||||
import { log } from "../../shared/logger"
|
||||
import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive"
|
||||
import { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils"
|
||||
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
export const HOOK_NAME = "atlas"
|
||||
@@ -269,113 +269,6 @@ function extractSessionIdFromOutput(output: string): string {
|
||||
return match?.[1] ?? "<session_id>"
|
||||
}
|
||||
|
||||
interface GitFileStat {
|
||||
path: string
|
||||
added: number
|
||||
removed: number
|
||||
status: "modified" | "added" | "deleted"
|
||||
}
|
||||
|
||||
function getGitDiffStats(directory: string): GitFileStat[] {
|
||||
try {
|
||||
const output = execSync("git diff --numstat HEAD", {
|
||||
cwd: directory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
if (!output) return []
|
||||
|
||||
const statusOutput = execSync("git status --porcelain", {
|
||||
cwd: directory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
const statusMap = new Map<string, "modified" | "added" | "deleted">()
|
||||
for (const line of statusOutput.split("\n")) {
|
||||
if (!line) continue
|
||||
const status = line.substring(0, 2).trim()
|
||||
const filePath = line.substring(3)
|
||||
if (status === "A" || status === "??") {
|
||||
statusMap.set(filePath, "added")
|
||||
} else if (status === "D") {
|
||||
statusMap.set(filePath, "deleted")
|
||||
} else {
|
||||
statusMap.set(filePath, "modified")
|
||||
}
|
||||
}
|
||||
|
||||
const stats: GitFileStat[] = []
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split("\t")
|
||||
if (parts.length < 3) continue
|
||||
|
||||
const [addedStr, removedStr, path] = parts
|
||||
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
|
||||
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
|
||||
|
||||
stats.push({
|
||||
path,
|
||||
added,
|
||||
removed,
|
||||
status: statusMap.get(path) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return stats
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
|
||||
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
|
||||
|
||||
const modified = stats.filter((s) => s.status === "modified")
|
||||
const added = stats.filter((s) => s.status === "added")
|
||||
const deleted = stats.filter((s) => s.status === "deleted")
|
||||
|
||||
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
|
||||
|
||||
if (modified.length > 0) {
|
||||
lines.push("Modified files:")
|
||||
for (const f of modified) {
|
||||
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
lines.push("Created files:")
|
||||
for (const f of added) {
|
||||
lines.push(` ${f.path} (+${f.added})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (deleted.length > 0) {
|
||||
lines.push("Deleted files:")
|
||||
for (const f of deleted) {
|
||||
lines.push(` ${f.path} (-${f.removed})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (notepadPath) {
|
||||
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
|
||||
if (notepadStat) {
|
||||
lines.push("[NOTEPAD UPDATED]")
|
||||
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
|
||||
lines.push("")
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
interface ToolExecuteAfterInput {
|
||||
tool: string
|
||||
sessionID?: string
|
||||
@@ -750,8 +643,8 @@ export function createAtlasHook(
|
||||
}
|
||||
|
||||
if (output.output && typeof output.output === "string") {
|
||||
const gitStats = getGitDiffStats(ctx.directory)
|
||||
const fileChanges = formatFileChanges(gitStats)
|
||||
const gitStats = collectGitDiffStats(ctx.directory)
|
||||
const fileChanges = formatFileChanges(gitStats)
|
||||
const subagentSessionId = extractSessionIdFromOutput(output.output)
|
||||
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
|
||||
@@ -24,6 +24,13 @@ describe("ralph-loop", () => {
|
||||
})
|
||||
return {}
|
||||
},
|
||||
promptAsync: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
messages: async (opts: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: opts.path.id })
|
||||
return { data: mockSessionMessages }
|
||||
|
||||
@@ -129,6 +129,63 @@ describe("detectErrorType", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("assistant_prefill_unsupported errors", () => {
|
||||
it("should detect assistant message prefill error from direct message", () => {
|
||||
//#given an error about assistant message prefill not being supported
|
||||
const error = {
|
||||
message: "This model does not support assistant message prefill. The conversation must end with a user message.",
|
||||
}
|
||||
|
||||
//#when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then should return assistant_prefill_unsupported
|
||||
expect(result).toBe("assistant_prefill_unsupported")
|
||||
})
|
||||
|
||||
it("should detect assistant message prefill error from nested error object", () => {
|
||||
//#given an Anthropic API error with nested structure matching the real error format
|
||||
const error = {
|
||||
error: {
|
||||
type: "invalid_request_error",
|
||||
message: "This model does not support assistant message prefill. The conversation must end with a user message.",
|
||||
},
|
||||
}
|
||||
|
||||
//#when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then should return assistant_prefill_unsupported
|
||||
expect(result).toBe("assistant_prefill_unsupported")
|
||||
})
|
||||
|
||||
it("should detect error with only 'conversation must end with a user message' fragment", () => {
|
||||
//#given an error containing only the user message requirement
|
||||
const error = {
|
||||
message: "The conversation must end with a user message.",
|
||||
}
|
||||
|
||||
//#when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then should return assistant_prefill_unsupported
|
||||
expect(result).toBe("assistant_prefill_unsupported")
|
||||
})
|
||||
|
||||
it("should detect error with only 'assistant message prefill' fragment", () => {
|
||||
//#given an error containing only the prefill mention
|
||||
const error = {
|
||||
message: "This model does not support assistant message prefill.",
|
||||
}
|
||||
|
||||
//#when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
//#then should return assistant_prefill_unsupported
|
||||
expect(result).toBe("assistant_prefill_unsupported")
|
||||
})
|
||||
})
|
||||
|
||||
describe("unrecognized errors", () => {
|
||||
it("should return null for unrecognized error patterns", () => {
|
||||
// given an unrelated error
|
||||
|
||||
@@ -28,6 +28,7 @@ type RecoveryErrorType =
|
||||
| "tool_result_missing"
|
||||
| "thinking_block_order"
|
||||
| "thinking_disabled_violation"
|
||||
| "assistant_prefill_unsupported"
|
||||
| null
|
||||
|
||||
interface MessageInfo {
|
||||
@@ -126,6 +127,13 @@ function extractMessageIndex(error: unknown): number | null {
|
||||
export function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (
|
||||
message.includes("assistant message prefill") ||
|
||||
message.includes("conversation must end with a user message")
|
||||
) {
|
||||
return "assistant_prefill_unsupported"
|
||||
}
|
||||
|
||||
// IMPORTANT: Check thinking_block_order BEFORE tool_result_missing
|
||||
// because Anthropic's extended thinking error messages contain "tool_use" and "tool_result"
|
||||
// in the documentation URL, which would incorrectly match tool_result_missing
|
||||
@@ -375,11 +383,13 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
|
||||
tool_result_missing: "Tool Crash Recovery",
|
||||
thinking_block_order: "Thinking Block Recovery",
|
||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||
assistant_prefill_unsupported: "Prefill Error Recovery",
|
||||
}
|
||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Injecting cancelled tool results...",
|
||||
thinking_block_order: "Fixing message structure...",
|
||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
||||
assistant_prefill_unsupported: "Sending 'Continue' to recover...",
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
@@ -411,6 +421,8 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
|
||||
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||
await resumeSession(ctx.client, resumeConfig)
|
||||
}
|
||||
} else if (errorType === "assistant_prefill_unsupported") {
|
||||
success = true
|
||||
}
|
||||
|
||||
return success
|
||||
|
||||
@@ -1313,4 +1313,64 @@ describe("todo-continuation-enforcer", () => {
|
||||
// then - no continuation injected (all countdowns cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should stop injecting after max injections reached", async () => {
|
||||
// given - session with incomplete todos and low injection cap
|
||||
const sessionID = "main-max-injections"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
config: { max_injections: 2, max_stale_injections: 100 },
|
||||
})
|
||||
|
||||
// when - idle cycles happen repeatedly
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2500)
|
||||
}
|
||||
|
||||
// then - only 2 injections occur
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
expect(toastCalls.some((t) => t.title === "Todo Continuation Stopped")).toBe(true)
|
||||
}, { timeout: 15000 })
|
||||
|
||||
test("should stop injecting when stale injections exceed limit and reset on progress", async () => {
|
||||
// given - session with a progress drop after first injection
|
||||
const sessionID = "main-stale-breaker"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.todo = async () => {
|
||||
// before first injection: 2 pending, after: 1 pending
|
||||
return {
|
||||
data: promptCalls.length === 0
|
||||
? [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "high" },
|
||||
]
|
||||
: [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {
|
||||
config: { max_injections: 100, max_stale_injections: 1 },
|
||||
})
|
||||
|
||||
// when - three idle cycles happen
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await fakeTimers.advanceBy(2500)
|
||||
}
|
||||
|
||||
// then - progress allows a second injection, but the third is blocked as stale
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
expect(toastCalls.some((t) => t.title === "Todo Continuation Stopped")).toBe(true)
|
||||
}, { timeout: 15000 })
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TodoContinuationConfig } from "../config"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
@@ -19,6 +20,7 @@ export interface TodoContinuationEnforcerOptions {
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
config?: TodoContinuationConfig
|
||||
}
|
||||
|
||||
export interface TodoContinuationEnforcer {
|
||||
@@ -41,6 +43,10 @@ interface SessionState {
|
||||
isRecovering?: boolean
|
||||
countdownStartedAt?: number
|
||||
abortDetectedAt?: number
|
||||
injectionCount?: number
|
||||
staleInjectionCount?: number
|
||||
lastIncompleteCount?: number
|
||||
circuitBroken?: boolean
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.TODO_CONTINUATION)}
|
||||
@@ -97,9 +103,17 @@ export function createTodoContinuationEnforcer(
|
||||
ctx: PluginInput,
|
||||
options: TodoContinuationEnforcerOptions = {}
|
||||
): TodoContinuationEnforcer {
|
||||
const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS, isContinuationStopped } = options
|
||||
const {
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
config,
|
||||
} = options
|
||||
const sessions = new Map<string, SessionState>()
|
||||
|
||||
const maxInjections = config?.max_injections ?? 8
|
||||
const maxStaleInjections = config?.max_stale_injections ?? 3
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
if (!state) {
|
||||
@@ -129,6 +143,45 @@ export function createTodoContinuationEnforcer(
|
||||
sessions.delete(sessionID)
|
||||
}
|
||||
|
||||
function resetCircuitBreaker(sessionID: string): void {
|
||||
const state = sessions.get(sessionID)
|
||||
if (!state) return
|
||||
state.injectionCount = 0
|
||||
state.staleInjectionCount = 0
|
||||
state.lastIncompleteCount = undefined
|
||||
state.circuitBroken = false
|
||||
}
|
||||
|
||||
async function tripCircuitBreaker(
|
||||
sessionID: string,
|
||||
reason: string,
|
||||
incompleteCount: number
|
||||
): Promise<void> {
|
||||
const state = getState(sessionID)
|
||||
if (state.circuitBroken) return
|
||||
state.circuitBroken = true
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] Circuit breaker tripped`, {
|
||||
sessionID,
|
||||
reason,
|
||||
injectionCount: state.injectionCount,
|
||||
staleInjectionCount: state.staleInjectionCount,
|
||||
incompleteCount,
|
||||
maxInjections,
|
||||
maxStaleInjections,
|
||||
})
|
||||
|
||||
await ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "Todo Continuation Stopped",
|
||||
message: reason,
|
||||
variant: "warning" as const,
|
||||
duration: 5000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const markRecovering = (sessionID: string): void => {
|
||||
const state = getState(sessionID)
|
||||
state.isRecovering = true
|
||||
@@ -169,6 +222,20 @@ export function createTodoContinuationEnforcer(
|
||||
): Promise<void> {
|
||||
const state = sessions.get(sessionID)
|
||||
|
||||
if (state?.circuitBroken) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: circuit breaker active`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if ((state?.injectionCount ?? 0) >= maxInjections) {
|
||||
await tripCircuitBreaker(
|
||||
sessionID,
|
||||
`Max injections (${maxInjections}) reached without todo completion progress`,
|
||||
incompleteCount
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (state?.isRecovering) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
||||
return
|
||||
@@ -198,6 +265,25 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
const currentState = getState(sessionID)
|
||||
if (typeof currentState.lastIncompleteCount === "number") {
|
||||
if (freshIncompleteCount < currentState.lastIncompleteCount) {
|
||||
currentState.staleInjectionCount = 0
|
||||
} else {
|
||||
currentState.staleInjectionCount = (currentState.staleInjectionCount ?? 0) + 1
|
||||
}
|
||||
}
|
||||
currentState.lastIncompleteCount = freshIncompleteCount
|
||||
|
||||
if (maxStaleInjections > 0 && (currentState.staleInjectionCount ?? 0) >= maxStaleInjections) {
|
||||
await tripCircuitBreaker(
|
||||
sessionID,
|
||||
`No todo progress detected for ${maxStaleInjections} consecutive continuation(s); stopping to prevent infinite loop`,
|
||||
freshIncompleteCount
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let agentName = resolvedInfo?.agent
|
||||
let model = resolvedInfo?.model
|
||||
let tools = resolvedInfo?.tools
|
||||
@@ -245,6 +331,9 @@ ${todoList}`
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount })
|
||||
|
||||
const nextCount = (currentState.injectionCount ?? 0) + 1
|
||||
currentState.injectionCount = nextCount
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
@@ -325,6 +414,11 @@ ${todoList}`
|
||||
|
||||
const state = getState(sessionID)
|
||||
|
||||
if (state.circuitBroken) {
|
||||
log(`[${HOOK_NAME}] Skipped: circuit breaker active`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isRecovering) {
|
||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||
return
|
||||
@@ -448,6 +542,7 @@ ${todoList}`
|
||||
if (!sessionID) return
|
||||
|
||||
if (role === "user") {
|
||||
resetCircuitBreaker(sessionID)
|
||||
const state = sessions.get(sessionID)
|
||||
if (state?.countdownStartedAt) {
|
||||
const elapsed = Date.now() - state.countdownStartedAt
|
||||
|
||||
@@ -2,7 +2,7 @@ import { _resetForTesting, setMainSession } from "../../features/claude-code-ses
|
||||
import type { BackgroundTask } from "../../features/background-agent"
|
||||
import { createUnstableAgentBabysitterHook } from "./index"
|
||||
|
||||
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
|
||||
const projectDir = "/tmp/fix-1349"
|
||||
|
||||
type BabysitterContext = Parameters<typeof createUnstableAgentBabysitterHook>[0]
|
||||
|
||||
@@ -21,6 +21,9 @@ function createMockPluginInput(options: {
|
||||
prompt: async (input: unknown) => {
|
||||
promptCalls.push({ input })
|
||||
},
|
||||
promptAsync: async (input: unknown) => {
|
||||
promptCalls.push({ input })
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -365,6 +365,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? safeCreateHook("todo-continuation-enforcer", () => createTodoContinuationEnforcer(ctx, {
|
||||
backgroundManager,
|
||||
isContinuationStopped: stopContinuationGuard?.isStopped,
|
||||
config: pluginConfig.todo_continuation,
|
||||
}), { enabled: safeHookEnabled })
|
||||
: null;
|
||||
|
||||
|
||||
@@ -600,6 +600,187 @@ describe("Prometheus direct override priority over category", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Plan agent model inheritance from prometheus", () => {
|
||||
test("plan agent inherits all model-related settings from resolved prometheus config", async () => {
|
||||
//#given - prometheus resolves to claude-opus-4-6 with model settings
|
||||
spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
provenance: "provider-fallback",
|
||||
variant: "max",
|
||||
})
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
replace_plan: true,
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {
|
||||
plan: {
|
||||
name: "plan",
|
||||
mode: "primary",
|
||||
prompt: "original plan prompt",
|
||||
},
|
||||
},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler(config)
|
||||
|
||||
//#then - plan inherits model and variant from prometheus, but NOT prompt
|
||||
const agents = config.agent as Record<string, { mode?: string; model?: string; variant?: string; prompt?: string }>
|
||||
expect(agents.plan).toBeDefined()
|
||||
expect(agents.plan.mode).toBe("subagent")
|
||||
expect(agents.plan.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(agents.plan.variant).toBe("max")
|
||||
expect(agents.plan.prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
test("plan agent inherits temperature, reasoningEffort, and other model settings from prometheus", async () => {
|
||||
//#given - prometheus configured with category that has temperature and reasoningEffort
|
||||
spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({
|
||||
model: "openai/gpt-5.2",
|
||||
provenance: "override",
|
||||
variant: "high",
|
||||
})
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
replace_plan: true,
|
||||
},
|
||||
agents: {
|
||||
prometheus: {
|
||||
model: "openai/gpt-5.2",
|
||||
variant: "high",
|
||||
temperature: 0.3,
|
||||
top_p: 0.9,
|
||||
maxTokens: 16000,
|
||||
reasoningEffort: "high",
|
||||
textVerbosity: "medium",
|
||||
thinking: { type: "enabled", budgetTokens: 8000 },
|
||||
},
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler(config)
|
||||
|
||||
//#then - plan inherits ALL model-related settings from resolved prometheus
|
||||
const agents = config.agent as Record<string, Record<string, unknown>>
|
||||
expect(agents.plan).toBeDefined()
|
||||
expect(agents.plan.mode).toBe("subagent")
|
||||
expect(agents.plan.model).toBe("openai/gpt-5.2")
|
||||
expect(agents.plan.variant).toBe("high")
|
||||
expect(agents.plan.temperature).toBe(0.3)
|
||||
expect(agents.plan.top_p).toBe(0.9)
|
||||
expect(agents.plan.maxTokens).toBe(16000)
|
||||
expect(agents.plan.reasoningEffort).toBe("high")
|
||||
expect(agents.plan.textVerbosity).toBe("medium")
|
||||
expect(agents.plan.thinking).toEqual({ type: "enabled", budgetTokens: 8000 })
|
||||
})
|
||||
|
||||
test("plan agent user override takes priority over prometheus inherited settings", async () => {
|
||||
//#given - prometheus resolves to opus, but user has plan override for gpt-5.2
|
||||
spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
provenance: "provider-fallback",
|
||||
variant: "max",
|
||||
})
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
replace_plan: true,
|
||||
},
|
||||
agents: {
|
||||
plan: {
|
||||
model: "openai/gpt-5.2",
|
||||
variant: "high",
|
||||
temperature: 0.5,
|
||||
},
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler(config)
|
||||
|
||||
//#then - plan uses its own override, not prometheus settings
|
||||
const agents = config.agent as Record<string, Record<string, unknown>>
|
||||
expect(agents.plan.model).toBe("openai/gpt-5.2")
|
||||
expect(agents.plan.variant).toBe("high")
|
||||
expect(agents.plan.temperature).toBe(0.5)
|
||||
})
|
||||
|
||||
test("plan agent does NOT inherit prompt, description, or color from prometheus", async () => {
|
||||
//#given
|
||||
spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
provenance: "provider-fallback",
|
||||
variant: "max",
|
||||
})
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
replace_plan: true,
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await handler(config)
|
||||
|
||||
//#then - plan has model settings but NOT prompt/description/color
|
||||
const agents = config.agent as Record<string, Record<string, unknown>>
|
||||
expect(agents.plan.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(agents.plan.prompt).toBeUndefined()
|
||||
expect(agents.plan.description).toBeUndefined()
|
||||
expect(agents.plan.color).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Deadlock prevention - fetchAvailableModels must not receive client", () => {
|
||||
test("fetchAvailableModels should be called with undefined client to prevent deadlock during plugin init", async () => {
|
||||
// given - This test ensures we don't regress on issue #1301
|
||||
|
||||
@@ -32,6 +32,7 @@ import { AGENT_NAME_MAP } from "../shared/migration";
|
||||
import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements";
|
||||
import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus";
|
||||
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants";
|
||||
import { buildPlanDemoteConfig } from "./plan-model-inheritance";
|
||||
import type { ModelCacheState } from "../plugin-state";
|
||||
import type { CategoryConfig } from "../config/schema";
|
||||
|
||||
@@ -385,8 +386,10 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: {};
|
||||
|
||||
const planDemoteConfig = shouldDemotePlan
|
||||
? { mode: "subagent" as const
|
||||
}
|
||||
? buildPlanDemoteConfig(
|
||||
agentConfig["prometheus"] as Record<string, unknown> | undefined,
|
||||
pluginConfig.agents?.plan as Record<string, unknown> | undefined,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
config.agent = {
|
||||
|
||||
118
src/plugin-handlers/plan-model-inheritance.test.ts
Normal file
118
src/plugin-handlers/plan-model-inheritance.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { buildPlanDemoteConfig } from "./plan-model-inheritance"
|
||||
|
||||
describe("buildPlanDemoteConfig", () => {
|
||||
test("returns only mode when prometheus and plan override are both undefined", () => {
|
||||
//#given
|
||||
const prometheusConfig = undefined
|
||||
const planOverride = undefined
|
||||
|
||||
//#when
|
||||
const result = buildPlanDemoteConfig(prometheusConfig, planOverride)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ mode: "subagent" })
|
||||
})
|
||||
|
||||
test("extracts all model settings from prometheus config", () => {
|
||||
//#given
|
||||
const prometheusConfig = {
|
||||
name: "prometheus",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
variant: "max",
|
||||
mode: "all",
|
||||
prompt: "You are Prometheus...",
|
||||
permission: { edit: "allow" },
|
||||
description: "Plan agent (Prometheus)",
|
||||
color: "#FF5722",
|
||||
temperature: 0.1,
|
||||
top_p: 0.95,
|
||||
maxTokens: 32000,
|
||||
thinking: { type: "enabled", budgetTokens: 10000 },
|
||||
reasoningEffort: "high",
|
||||
textVerbosity: "medium",
|
||||
providerOptions: { key: "value" },
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = buildPlanDemoteConfig(prometheusConfig, undefined)
|
||||
|
||||
//#then - picks model settings, NOT prompt/permission/description/color/name/mode
|
||||
expect(result.mode).toBe("subagent")
|
||||
expect(result.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result.variant).toBe("max")
|
||||
expect(result.temperature).toBe(0.1)
|
||||
expect(result.top_p).toBe(0.95)
|
||||
expect(result.maxTokens).toBe(32000)
|
||||
expect(result.thinking).toEqual({ type: "enabled", budgetTokens: 10000 })
|
||||
expect(result.reasoningEffort).toBe("high")
|
||||
expect(result.textVerbosity).toBe("medium")
|
||||
expect(result.providerOptions).toEqual({ key: "value" })
|
||||
expect(result.prompt).toBeUndefined()
|
||||
expect(result.permission).toBeUndefined()
|
||||
expect(result.description).toBeUndefined()
|
||||
expect(result.color).toBeUndefined()
|
||||
expect(result.name).toBeUndefined()
|
||||
})
|
||||
|
||||
test("plan override takes priority over prometheus for all model settings", () => {
|
||||
//#given
|
||||
const prometheusConfig = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
variant: "max",
|
||||
temperature: 0.1,
|
||||
reasoningEffort: "high",
|
||||
}
|
||||
const planOverride = {
|
||||
model: "openai/gpt-5.2",
|
||||
variant: "high",
|
||||
temperature: 0.5,
|
||||
reasoningEffort: "low",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = buildPlanDemoteConfig(prometheusConfig, planOverride)
|
||||
|
||||
//#then
|
||||
expect(result.model).toBe("openai/gpt-5.2")
|
||||
expect(result.variant).toBe("high")
|
||||
expect(result.temperature).toBe(0.5)
|
||||
expect(result.reasoningEffort).toBe("low")
|
||||
})
|
||||
|
||||
test("falls back to prometheus when plan override has partial settings", () => {
|
||||
//#given
|
||||
const prometheusConfig = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
variant: "max",
|
||||
temperature: 0.1,
|
||||
reasoningEffort: "high",
|
||||
}
|
||||
const planOverride = {
|
||||
model: "openai/gpt-5.2",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = buildPlanDemoteConfig(prometheusConfig, planOverride)
|
||||
|
||||
//#then - plan model wins, rest inherits from prometheus
|
||||
expect(result.model).toBe("openai/gpt-5.2")
|
||||
expect(result.variant).toBe("max")
|
||||
expect(result.temperature).toBe(0.1)
|
||||
expect(result.reasoningEffort).toBe("high")
|
||||
})
|
||||
|
||||
test("skips undefined values from both sources", () => {
|
||||
//#given
|
||||
const prometheusConfig = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = buildPlanDemoteConfig(prometheusConfig, undefined)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ mode: "subagent", model: "anthropic/claude-opus-4-6" })
|
||||
expect(Object.keys(result)).toEqual(["mode", "model"])
|
||||
})
|
||||
})
|
||||
27
src/plugin-handlers/plan-model-inheritance.ts
Normal file
27
src/plugin-handlers/plan-model-inheritance.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
const MODEL_SETTINGS_KEYS = [
|
||||
"model",
|
||||
"variant",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"maxTokens",
|
||||
"thinking",
|
||||
"reasoningEffort",
|
||||
"textVerbosity",
|
||||
"providerOptions",
|
||||
] as const
|
||||
|
||||
export function buildPlanDemoteConfig(
|
||||
prometheusConfig: Record<string, unknown> | undefined,
|
||||
planOverride: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const modelSettings: Record<string, unknown> = {}
|
||||
|
||||
for (const key of MODEL_SETTINGS_KEYS) {
|
||||
const value = planOverride?.[key] ?? prometheusConfig?.[key]
|
||||
if (value !== undefined) {
|
||||
modelSettings[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return { mode: "subagent" as const, ...modelSettings }
|
||||
}
|
||||
29
src/shared/git-worktree/collect-git-diff-stats.ts
Normal file
29
src/shared/git-worktree/collect-git-diff-stats.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { execSync } from "node:child_process"
|
||||
import { parseGitStatusPorcelain } from "./parse-status-porcelain"
|
||||
import { parseGitDiffNumstat } from "./parse-diff-numstat"
|
||||
import type { GitFileStat } from "./types"
|
||||
|
||||
export function collectGitDiffStats(directory: string): GitFileStat[] {
|
||||
try {
|
||||
const diffOutput = execSync("git diff --numstat HEAD", {
|
||||
cwd: directory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
if (!diffOutput) return []
|
||||
|
||||
const statusOutput = execSync("git status --porcelain", {
|
||||
cwd: directory,
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
const statusMap = parseGitStatusPorcelain(statusOutput)
|
||||
return parseGitDiffNumstat(diffOutput, statusMap)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
46
src/shared/git-worktree/format-file-changes.ts
Normal file
46
src/shared/git-worktree/format-file-changes.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { GitFileStat } from "./types"
|
||||
|
||||
export function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
|
||||
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
|
||||
|
||||
const modified = stats.filter((s) => s.status === "modified")
|
||||
const added = stats.filter((s) => s.status === "added")
|
||||
const deleted = stats.filter((s) => s.status === "deleted")
|
||||
|
||||
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
|
||||
|
||||
if (modified.length > 0) {
|
||||
lines.push("Modified files:")
|
||||
for (const f of modified) {
|
||||
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
lines.push("Created files:")
|
||||
for (const f of added) {
|
||||
lines.push(` ${f.path} (+${f.added})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (deleted.length > 0) {
|
||||
lines.push("Deleted files:")
|
||||
for (const f of deleted) {
|
||||
lines.push(` ${f.path} (-${f.removed})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (notepadPath) {
|
||||
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
|
||||
if (notepadStat) {
|
||||
lines.push("[NOTEPAD UPDATED]")
|
||||
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
|
||||
lines.push("")
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
51
src/shared/git-worktree/git-worktree.test.ts
Normal file
51
src/shared/git-worktree/git-worktree.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { formatFileChanges, parseGitDiffNumstat, parseGitStatusPorcelain } from "./index"
|
||||
|
||||
describe("git-worktree", () => {
|
||||
test("#given status porcelain output #when parsing #then maps paths to statuses", () => {
|
||||
const porcelain = [
|
||||
" M src/a.ts",
|
||||
"A src/b.ts",
|
||||
"?? src/c.ts",
|
||||
"D src/d.ts",
|
||||
].join("\n")
|
||||
|
||||
const map = parseGitStatusPorcelain(porcelain)
|
||||
expect(map.get("src/a.ts")).toBe("modified")
|
||||
expect(map.get("src/b.ts")).toBe("added")
|
||||
expect(map.get("src/c.ts")).toBe("added")
|
||||
expect(map.get("src/d.ts")).toBe("deleted")
|
||||
})
|
||||
|
||||
test("#given diff numstat and status map #when parsing #then returns typed stats", () => {
|
||||
const porcelain = [" M src/a.ts", "A src/b.ts"].join("\n")
|
||||
const statusMap = parseGitStatusPorcelain(porcelain)
|
||||
|
||||
const numstat = ["1\t2\tsrc/a.ts", "3\t0\tsrc/b.ts", "-\t-\tbin.dat"].join("\n")
|
||||
const stats = parseGitDiffNumstat(numstat, statusMap)
|
||||
|
||||
expect(stats).toEqual([
|
||||
{ path: "src/a.ts", added: 1, removed: 2, status: "modified" },
|
||||
{ path: "src/b.ts", added: 3, removed: 0, status: "added" },
|
||||
{ path: "bin.dat", added: 0, removed: 0, status: "modified" },
|
||||
])
|
||||
})
|
||||
|
||||
test("#given git file stats #when formatting #then produces grouped summary", () => {
|
||||
const summary = formatFileChanges([
|
||||
{ path: "src/a.ts", added: 1, removed: 2, status: "modified" },
|
||||
{ path: "src/b.ts", added: 3, removed: 0, status: "added" },
|
||||
{ path: "src/c.ts", added: 0, removed: 4, status: "deleted" },
|
||||
])
|
||||
|
||||
expect(summary).toContain("[FILE CHANGES SUMMARY]")
|
||||
expect(summary).toContain("Modified files:")
|
||||
expect(summary).toContain("Created files:")
|
||||
expect(summary).toContain("Deleted files:")
|
||||
expect(summary).toContain("src/a.ts")
|
||||
expect(summary).toContain("src/b.ts")
|
||||
expect(summary).toContain("src/c.ts")
|
||||
})
|
||||
})
|
||||
5
src/shared/git-worktree/index.ts
Normal file
5
src/shared/git-worktree/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { GitFileStatus, GitFileStat } from "./types"
|
||||
export { parseGitStatusPorcelain } from "./parse-status-porcelain"
|
||||
export { parseGitDiffNumstat } from "./parse-diff-numstat"
|
||||
export { collectGitDiffStats } from "./collect-git-diff-stats"
|
||||
export { formatFileChanges } from "./format-file-changes"
|
||||
27
src/shared/git-worktree/parse-diff-numstat.ts
Normal file
27
src/shared/git-worktree/parse-diff-numstat.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { GitFileStat, GitFileStatus } from "./types"
|
||||
|
||||
export function parseGitDiffNumstat(
|
||||
output: string,
|
||||
statusMap: Map<string, GitFileStatus>
|
||||
): GitFileStat[] {
|
||||
if (!output) return []
|
||||
|
||||
const stats: GitFileStat[] = []
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split("\t")
|
||||
if (parts.length < 3) continue
|
||||
|
||||
const [addedStr, removedStr, path] = parts
|
||||
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
|
||||
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
|
||||
|
||||
stats.push({
|
||||
path,
|
||||
added,
|
||||
removed,
|
||||
status: statusMap.get(path) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
25
src/shared/git-worktree/parse-status-porcelain.ts
Normal file
25
src/shared/git-worktree/parse-status-porcelain.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { GitFileStatus } from "./types"
|
||||
|
||||
export function parseGitStatusPorcelain(output: string): Map<string, GitFileStatus> {
|
||||
const map = new Map<string, GitFileStatus>()
|
||||
if (!output) return map
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
if (!line) continue
|
||||
|
||||
const status = line.substring(0, 2).trim()
|
||||
const filePath = line.substring(3)
|
||||
|
||||
if (!filePath) continue
|
||||
|
||||
if (status === "A" || status === "??") {
|
||||
map.set(filePath, "added")
|
||||
} else if (status === "D") {
|
||||
map.set(filePath, "deleted")
|
||||
} else {
|
||||
map.set(filePath, "modified")
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
8
src/shared/git-worktree/types.ts
Normal file
8
src/shared/git-worktree/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type GitFileStatus = "modified" | "added" | "deleted"
|
||||
|
||||
export interface GitFileStat {
|
||||
path: string
|
||||
added: number
|
||||
removed: number
|
||||
status: GitFileStatus
|
||||
}
|
||||
@@ -41,5 +41,6 @@ export * from "./tmux"
|
||||
export * from "./model-suggestion-retry"
|
||||
export * from "./opencode-server-auth"
|
||||
export * from "./port-utils"
|
||||
export * from "./git-worktree"
|
||||
export * from "./safe-create-hook"
|
||||
export * from "./truncate-description"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, mock } from "bun:test"
|
||||
import { parseModelSuggestion, promptWithModelSuggestionRetry } from "./model-suggestion-retry"
|
||||
import { parseModelSuggestion, promptWithModelSuggestionRetry, promptSyncWithModelSuggestionRetry } from "./model-suggestion-retry"
|
||||
|
||||
describe("parseModelSuggestion", () => {
|
||||
describe("structured NamedError format", () => {
|
||||
@@ -377,3 +377,128 @@ describe("promptWithModelSuggestionRetry", () => {
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("promptSyncWithModelSuggestionRetry", () => {
|
||||
it("should use synchronous prompt (not promptAsync)", async () => {
|
||||
// given a client with both prompt and promptAsync
|
||||
const promptMock = mock(() => Promise.resolve())
|
||||
const promptAsyncMock = mock(() => Promise.resolve())
|
||||
const client = { session: { prompt: promptMock, promptAsync: promptAsyncMock } }
|
||||
|
||||
// when calling promptSyncWithModelSuggestionRetry
|
||||
await promptSyncWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
})
|
||||
|
||||
// then should call prompt (sync), NOT promptAsync
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("should retry with suggested model on ProviderModelNotFoundError", async () => {
|
||||
// given a client that fails first with model-not-found, then succeeds
|
||||
const promptMock = mock()
|
||||
.mockRejectedValueOnce({
|
||||
name: "ProviderModelNotFoundError",
|
||||
data: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonet-4",
|
||||
suggestions: ["claude-sonnet-4"],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
// when calling promptSyncWithModelSuggestionRetry
|
||||
await promptSyncWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "anthropic", modelID: "claude-sonet-4" },
|
||||
},
|
||||
})
|
||||
|
||||
// then should call prompt twice (original + retry with suggestion)
|
||||
expect(promptMock).toHaveBeenCalledTimes(2)
|
||||
const retryCall = promptMock.mock.calls[1][0]
|
||||
expect(retryCall.body.model).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4",
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw original error when no suggestion available", async () => {
|
||||
// given a client that fails with a non-model error
|
||||
const originalError = new Error("Connection refused")
|
||||
const promptMock = mock().mockRejectedValueOnce(originalError)
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
// when calling promptSyncWithModelSuggestionRetry
|
||||
// then should throw the original error
|
||||
await expect(
|
||||
promptSyncWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("Connection refused")
|
||||
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should throw when model-not-found but no model in original request", async () => {
|
||||
// given a client that fails with model error but no model in body
|
||||
const promptMock = mock().mockRejectedValueOnce({
|
||||
name: "ProviderModelNotFoundError",
|
||||
data: {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonet-4",
|
||||
suggestions: ["claude-sonnet-4"],
|
||||
},
|
||||
})
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
// when calling without model in body
|
||||
// then should throw (cannot retry without original model)
|
||||
await expect(
|
||||
promptSyncWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
body: {
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should pass all body fields through to prompt", async () => {
|
||||
// given a client where prompt succeeds
|
||||
const promptMock = mock().mockResolvedValueOnce(undefined)
|
||||
const client = { session: { prompt: promptMock } }
|
||||
|
||||
// when calling with additional body fields
|
||||
await promptSyncWithModelSuggestionRetry(client as any, {
|
||||
path: { id: "session-1" },
|
||||
body: {
|
||||
agent: "multimodal-looker",
|
||||
tools: { task: false },
|
||||
parts: [{ type: "text", text: "analyze" }],
|
||||
model: { providerID: "google", modelID: "gemini-3-flash" },
|
||||
variant: "max",
|
||||
},
|
||||
})
|
||||
|
||||
// then call should pass all fields through unchanged
|
||||
const call = promptMock.mock.calls[0][0]
|
||||
expect(call.body.agent).toBe("multimodal-looker")
|
||||
expect(call.body.tools).toEqual({ task: false })
|
||||
expect(call.body.variant).toBe("max")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,3 +88,42 @@ export async function promptWithModelSuggestionRetry(
|
||||
// model errors happen asynchronously server-side and cannot be caught here
|
||||
await client.session.promptAsync(args as Parameters<typeof client.session.promptAsync>[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous variant of promptWithModelSuggestionRetry.
|
||||
*
|
||||
* Uses `session.prompt` (blocking HTTP call that waits for the LLM response)
|
||||
* instead of `promptAsync` (fire-and-forget HTTP 204).
|
||||
*
|
||||
* Required by callers that need the response to be available immediately after
|
||||
* the call returns — e.g. look_at, which reads session messages right away.
|
||||
*/
|
||||
export async function promptSyncWithModelSuggestionRetry(
|
||||
client: Client,
|
||||
args: PromptArgs,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await client.session.prompt(args as Parameters<typeof client.session.prompt>[0])
|
||||
} catch (error) {
|
||||
const suggestion = parseModelSuggestion(error)
|
||||
if (!suggestion || !args.body.model) {
|
||||
throw error
|
||||
}
|
||||
|
||||
log("[model-suggestion-retry] Model not found, retrying with suggestion", {
|
||||
original: `${suggestion.providerID}/${suggestion.modelID}`,
|
||||
suggested: suggestion.suggestion,
|
||||
})
|
||||
|
||||
await client.session.prompt({
|
||||
...args,
|
||||
body: {
|
||||
...args.body,
|
||||
model: {
|
||||
providerID: suggestion.providerID,
|
||||
modelID: suggestion.suggestion,
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof client.session.prompt>[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,7 +538,7 @@ export function buildPlanAgentSystemPrepend(
|
||||
* List of agent names that should be treated as plan agents.
|
||||
* Case-insensitive matching is used.
|
||||
*/
|
||||
export const PLAN_AGENT_NAMES = ["plan", "prometheus", "planner"]
|
||||
export const PLAN_AGENT_NAMES = ["plan", "planner"]
|
||||
|
||||
/**
|
||||
* Check if the given agent name is a plan agent.
|
||||
|
||||
@@ -12,7 +12,7 @@ import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader
|
||||
import { discoverSkills } from "../../features/opencode-skill-loader"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log, getAgentToolRestrictions, resolveModelPipeline, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { log, getAgentToolRestrictions, resolveModelPipeline, promptWithModelSuggestionRetry, promptSyncWithModelSuggestionRetry } from "../../shared"
|
||||
import { fetchAvailableModels, isModelAvailable } from "../../shared/model-availability"
|
||||
import { readConnectedProvidersCache } from "../../shared/connected-providers-cache"
|
||||
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||
@@ -39,7 +39,7 @@ export interface ParentContext {
|
||||
}
|
||||
|
||||
interface SessionMessage {
|
||||
info?: { role?: string; time?: { created?: number }; agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
|
||||
info?: { role?: string; time?: { created?: number }; agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string; variant?: string }
|
||||
parts?: Array<{ type?: string; text?: string }>
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ export async function executeSyncContinuation(
|
||||
try {
|
||||
let resumeAgent: string | undefined
|
||||
let resumeModel: { providerID: string; modelID: string } | undefined
|
||||
let resumeVariant: string | undefined
|
||||
|
||||
try {
|
||||
const messagesResp = await client.session.messages({ path: { id: args.session_id! } })
|
||||
@@ -199,6 +200,7 @@ export async function executeSyncContinuation(
|
||||
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
||||
resumeAgent = info.agent
|
||||
resumeModel = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
|
||||
resumeVariant = info.variant
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -209,22 +211,24 @@ export async function executeSyncContinuation(
|
||||
resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
|
||||
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
|
||||
: undefined
|
||||
resumeVariant = resumeMessage?.model?.variant
|
||||
}
|
||||
|
||||
await (client.session as any).promptAsync({
|
||||
path: { id: args.session_id! },
|
||||
body: {
|
||||
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
|
||||
...(resumeModel !== undefined ? { model: resumeModel } : {}),
|
||||
tools: {
|
||||
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
},
|
||||
})
|
||||
await promptSyncWithModelSuggestionRetry(client, {
|
||||
path: { id: args.session_id! },
|
||||
body: {
|
||||
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
|
||||
...(resumeModel !== undefined ? { model: resumeModel } : {}),
|
||||
...(resumeVariant !== undefined ? { variant: resumeVariant } : {}),
|
||||
tools: {
|
||||
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
|
||||
task: false,
|
||||
call_omo_agent: true,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
},
|
||||
})
|
||||
} catch (promptError) {
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
@@ -233,30 +237,6 @@ export async function executeSyncContinuation(
|
||||
return `Failed to send continuation prompt: ${errorMessage}\n\nSession ID: ${args.session_id}`
|
||||
}
|
||||
|
||||
const timing = getTimingConfig()
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
|
||||
while (Date.now() - pollStart < 60000) {
|
||||
await new Promise(resolve => setTimeout(resolve, timing.POLL_INTERVAL_MS))
|
||||
|
||||
const elapsed = Date.now() - pollStart
|
||||
if (elapsed < timing.SESSION_CONTINUATION_STABILITY_MS) continue
|
||||
|
||||
const messagesCheck = await client.session.messages({ path: { id: args.session_id! } })
|
||||
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
|
||||
const currentMsgCount = msgs.length
|
||||
|
||||
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
|
||||
stablePolls++
|
||||
if (stablePolls >= timing.STABILITY_POLLS_REQUIRED) break
|
||||
} else {
|
||||
stablePolls = 0
|
||||
lastMsgCount = currentMsgCount
|
||||
}
|
||||
}
|
||||
|
||||
const messagesResult = await client.session.messages({
|
||||
path: { id: args.session_id! },
|
||||
})
|
||||
@@ -621,7 +601,7 @@ export async function executeSyncTask(
|
||||
|
||||
try {
|
||||
const allowTask = isPlanAgent(agentToUse)
|
||||
await promptWithModelSuggestionRetry(client, {
|
||||
await promptSyncWithModelSuggestionRetry(client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: agentToUse,
|
||||
@@ -659,70 +639,6 @@ export async function executeSyncTask(
|
||||
})
|
||||
}
|
||||
|
||||
const syncTiming = getTimingConfig()
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
let pollCount = 0
|
||||
|
||||
log("[task] Starting poll loop", { sessionID, agentToUse })
|
||||
|
||||
while (Date.now() - pollStart < syncTiming.MAX_POLL_TIME_MS) {
|
||||
if (ctx.abort?.aborted) {
|
||||
log("[task] Aborted by user", { sessionID })
|
||||
if (toastManager && taskId) toastManager.removeTask(taskId)
|
||||
return `Task aborted.\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, syncTiming.POLL_INTERVAL_MS))
|
||||
pollCount++
|
||||
|
||||
const statusResult = await client.session.status()
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
|
||||
if (pollCount % 10 === 0) {
|
||||
log("[task] Poll status", {
|
||||
sessionID,
|
||||
pollCount,
|
||||
elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s",
|
||||
sessionStatus: sessionStatus?.type ?? "not_in_status",
|
||||
stablePolls,
|
||||
lastMsgCount,
|
||||
})
|
||||
}
|
||||
|
||||
if (sessionStatus && sessionStatus.type !== "idle") {
|
||||
stablePolls = 0
|
||||
lastMsgCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - pollStart
|
||||
if (elapsed < syncTiming.MIN_STABILITY_TIME_MS) {
|
||||
continue
|
||||
}
|
||||
|
||||
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
|
||||
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
|
||||
const currentMsgCount = msgs.length
|
||||
|
||||
if (currentMsgCount === lastMsgCount) {
|
||||
stablePolls++
|
||||
if (stablePolls >= syncTiming.STABILITY_POLLS_REQUIRED) {
|
||||
log("[task] Poll complete - messages stable", { sessionID, pollCount, currentMsgCount })
|
||||
break
|
||||
}
|
||||
} else {
|
||||
stablePolls = 0
|
||||
lastMsgCount = currentMsgCount
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - pollStart >= syncTiming.MAX_POLL_TIME_MS) {
|
||||
log("[task] Poll timeout reached", { sessionID, pollCount, lastMsgCount, stablePolls })
|
||||
}
|
||||
|
||||
const messagesResult = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
@@ -963,7 +879,7 @@ Sisyphus-Junior is spawned automatically when you specify a category. Pick the a
|
||||
return {
|
||||
agentToUse: "",
|
||||
categoryModel: undefined,
|
||||
error: `You are prometheus. You cannot delegate to prometheus via task.
|
||||
error: `You are the plan agent. You cannot delegate to plan via task.
|
||||
|
||||
Create the work plan directly - that's your job as the planning agent.`,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
|
||||
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES } from "./constants"
|
||||
import { resolveCategoryConfig } from "./tools"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
@@ -135,12 +135,12 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'prometheus'", () => {
|
||||
// given / #when
|
||||
test("returns false for 'prometheus' (decoupled from plan)", () => {
|
||||
//#given / #when
|
||||
const result = isPlanAgent("prometheus")
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
//#then - prometheus is NOT a plan agent
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for 'planner'", () => {
|
||||
@@ -159,12 +159,12 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for case-insensitive match 'Prometheus'", () => {
|
||||
// given / #when
|
||||
test("returns false for case-insensitive match 'Prometheus' (decoupled from plan)", () => {
|
||||
//#given / #when
|
||||
const result = isPlanAgent("Prometheus")
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
//#then - Prometheus is NOT a plan agent
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for 'oracle'", () => {
|
||||
@@ -199,11 +199,11 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("PLAN_AGENT_NAMES contains expected values", () => {
|
||||
// given / #when / #then
|
||||
test("PLAN_AGENT_NAMES contains only plan and planner (not prometheus)", () => {
|
||||
//#given / #when / #then
|
||||
expect(PLAN_AGENT_NAMES).toContain("plan")
|
||||
expect(PLAN_AGENT_NAMES).toContain("prometheus")
|
||||
expect(PLAN_AGENT_NAMES).toContain("planner")
|
||||
expect(PLAN_AGENT_NAMES).not.toContain("prometheus")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1055,6 +1055,75 @@ describe("sisyphus-task", () => {
|
||||
expect(result).not.toContain("Background task continued")
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("sync continuation preserves variant from previous session message", async () => {
|
||||
//#given a session with a previous message that has variant "max"
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const promptMock = mock(async (input: any) => {
|
||||
return { data: {} }
|
||||
})
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
prompt: promptMock,
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "sisyphus-junior",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
variant: "max",
|
||||
time: { created: Date.now() },
|
||||
},
|
||||
parts: [{ type: "text", text: "previous message" }],
|
||||
},
|
||||
{
|
||||
info: { role: "assistant", time: { created: Date.now() + 1 } },
|
||||
parts: [{ type: "text", text: "Completed." }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
app: {
|
||||
agents: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: { resume: async () => ({ id: "task-var", sessionID: "ses_var_test", description: "Variant test", agent: "sisyphus-junior", status: "running" }) },
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
//#when continuing the session
|
||||
await tool.execute(
|
||||
{
|
||||
description: "Continue with variant",
|
||||
prompt: "Continue the task",
|
||||
session_id: "ses_var_test",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
//#then prompt should include variant from previous message
|
||||
expect(promptMock).toHaveBeenCalled()
|
||||
const callArgs = promptMock.mock.calls[0][0]
|
||||
expect(callArgs.body.variant).toBe("max")
|
||||
expect(callArgs.body.agent).toBe("sisyphus-junior")
|
||||
expect(callArgs.body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("session_id with background=true should return immediately without waiting", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
@@ -2258,68 +2327,36 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
|
||||
})
|
||||
|
||||
test("prepends plan agent system prompt when agentName is 'prometheus'", () => {
|
||||
// given
|
||||
test("does not prepend plan agent prompt for prometheus agent", () => {
|
||||
//#given - prometheus is NOT a plan agent (decoupled)
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const { buildPlanAgentSystemPrepend } = require("./constants")
|
||||
const skillContent = "You are a strategic planner"
|
||||
|
||||
const availableCategories = [
|
||||
{
|
||||
name: "ultrabrain",
|
||||
description: "Complex architecture, deep logical reasoning",
|
||||
model: "openai/gpt-5.3-codex",
|
||||
},
|
||||
]
|
||||
const availableSkills = [
|
||||
{
|
||||
name: "git-master",
|
||||
description: "Atomic commits, git operations.",
|
||||
location: "plugin",
|
||||
},
|
||||
]
|
||||
|
||||
// when
|
||||
//#when
|
||||
const result = buildSystemContent({
|
||||
skillContent,
|
||||
agentName: "prometheus",
|
||||
availableCategories,
|
||||
availableSkills,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toContain("<system>")
|
||||
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
|
||||
//#then - prometheus should NOT get plan agent system prepend
|
||||
expect(result).toBe(skillContent)
|
||||
expect(result).not.toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
|
||||
})
|
||||
|
||||
test("prepends plan agent system prompt when agentName is 'Prometheus' (case insensitive)", () => {
|
||||
// given
|
||||
test("does not prepend plan agent prompt for Prometheus (case insensitive)", () => {
|
||||
//#given - Prometheus (capitalized) is NOT a plan agent
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const { buildPlanAgentSystemPrepend } = require("./constants")
|
||||
const skillContent = "You are a strategic planner"
|
||||
|
||||
const availableCategories = [
|
||||
{
|
||||
name: "quick",
|
||||
description: "Trivial tasks",
|
||||
model: "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
]
|
||||
const availableSkills = [
|
||||
{
|
||||
name: "dev-browser",
|
||||
description: "Persistent browser state automation.",
|
||||
location: "plugin",
|
||||
},
|
||||
]
|
||||
|
||||
// when
|
||||
//#when
|
||||
const result = buildSystemContent({
|
||||
skillContent,
|
||||
agentName: "Prometheus",
|
||||
availableCategories,
|
||||
availableSkills,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toContain("<system>")
|
||||
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
|
||||
//#then
|
||||
expect(result).toBe(skillContent)
|
||||
expect(result).not.toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
|
||||
})
|
||||
|
||||
test("combines plan agent prepend with skill content", () => {
|
||||
@@ -2565,14 +2602,14 @@ describe("sisyphus-task", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("prometheus self-delegation block", () => {
|
||||
test("prometheus cannot delegate to prometheus - returns error with guidance", async () => {
|
||||
// given - current agent is prometheus
|
||||
describe("plan agent self-delegation block", () => {
|
||||
test("plan agent cannot delegate to plan - returns error with guidance", async () => {
|
||||
//#given - current agent is plan
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
||||
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
@@ -2592,44 +2629,44 @@ describe("sisyphus-task", () => {
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "prometheus",
|
||||
agent: "plan",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// when - prometheus tries to delegate to prometheus
|
||||
//#when - plan agent tries to delegate to plan
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test self-delegation block",
|
||||
prompt: "Create a plan",
|
||||
subagent_type: "prometheus",
|
||||
subagent_type: "plan",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// then - should return error telling prometheus to create plan directly
|
||||
expect(result).toContain("prometheus")
|
||||
//#then - should return error telling plan agent to create plan directly
|
||||
expect(result).toContain("plan agent")
|
||||
expect(result).toContain("directly")
|
||||
})
|
||||
|
||||
test("non-prometheus agent CAN delegate to prometheus - proceeds normally", async () => {
|
||||
// given - current agent is sisyphus
|
||||
test("prometheus is NOT a plan agent - can delegate to plan normally", async () => {
|
||||
//#given - current agent is prometheus (no longer treated as plan agent)
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
||||
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_prometheus_allowed" } }),
|
||||
create: async () => ({ data: { id: "ses_plan_from_prometheus" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created successfully" }] }]
|
||||
}),
|
||||
status: async () => ({ data: { "ses_prometheus_allowed": { type: "idle" } } }),
|
||||
status: async () => ({ data: { "ses_plan_from_prometheus": { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2641,34 +2678,34 @@ describe("sisyphus-task", () => {
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
agent: "prometheus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// when - sisyphus delegates to prometheus
|
||||
//#when - prometheus delegates to plan (should work now)
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test prometheus delegation from non-prometheus agent",
|
||||
description: "Test plan delegation from prometheus",
|
||||
prompt: "Create a plan",
|
||||
subagent_type: "prometheus",
|
||||
subagent_type: "plan",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// then - should proceed normally
|
||||
//#then - should proceed normally (prometheus is not plan agent)
|
||||
expect(result).not.toContain("Cannot delegate")
|
||||
expect(result).toContain("Plan created successfully")
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("case-insensitive: Prometheus (capitalized) cannot delegate to prometheus", async () => {
|
||||
// given - current agent is Prometheus (capitalized)
|
||||
test("planner agent self-delegation is also blocked", async () => {
|
||||
//#given - current agent is planner
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
||||
app: { agents: async () => ({ data: [{ name: "planner", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
@@ -2688,24 +2725,24 @@ describe("sisyphus-task", () => {
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Prometheus",
|
||||
agent: "planner",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// when - Prometheus tries to delegate to prometheus
|
||||
//#when - planner tries to delegate to plan
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test case-insensitive block",
|
||||
description: "Test planner self-delegation block",
|
||||
prompt: "Create a plan",
|
||||
subagent_type: "prometheus",
|
||||
subagent_type: "plan",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// then - should still return error
|
||||
expect(result).toContain("prometheus")
|
||||
//#then - should return error (planner is a plan agent alias)
|
||||
expect(result).toContain("plan agent")
|
||||
expect(result).toContain("directly")
|
||||
})
|
||||
})
|
||||
@@ -2903,9 +2940,9 @@ describe("sisyphus-task", () => {
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
|
||||
describe("prometheus subagent task permission", () => {
|
||||
test("prometheus subagent should have task permission enabled", async () => {
|
||||
// given - sisyphus delegates to prometheus
|
||||
describe("subagent task permission", () => {
|
||||
test("plan subagent should have task permission enabled", async () => {
|
||||
//#given - sisyphus delegates to plan agent
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptBody: any
|
||||
|
||||
@@ -2917,17 +2954,17 @@ describe("sisyphus-task", () => {
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
||||
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_prometheus_delegate" } }),
|
||||
create: async () => ({ data: { id: "ses_plan_delegate" } }),
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }]
|
||||
}),
|
||||
status: async () => ({ data: { "ses_prometheus_delegate": { type: "idle" } } }),
|
||||
status: async () => ({ data: { "ses_plan_delegate": { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2943,10 +2980,65 @@ describe("sisyphus-task", () => {
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// when - sisyphus delegates to prometheus
|
||||
//#when - sisyphus delegates to plan
|
||||
await tool.execute(
|
||||
{
|
||||
description: "Test prometheus task permission",
|
||||
description: "Test plan task permission",
|
||||
prompt: "Create a plan",
|
||||
subagent_type: "plan",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
//#then - plan agent should have task permission
|
||||
expect(promptBody.tools.task).toBe(true)
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("prometheus subagent should NOT have task permission (decoupled from plan)", async () => {
|
||||
//#given - sisyphus delegates to prometheus (no longer a plan agent)
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptBody: any
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
|
||||
const promptMock = async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_prometheus_no_task" } }),
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }]
|
||||
}),
|
||||
status: async () => ({ data: { "ses_prometheus_no_task": { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
//#when - sisyphus delegates to prometheus
|
||||
await tool.execute(
|
||||
{
|
||||
description: "Test prometheus no task permission",
|
||||
prompt: "Create a plan",
|
||||
subagent_type: "prometheus",
|
||||
run_in_background: false,
|
||||
@@ -2955,12 +3047,12 @@ describe("sisyphus-task", () => {
|
||||
toolContext
|
||||
)
|
||||
|
||||
// then - prometheus should have task permission
|
||||
expect(promptBody.tools.task).toBe(true)
|
||||
//#then - prometheus should NOT have task permission (it's not a plan agent)
|
||||
expect(promptBody.tools.task).toBe(false)
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("non-prometheus subagent should NOT have task permission", async () => {
|
||||
// given - sisyphus delegates to oracle (non-prometheus)
|
||||
test("non-plan subagent should NOT have task permission", async () => {
|
||||
//#given - sisyphus delegates to oracle (non-plan)
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptBody: any
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url"
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||
import type { LookAtArgs } from "./types"
|
||||
import { log, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
|
||||
|
||||
interface LookAtArgsWithAlias extends LookAtArgs {
|
||||
path?: string
|
||||
@@ -223,7 +223,7 @@ Original error: ${createResult.error}`
|
||||
|
||||
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
||||
try {
|
||||
await promptWithModelSuggestionRetry(ctx.client, {
|
||||
await promptSyncWithModelSuggestionRetry(ctx.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: MULTIMODAL_LOOKER_AGENT,
|
||||
|
||||
Reference in New Issue
Block a user