fix: resolve publish blockers for v3.7.4→v3.8.0 release
- Fix #1991 crash: optional chaining for task-history sessionID access - Fix #1992 think-mode: add antigravity entries to HIGH_VARIANT_MAP - Fix #1949 Copilot premium misattribution: use createInternalAgentTextPart - Fix #1982 load_skills: pass directory to discoverSkills for project-level skills - Fix command priority: sort scopePriority before .find(), project-first return - Fix Google provider transform: apply in userFallbackModels path - Fix ralph-loop TUI: optional chaining for event handler - Fix runtime-fallback: unify dual fallback engines, remove HTTP 400 from retry, fix pendingFallbackModel stuck state, add priority gate to skip model-fallback when runtime-fallback is active - Fix Prometheus task system: exempt from todowrite/todoread deny - Fix background_output: default full_session to true - Remove orphan hooks: hashline-edit-diff-enhancer (redundant with hashline_edit built-in diff), task-reminder (dead code) - Remove orphan config entries: 3 stale hook names from Zod schema - Fix disabled_hooks schema: accept arbitrary strings for forward compatibility - Register json-error-recovery hook in tool-guard pipeline - Add disabled_hooks gating for question-label-truncator, task-resume-info, claude-code-hooks - Update test expectations to match new behavior
This commit is contained in:
parent
ee5df1683e
commit
fe415319e5
@ -55,59 +55,7 @@
|
|||||||
"disabled_hooks": {
|
"disabled_hooks": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"enum": [
|
|
||||||
"todo-continuation-enforcer",
|
|
||||||
"context-window-monitor",
|
|
||||||
"session-recovery",
|
|
||||||
"session-notification",
|
|
||||||
"comment-checker",
|
|
||||||
"grep-output-truncator",
|
|
||||||
"tool-output-truncator",
|
|
||||||
"question-label-truncator",
|
|
||||||
"directory-agents-injector",
|
|
||||||
"directory-readme-injector",
|
|
||||||
"empty-task-response-detector",
|
|
||||||
"think-mode",
|
|
||||||
"model-fallback",
|
|
||||||
"anthropic-context-window-limit-recovery",
|
|
||||||
"preemptive-compaction",
|
|
||||||
"rules-injector",
|
|
||||||
"background-notification",
|
|
||||||
"auto-update-checker",
|
|
||||||
"startup-toast",
|
|
||||||
"keyword-detector",
|
|
||||||
"agent-usage-reminder",
|
|
||||||
"non-interactive-env",
|
|
||||||
"interactive-bash-session",
|
|
||||||
"thinking-block-validator",
|
|
||||||
"beast-mode-system",
|
|
||||||
"ralph-loop",
|
|
||||||
"category-skill-reminder",
|
|
||||||
"compaction-context-injector",
|
|
||||||
"compaction-todo-preserver",
|
|
||||||
"claude-code-hooks",
|
|
||||||
"auto-slash-command",
|
|
||||||
"edit-error-recovery",
|
|
||||||
"json-error-recovery",
|
|
||||||
"delegate-task-retry",
|
|
||||||
"prometheus-md-only",
|
|
||||||
"sisyphus-junior-notepad",
|
|
||||||
"no-sisyphus-gpt",
|
|
||||||
"no-hephaestus-non-gpt",
|
|
||||||
"start-work",
|
|
||||||
"atlas",
|
|
||||||
"unstable-agent-babysitter",
|
|
||||||
"task-reminder",
|
|
||||||
"task-resume-info",
|
|
||||||
"stop-continuation-guard",
|
|
||||||
"tasks-todowrite-disabler",
|
|
||||||
"runtime-fallback",
|
|
||||||
"write-existing-file-guard",
|
|
||||||
"anthropic-effort",
|
|
||||||
"hashline-read-enhancer",
|
|
||||||
"hashline-edit-diff-enhancer"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"disabled_commands": {
|
"disabled_commands": {
|
||||||
|
|||||||
@ -6,7 +6,6 @@ export const HookNameSchema = z.enum([
|
|||||||
"session-recovery",
|
"session-recovery",
|
||||||
"session-notification",
|
"session-notification",
|
||||||
"comment-checker",
|
"comment-checker",
|
||||||
"grep-output-truncator",
|
|
||||||
"tool-output-truncator",
|
"tool-output-truncator",
|
||||||
"question-label-truncator",
|
"question-label-truncator",
|
||||||
"directory-agents-injector",
|
"directory-agents-injector",
|
||||||
@ -44,7 +43,6 @@ export const HookNameSchema = z.enum([
|
|||||||
"start-work",
|
"start-work",
|
||||||
"atlas",
|
"atlas",
|
||||||
"unstable-agent-babysitter",
|
"unstable-agent-babysitter",
|
||||||
"task-reminder",
|
|
||||||
"task-resume-info",
|
"task-resume-info",
|
||||||
"stop-continuation-guard",
|
"stop-continuation-guard",
|
||||||
"tasks-todowrite-disabler",
|
"tasks-todowrite-disabler",
|
||||||
@ -52,7 +50,6 @@ export const HookNameSchema = z.enum([
|
|||||||
"write-existing-file-guard",
|
"write-existing-file-guard",
|
||||||
"anthropic-effort",
|
"anthropic-effort",
|
||||||
"hashline-read-enhancer",
|
"hashline-read-enhancer",
|
||||||
"hashline-edit-diff-enhancer",
|
|
||||||
])
|
])
|
||||||
|
|
||||||
export type HookName = z.infer<typeof HookNameSchema>
|
export type HookName = z.infer<typeof HookNameSchema>
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import { CommentCheckerConfigSchema } from "./comment-checker"
|
|||||||
import { BuiltinCommandNameSchema } from "./commands"
|
import { BuiltinCommandNameSchema } from "./commands"
|
||||||
import { ExperimentalConfigSchema } from "./experimental"
|
import { ExperimentalConfigSchema } from "./experimental"
|
||||||
import { GitMasterConfigSchema } from "./git-master"
|
import { GitMasterConfigSchema } from "./git-master"
|
||||||
import { HookNameSchema } from "./hooks"
|
|
||||||
import { NotificationConfigSchema } from "./notification"
|
import { NotificationConfigSchema } from "./notification"
|
||||||
import { RalphLoopConfigSchema } from "./ralph-loop"
|
import { RalphLoopConfigSchema } from "./ralph-loop"
|
||||||
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
|
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
|
||||||
@ -30,7 +29,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
|||||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||||
disabled_hooks: z.array(HookNameSchema).optional(),
|
disabled_hooks: z.array(z.string()).optional(),
|
||||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||||
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
|
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
|
||||||
disabled_tools: z.array(z.string()).optional(),
|
disabled_tools: z.array(z.string()).optional(),
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
hasMoreFallbacks,
|
hasMoreFallbacks,
|
||||||
selectFallbackProvider,
|
selectFallbackProvider,
|
||||||
} from "../../shared/model-error-classifier"
|
} from "../../shared/model-error-classifier"
|
||||||
|
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||||
import {
|
import {
|
||||||
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
|
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
|
||||||
DEFAULT_STALE_TIMEOUT_MS,
|
DEFAULT_STALE_TIMEOUT_MS,
|
||||||
@ -364,7 +365,7 @@ export class BackgroundManager {
|
|||||||
setSessionTools(sessionID, tools)
|
setSessionTools(sessionID, tools)
|
||||||
return tools
|
return tools
|
||||||
})(),
|
})(),
|
||||||
parts: [{ type: "text", text: input.prompt }],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
},
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
log("[background-agent] promptAsync error:", error)
|
log("[background-agent] promptAsync error:", error)
|
||||||
@ -637,7 +638,7 @@ export class BackgroundManager {
|
|||||||
setSessionTools(existingTask.sessionID!, tools)
|
setSessionTools(existingTask.sessionID!, tools)
|
||||||
return tools
|
return tools
|
||||||
})(),
|
})(),
|
||||||
parts: [{ type: "text", text: input.prompt }],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
},
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
log("[background-agent] resume prompt error:", error)
|
log("[background-agent] resume prompt error:", error)
|
||||||
@ -1006,9 +1007,10 @@ export class BackgroundManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
task.attemptCount = selectedAttemptCount
|
task.attemptCount = selectedAttemptCount
|
||||||
|
const transformedModelId = transformModelForProvider(providerID, nextFallback.model)
|
||||||
task.model = {
|
task.model = {
|
||||||
providerID,
|
providerID,
|
||||||
modelID: nextFallback.model,
|
modelID: transformedModelId,
|
||||||
variant: nextFallback.variant,
|
variant: nextFallback.variant,
|
||||||
}
|
}
|
||||||
task.status = "pending"
|
task.status = "pending"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
|
||||||
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
|
||||||
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
|
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
|
||||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
|
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
|
||||||
import { subagentSessions } from "../claude-code-session-state"
|
import { subagentSessions } from "../claude-code-session-state"
|
||||||
import { getTaskToastManager } from "../task-toast-manager"
|
import { getTaskToastManager } from "../task-toast-manager"
|
||||||
import { isInsideTmux } from "../../shared/tmux"
|
import { isInsideTmux } from "../../shared/tmux"
|
||||||
@ -146,7 +146,7 @@ export async function startTask(
|
|||||||
question: false,
|
question: false,
|
||||||
...getAgentToolRestrictions(input.agent),
|
...getAgentToolRestrictions(input.agent),
|
||||||
},
|
},
|
||||||
parts: [{ type: "text", text: input.prompt }],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
},
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
log("[background-agent] promptAsync error:", error)
|
log("[background-agent] promptAsync error:", error)
|
||||||
@ -230,7 +230,7 @@ export async function resumeTask(
|
|||||||
question: false,
|
question: false,
|
||||||
...getAgentToolRestrictions(task.agent),
|
...getAgentToolRestrictions(task.agent),
|
||||||
},
|
},
|
||||||
parts: [{ type: "text", text: input.prompt }],
|
parts: [createInternalAgentTextPart(input.prompt)],
|
||||||
},
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
log("[background-agent] resume prompt error:", error)
|
log("[background-agent] resume prompt error:", error)
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export class TaskHistory {
|
|||||||
if (list.length === 0) return null
|
if (list.length === 0) return null
|
||||||
|
|
||||||
const lines = list.map((e) => {
|
const lines = list.map((e) => {
|
||||||
const desc = e.description.replace(/[\n\r]+/g, " ").trim()
|
const desc = e.description?.replace(/[\n\r]+/g, " ").trim() ?? ""
|
||||||
const parts = [
|
const parts = [
|
||||||
`- **${e.agent}**`,
|
`- **${e.agent}**`,
|
||||||
e.category ? `[${e.category}]` : null,
|
e.category ? `[${e.category}]` : null,
|
||||||
|
|||||||
@ -1,106 +0,0 @@
|
|||||||
import { log } from "../../shared"
|
|
||||||
import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils"
|
|
||||||
|
|
||||||
interface HashlineEditDiffEnhancerConfig {
|
|
||||||
hashline_edit?: { enabled: boolean }
|
|
||||||
}
|
|
||||||
|
|
||||||
type BeforeInput = { tool: string; sessionID: string; callID: string }
|
|
||||||
type BeforeOutput = { args: Record<string, unknown> }
|
|
||||||
type AfterInput = { tool: string; sessionID: string; callID: string }
|
|
||||||
type AfterOutput = { title: string; output: string; metadata: Record<string, unknown> }
|
|
||||||
|
|
||||||
const STALE_TIMEOUT_MS = 5 * 60 * 1000
|
|
||||||
|
|
||||||
const pendingCaptures = new Map<string, { content: string; filePath: string; storedAt: number }>()
|
|
||||||
|
|
||||||
function makeKey(sessionID: string, callID: string): string {
|
|
||||||
return `${sessionID}:${callID}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupStaleEntries(): void {
|
|
||||||
const now = Date.now()
|
|
||||||
for (const [key, entry] of pendingCaptures) {
|
|
||||||
if (now - entry.storedAt > STALE_TIMEOUT_MS) {
|
|
||||||
pendingCaptures.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWriteTool(toolName: string): boolean {
|
|
||||||
return toolName.toLowerCase() === "write"
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractFilePath(args: Record<string, unknown>): string | undefined {
|
|
||||||
const path = args.path ?? args.filePath ?? args.file_path
|
|
||||||
return typeof path === "string" ? path : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureOldContent(filePath: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
const file = Bun.file(filePath)
|
|
||||||
if (await file.exists()) {
|
|
||||||
return await file.text()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
log("[hashline-edit-diff-enhancer] failed to read old content", { filePath })
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhancerConfig) {
|
|
||||||
const enabled = config.hashline_edit?.enabled ?? false
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tool.execute.before": async (input: BeforeInput, output: BeforeOutput) => {
|
|
||||||
if (!enabled || !isWriteTool(input.tool)) return
|
|
||||||
|
|
||||||
const filePath = extractFilePath(output.args)
|
|
||||||
if (!filePath) return
|
|
||||||
|
|
||||||
cleanupStaleEntries()
|
|
||||||
const oldContent = await captureOldContent(filePath)
|
|
||||||
pendingCaptures.set(makeKey(input.sessionID, input.callID), {
|
|
||||||
content: oldContent,
|
|
||||||
filePath,
|
|
||||||
storedAt: Date.now(),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
"tool.execute.after": async (input: AfterInput, output: AfterOutput) => {
|
|
||||||
if (!enabled || !isWriteTool(input.tool)) return
|
|
||||||
|
|
||||||
const key = makeKey(input.sessionID, input.callID)
|
|
||||||
const captured = pendingCaptures.get(key)
|
|
||||||
if (!captured) return
|
|
||||||
pendingCaptures.delete(key)
|
|
||||||
|
|
||||||
const { content: oldContent, filePath } = captured
|
|
||||||
|
|
||||||
let newContent: string
|
|
||||||
try {
|
|
||||||
newContent = await Bun.file(filePath).text()
|
|
||||||
} catch {
|
|
||||||
log("[hashline-edit-diff-enhancer] failed to read new content", { filePath })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { additions, deletions } = countLineDiffs(oldContent, newContent)
|
|
||||||
const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath)
|
|
||||||
|
|
||||||
output.metadata.filediff = {
|
|
||||||
file: filePath,
|
|
||||||
path: filePath,
|
|
||||||
before: oldContent,
|
|
||||||
after: newContent,
|
|
||||||
additions,
|
|
||||||
deletions,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TUI reads metadata.diff (unified diff string), not filediff object
|
|
||||||
output.metadata.diff = unifiedDiff
|
|
||||||
|
|
||||||
output.title = filePath
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,306 +0,0 @@
|
|||||||
import { describe, test, expect, beforeEach } from "bun:test"
|
|
||||||
import { createHashlineEditDiffEnhancerHook } from "./hook"
|
|
||||||
|
|
||||||
function makeInput(tool: string, callID = "call-1", sessionID = "ses-1") {
|
|
||||||
return { tool, sessionID, callID }
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeBeforeOutput(args: Record<string, unknown>) {
|
|
||||||
return { args }
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAfterOutput(overrides?: Partial<{ title: string; output: string; metadata: Record<string, unknown> }>) {
|
|
||||||
return {
|
|
||||||
title: overrides?.title ?? "",
|
|
||||||
output: overrides?.output ?? "Successfully applied 1 edit(s)",
|
|
||||||
metadata: overrides?.metadata ?? { truncated: false },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileDiffMetadata = {
|
|
||||||
file: string
|
|
||||||
path: string
|
|
||||||
before: string
|
|
||||||
after: string
|
|
||||||
additions: number
|
|
||||||
deletions: number
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("hashline-edit-diff-enhancer", () => {
|
|
||||||
let hook: ReturnType<typeof createHashlineEditDiffEnhancerHook>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
hook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: true } })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tool.execute.before", () => {
|
|
||||||
test("captures old file content for write tool", async () => {
|
|
||||||
const filePath = import.meta.dir + "/index.test.ts"
|
|
||||||
const input = makeInput("write")
|
|
||||||
const output = makeBeforeOutput({ path: filePath, edits: [] })
|
|
||||||
|
|
||||||
await hook["tool.execute.before"](input, output)
|
|
||||||
|
|
||||||
// given the hook ran without error, the old content should be stored internally
|
|
||||||
// we verify in the after hook test that it produces filediff
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores non-write tools", async () => {
|
|
||||||
const input = makeInput("read")
|
|
||||||
const output = makeBeforeOutput({ path: "/some/file.ts" })
|
|
||||||
|
|
||||||
// when - should not throw
|
|
||||||
await hook["tool.execute.before"](input, output)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("tool.execute.after", () => {
|
|
||||||
test("injects filediff metadata after write tool execution", async () => {
|
|
||||||
// given - a temp file that we can modify between before/after
|
|
||||||
const tmpDir = (await import("os")).tmpdir()
|
|
||||||
const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts`
|
|
||||||
const oldContent = "line 1\nline 2\nline 3\n"
|
|
||||||
await Bun.write(tmpFile, oldContent)
|
|
||||||
|
|
||||||
const input = makeInput("write", "call-diff-1")
|
|
||||||
const beforeOutput = makeBeforeOutput({ path: tmpFile, edits: [] })
|
|
||||||
|
|
||||||
// when - before hook captures old content
|
|
||||||
await hook["tool.execute.before"](input, beforeOutput)
|
|
||||||
|
|
||||||
// when - file is modified (simulating write execution)
|
|
||||||
const newContent = "line 1\nmodified line 2\nline 3\nnew line 4\n"
|
|
||||||
await Bun.write(tmpFile, newContent)
|
|
||||||
|
|
||||||
// when - after hook computes filediff
|
|
||||||
const afterOutput = makeAfterOutput()
|
|
||||||
await hook["tool.execute.after"](input, afterOutput)
|
|
||||||
|
|
||||||
// then - metadata should contain filediff
|
|
||||||
const filediff = afterOutput.metadata.filediff as {
|
|
||||||
file: string
|
|
||||||
path: string
|
|
||||||
before: string
|
|
||||||
after: string
|
|
||||||
additions: number
|
|
||||||
deletions: number
|
|
||||||
}
|
|
||||||
expect(filediff).toBeDefined()
|
|
||||||
expect(filediff.file).toBe(tmpFile)
|
|
||||||
expect(filediff.path).toBe(tmpFile)
|
|
||||||
expect(filediff.before).toBe(oldContent)
|
|
||||||
expect(filediff.after).toBe(newContent)
|
|
||||||
expect(filediff.additions).toBeGreaterThan(0)
|
|
||||||
expect(filediff.deletions).toBeGreaterThan(0)
|
|
||||||
|
|
||||||
// then - title should be set to the file path
|
|
||||||
expect(afterOutput.title).toBe(tmpFile)
|
|
||||||
|
|
||||||
// cleanup
|
|
||||||
await Bun.file(tmpFile).exists() && (await import("fs/promises")).unlink(tmpFile)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does nothing for non-write tools", async () => {
|
|
||||||
const input = makeInput("read", "call-other")
|
|
||||||
const afterOutput = makeAfterOutput()
|
|
||||||
const originalMetadata = { ...afterOutput.metadata }
|
|
||||||
|
|
||||||
await hook["tool.execute.after"](input, afterOutput)
|
|
||||||
|
|
||||||
// then - metadata unchanged
|
|
||||||
expect(afterOutput.metadata).toEqual(originalMetadata)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does nothing when no before capture exists", async () => {
|
|
||||||
// given - no before hook was called for this callID
|
|
||||||
const input = makeInput("write", "call-no-before")
|
|
||||||
const afterOutput = makeAfterOutput()
|
|
||||||
const originalMetadata = { ...afterOutput.metadata }
|
|
||||||
|
|
||||||
await hook["tool.execute.after"](input, afterOutput)
|
|
||||||
|
|
||||||
// then - metadata unchanged (no filediff injected)
|
|
||||||
expect(afterOutput.metadata.filediff).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cleans up stored content after consumption", async () => {
|
|
||||||
const tmpDir = (await import("os")).tmpdir()
|
|
||||||
const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts`
|
|
||||||
await Bun.write(tmpFile, "original")
|
|
||||||
|
|
||||||
const input = makeInput("write", "call-cleanup")
|
|
||||||
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
|
||||||
await Bun.write(tmpFile, "modified")
|
|
||||||
|
|
||||||
// when - first after call consumes
|
|
||||||
const afterOutput1 = makeAfterOutput()
|
|
||||||
await hook["tool.execute.after"](input, afterOutput1)
|
|
||||||
expect(afterOutput1.metadata.filediff).toBeDefined()
|
|
||||||
|
|
||||||
// when - second after call finds nothing
|
|
||||||
const afterOutput2 = makeAfterOutput()
|
|
||||||
await hook["tool.execute.after"](input, afterOutput2)
|
|
||||||
expect(afterOutput2.metadata.filediff).toBeUndefined()
|
|
||||||
|
|
||||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles file creation (empty old content)", async () => {
|
|
||||||
const tmpDir = (await import("os")).tmpdir()
|
|
||||||
const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts`
|
|
||||||
|
|
||||||
// given - file doesn't exist during before hook
|
|
||||||
const input = makeInput("write", "call-create")
|
|
||||||
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
|
||||||
|
|
||||||
// when - file created during write
|
|
||||||
await Bun.write(tmpFile, "new content\n")
|
|
||||||
|
|
||||||
const afterOutput = makeAfterOutput()
|
|
||||||
await hook["tool.execute.after"](input, afterOutput)
|
|
||||||
|
|
||||||
// then - filediff shows creation (before is empty)
|
|
||||||
const filediff = afterOutput.metadata.filediff as FileDiffMetadata
|
|
||||||
expect(filediff).toBeDefined()
|
|
||||||
expect(filediff.before).toBe("")
|
|
||||||
expect(filediff.after).toBe("new content\n")
|
|
||||||
expect(filediff.additions).toBeGreaterThan(0)
|
|
||||||
expect(filediff.deletions).toBe(0)
|
|
||||||
|
|
||||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("disabled config", () => {
|
|
||||||
test("does nothing when hashline_edit is disabled", async () => {
|
|
||||||
const disabledHook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: false } })
|
|
||||||
const tmpDir = (await import("os")).tmpdir()
|
|
||||||
const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts`
|
|
||||||
await Bun.write(tmpFile, "content")
|
|
||||||
|
|
||||||
const input = makeInput("write", "call-disabled")
|
|
||||||
await disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
|
||||||
await Bun.write(tmpFile, "modified")
|
|
||||||
|
|
||||||
const afterOutput = makeAfterOutput()
|
|
||||||
await disabledHook["tool.execute.after"](input, afterOutput)
|
|
||||||
|
|
||||||
// then - no filediff injected
|
|
||||||
expect(afterOutput.metadata.filediff).toBeUndefined()
|
|
||||||
|
|
||||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("write tool support", () => {
|
|
||||||
test("captures filediff for write tool (path arg)", async () => {
|
|
||||||
//#given - a temp file
|
|
||||||
const tmpDir = (await import("os")).tmpdir()
|
|
||||||
const tmpFile = `${tmpDir}/hashline-diff-write-${Date.now()}.ts`
|
|
||||||
const oldContent = "line 1\nline 2\n"
|
|
||||||
await Bun.write(tmpFile, oldContent)
|
|
||||||
|
|
||||||
const input = makeInput("write", "call-write-1")
|
|
||||||
const beforeOutput = makeBeforeOutput({ path: tmpFile })
|
|
||||||
|
|
||||||
//#when - before hook captures old content
|
|
||||||
await hook["tool.execute.before"](input, beforeOutput)
|
|
||||||
|
|
||||||
//#when - file is written
|
|
||||||
const newContent = "line 1\nmodified line 2\nnew line 3\n"
|
|
||||||
await Bun.write(tmpFile, newContent)
|
|
||||||
|
|
||||||
//#when - after hook computes filediff
|
|
||||||
const afterOutput = makeAfterOutput()
|
|
||||||
await hook["tool.execute.after"](input, afterOutput)
|
|
||||||
|
|
||||||
//#then - metadata should contain filediff
|
|
||||||
const filediff = afterOutput.metadata.filediff as { file: string; before: string; after: string; additions: number; deletions: number }
|
|
||||||
expect(filediff).toBeDefined()
|
|
||||||
expect(filediff.file).toBe(tmpFile)
|
|
||||||
expect(filediff.additions).toBeGreaterThan(0)
|
|
||||||
|
|
||||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("captures filediff for write tool (filePath arg)", async () => {
|
|
||||||
//#given
|
|
||||||
const tmpDir = (await import("os")).tmpdir()
|
|
||||||
const tmpFile = `${tmpDir}/hashline-diff-write-fp-${Date.now()}.ts`
|
|
||||||
await Bun.write(tmpFile, "original content\n")
|
|
||||||
|
|
||||||
const input = makeInput("write", "call-write-fp")
|
|
||||||
|
|
||||||
//#when - before hook uses filePath arg
|
|
||||||
await hook["tool.execute.before"](input, makeBeforeOutput({ filePath: tmpFile }))
|
|
||||||
await Bun.write(tmpFile, "new content\n")
|
|
||||||
|
|
||||||
const afterOutput = makeAfterOutput()
|
|
||||||
await hook["tool.execute.after"](input, afterOutput)
|
|
||||||
|
|
||||||
//#then
|
|
||||||
const filediff = afterOutput.metadata.filediff as FileDiffMetadata | undefined
|
|
||||||
expect(filediff).toBeDefined()
|
|
||||||
|
|
||||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("raw content in filediff", () => {
|
|
||||||
test("filediff.before and filediff.after are raw file content", async () => {
|
|
||||||
//#given - a temp file
|
|
||||||
const tmpDir = (await import("os")).tmpdir()
|
|
||||||
const tmpFile = `${tmpDir}/hashline-diff-format-${Date.now()}.ts`
|
|
||||||
const oldContent = "const x = 1\nconst y = 2\n"
|
|
||||||
await Bun.write(tmpFile, oldContent)
|
|
||||||
|
|
||||||
const input = makeInput("write", "call-hashline-format")
|
|
||||||
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
|
||||||
|
|
||||||
//#when - file is modified and after hook runs
|
|
||||||
const newContent = "const x = 1\nconst y = 42\n"
|
|
||||||
await Bun.write(tmpFile, newContent)
|
|
||||||
|
|
||||||
const afterOutput = makeAfterOutput()
|
|
||||||
await hook["tool.execute.after"](input, afterOutput)
|
|
||||||
|
|
||||||
//#then - before and after should be raw file content
|
|
||||||
const filediff = afterOutput.metadata.filediff as { before: string; after: string }
|
|
||||||
expect(filediff.before).toBe(oldContent)
|
|
||||||
expect(filediff.after).toBe(newContent)
|
|
||||||
|
|
||||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("TUI diff support (metadata.diff)", () => {
|
|
||||||
test("injects unified diff string in metadata.diff for write tool TUI", async () => {
|
|
||||||
//#given - a temp file
|
|
||||||
const tmpDir = (await import("os")).tmpdir()
|
|
||||||
const tmpFile = `${tmpDir}/hashline-tui-diff-${Date.now()}.ts`
|
|
||||||
const oldContent = "line 1\nline 2\nline 3\n"
|
|
||||||
await Bun.write(tmpFile, oldContent)
|
|
||||||
|
|
||||||
const input = makeInput("write", "call-tui-diff")
|
|
||||||
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
|
|
||||||
|
|
||||||
//#when - file is modified
|
|
||||||
const newContent = "line 1\nmodified line 2\nline 3\n"
|
|
||||||
await Bun.write(tmpFile, newContent)
|
|
||||||
|
|
||||||
const afterOutput = makeAfterOutput()
|
|
||||||
await hook["tool.execute.after"](input, afterOutput)
|
|
||||||
|
|
||||||
//#then - metadata.diff should be a unified diff string
|
|
||||||
expect(afterOutput.metadata.diff).toBeDefined()
|
|
||||||
expect(typeof afterOutput.metadata.diff).toBe("string")
|
|
||||||
expect(afterOutput.metadata.diff).toContain("---")
|
|
||||||
expect(afterOutput.metadata.diff).toContain("+++")
|
|
||||||
expect(afterOutput.metadata.diff).toContain("@@")
|
|
||||||
expect(afterOutput.metadata.diff).toContain("-line 2")
|
|
||||||
expect(afterOutput.metadata.diff).toContain("+modified line 2")
|
|
||||||
|
|
||||||
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { createHashlineEditDiffEnhancerHook } from "./hook"
|
|
||||||
@ -49,6 +49,5 @@ export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
|
|||||||
export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallbackOptions } from "./runtime-fallback";
|
export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallbackOptions } from "./runtime-fallback";
|
||||||
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
||||||
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||||
export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer";
|
|
||||||
export { createBeastModeSystemHook, BEAST_MODE_SYSTEM_PROMPT } from "./beast-mode-system";
|
export { createBeastModeSystemHook, BEAST_MODE_SYSTEM_PROMPT } from "./beast-mode-system";
|
||||||
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export function createRalphLoopEventHandler(
|
|||||||
|
|
||||||
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
|
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
|
||||||
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
|
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
|
||||||
await ctx.client.tui.showToast({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
|
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,11 +100,9 @@ export function createRalphLoopEventHandler(
|
|||||||
})
|
})
|
||||||
options.loopState.clear()
|
options.loopState.clear()
|
||||||
|
|
||||||
await ctx.client.tui
|
await ctx.client.tui?.showToast?.({
|
||||||
.showToast({
|
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
||||||
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
}).catch(() => {})
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,16 +118,14 @@ export function createRalphLoopEventHandler(
|
|||||||
max: newState.max_iterations,
|
max: newState.max_iterations,
|
||||||
})
|
})
|
||||||
|
|
||||||
await ctx.client.tui
|
await ctx.client.tui?.showToast?.({
|
||||||
.showToast({
|
body: {
|
||||||
body: {
|
title: "Ralph Loop",
|
||||||
title: "Ralph Loop",
|
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
variant: "info",
|
||||||
variant: "info",
|
duration: 2000,
|
||||||
duration: 2000,
|
},
|
||||||
},
|
}).catch(() => {})
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await continueIteration(ctx, newState, {
|
await continueIteration(ctx, newState, {
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
|||||||
|
|
||||||
const SESSION_TTL_MS = 30 * 60 * 1000
|
const SESSION_TTL_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
|
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): ReturnType<typeof globalThis.setTimeout>
|
||||||
|
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
|
||||||
|
|
||||||
export function createAutoRetryHelpers(deps: HookDeps) {
|
export function createAutoRetryHelpers(deps: HookDeps) {
|
||||||
const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps
|
const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps
|
||||||
|
|
||||||
@ -87,6 +90,10 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
const modelParts = newModel.split("/")
|
const modelParts = newModel.split("/")
|
||||||
if (modelParts.length < 2) {
|
if (modelParts.length < 2) {
|
||||||
log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`)
|
log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`)
|
||||||
|
const state = sessionStates.get(sessionID)
|
||||||
|
if (state?.pendingFallbackModel) {
|
||||||
|
state.pendingFallbackModel = undefined
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,6 +103,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionRetryInFlight.add(sessionID)
|
sessionRetryInFlight.add(sessionID)
|
||||||
|
let retryDispatched = false
|
||||||
try {
|
try {
|
||||||
const messagesResp = await ctx.client.session.messages({
|
const messagesResp = await ctx.client.session.messages({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
@ -136,6 +144,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
},
|
},
|
||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
})
|
})
|
||||||
|
retryDispatched = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
|
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
|
||||||
@ -144,6 +153,14 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
||||||
} finally {
|
} finally {
|
||||||
sessionRetryInFlight.delete(sessionID)
|
sessionRetryInFlight.delete(sessionID)
|
||||||
|
if (!retryDispatched) {
|
||||||
|
sessionAwaitingFallbackResult.delete(sessionID)
|
||||||
|
clearSessionFallbackTimeout(sessionID)
|
||||||
|
const state = sessionStates.get(sessionID)
|
||||||
|
if (state?.pendingFallbackModel) {
|
||||||
|
state.pendingFallbackModel = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config"
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
|
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
retry_on_errors: [400, 429, 503, 529],
|
retry_on_errors: [429, 500, 502, 503, 504],
|
||||||
max_fallback_attempts: 3,
|
max_fallback_attempts: 3,
|
||||||
cooldown_seconds: 60,
|
cooldown_seconds: 60,
|
||||||
timeout_seconds: 30,
|
timeout_seconds: 30,
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
|
||||||
|
|
||||||
const TASK_TOOLS = new Set([
|
|
||||||
"task",
|
|
||||||
"task_create",
|
|
||||||
"task_list",
|
|
||||||
"task_get",
|
|
||||||
"task_update",
|
|
||||||
"task_delete",
|
|
||||||
])
|
|
||||||
const TURN_THRESHOLD = 10
|
|
||||||
const REMINDER_MESSAGE = `
|
|
||||||
|
|
||||||
The task tools haven't been used recently. If you're tracking work, use task with action=create/update (or task_create/task_update) to record progress.`
|
|
||||||
|
|
||||||
interface ToolExecuteInput {
|
|
||||||
tool: string
|
|
||||||
sessionID: string
|
|
||||||
callID: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToolExecuteOutput {
|
|
||||||
output: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTaskReminderHook(_ctx: PluginInput) {
|
|
||||||
const sessionCounters = new Map<string, number>()
|
|
||||||
|
|
||||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
|
||||||
const { tool, sessionID } = input
|
|
||||||
const toolLower = tool.toLowerCase()
|
|
||||||
|
|
||||||
if (TASK_TOOLS.has(toolLower)) {
|
|
||||||
sessionCounters.set(sessionID, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentCount = sessionCounters.get(sessionID) ?? 0
|
|
||||||
const newCount = currentCount + 1
|
|
||||||
|
|
||||||
if (newCount >= TURN_THRESHOLD) {
|
|
||||||
output.output += REMINDER_MESSAGE
|
|
||||||
sessionCounters.set(sessionID, 0)
|
|
||||||
} else {
|
|
||||||
sessionCounters.set(sessionID, newCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tool.execute.after": toolExecuteAfter,
|
|
||||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
||||||
if (event.type !== "session.deleted") return
|
|
||||||
const props = event.properties as { info?: { id?: string } } | undefined
|
|
||||||
const sessionId = props?.info?.id
|
|
||||||
if (!sessionId) return
|
|
||||||
sessionCounters.delete(sessionId)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
import { describe, test, expect, beforeEach } from "bun:test"
|
|
||||||
import { createTaskReminderHook } from "./index"
|
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
|
||||||
|
|
||||||
const mockCtx = {} as PluginInput
|
|
||||||
|
|
||||||
describe("TaskReminderHook", () => {
|
|
||||||
let hook: ReturnType<typeof createTaskReminderHook>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
hook = createTaskReminderHook(mockCtx)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not inject reminder before 10 turns", async () => {
|
|
||||||
//#given
|
|
||||||
const sessionID = "test-session"
|
|
||||||
const output = { output: "Result" }
|
|
||||||
|
|
||||||
//#when
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID, callID: `call-${i}` },
|
|
||||||
output
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(output.output).not.toContain("task tools haven't been used")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("injects reminder after 10 turns without task tool usage", async () => {
|
|
||||||
//#given
|
|
||||||
const sessionID = "test-session"
|
|
||||||
const output = { output: "Result" }
|
|
||||||
|
|
||||||
//#when
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID, callID: `call-${i}` },
|
|
||||||
output
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(output.output).toContain("task tools haven't been used")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resets counter when task tool is used", async () => {
|
|
||||||
//#given
|
|
||||||
const sessionID = "test-session"
|
|
||||||
const output = { output: "Result" }
|
|
||||||
|
|
||||||
//#when
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID, callID: `call-${i}` },
|
|
||||||
output
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "task", sessionID, callID: "call-task" },
|
|
||||||
output
|
|
||||||
)
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID, callID: `call-after-${i}` },
|
|
||||||
output
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(output.output).not.toContain("task tools haven't been used")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resets counter after injecting reminder", async () => {
|
|
||||||
//#given
|
|
||||||
const sessionID = "test-session"
|
|
||||||
const output1 = { output: "Result 1" }
|
|
||||||
const output2 = { output: "Result 2" }
|
|
||||||
|
|
||||||
//#when
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID, callID: `call-1-${i}` },
|
|
||||||
output1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID, callID: `call-2-${i}` },
|
|
||||||
output2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(output1.output).toContain("task tools haven't been used")
|
|
||||||
expect(output2.output).not.toContain("task tools haven't been used")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("tracks separate counters per session", async () => {
|
|
||||||
//#given
|
|
||||||
const session1 = "session-1"
|
|
||||||
const session2 = "session-2"
|
|
||||||
const output1 = { output: "Result 1" }
|
|
||||||
const output2 = { output: "Result 2" }
|
|
||||||
|
|
||||||
//#when
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID: session1, callID: `call-${i}` },
|
|
||||||
output1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID: session2, callID: `call-${i}` },
|
|
||||||
output2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(output1.output).toContain("task tools haven't been used")
|
|
||||||
expect(output2.output).not.toContain("task tools haven't been used")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cleans up counters on session.deleted", async () => {
|
|
||||||
//#given
|
|
||||||
const sessionID = "test-session"
|
|
||||||
const output = { output: "Result" }
|
|
||||||
|
|
||||||
//#when
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID, callID: `call-${i}` },
|
|
||||||
output
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
|
|
||||||
const outputAfterDelete = { output: "Result" }
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
await hook["tool.execute.after"]?.(
|
|
||||||
{ tool: "bash", sessionID, callID: `call-after-${i}` },
|
|
||||||
outputAfterDelete
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
//#then
|
|
||||||
expect(outputAfterDelete.output).not.toContain("task tools haven't been used")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { createTaskReminderHook } from "./hook";
|
|
||||||
@ -109,6 +109,9 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
|
|||||||
"gpt-5-2": "gpt-5-2-high",
|
"gpt-5-2": "gpt-5-2-high",
|
||||||
"gpt-5-2-chat-latest": "gpt-5-2-chat-latest-high",
|
"gpt-5-2-chat-latest": "gpt-5-2-chat-latest-high",
|
||||||
"gpt-5-2-pro": "gpt-5-2-pro-high",
|
"gpt-5-2-pro": "gpt-5-2-pro-high",
|
||||||
|
// Antigravity (Google)
|
||||||
|
"antigravity-gemini-3-pro": "antigravity-gemini-3-pro-high",
|
||||||
|
"antigravity-gemini-3-flash": "antigravity-gemini-3-flash-high",
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALREADY_HIGH: Set<string> = new Set(Object.values(HIGH_VARIANT_MAP))
|
const ALREADY_HIGH: Set<string> = new Set(Object.values(HIGH_VARIANT_MAP))
|
||||||
|
|||||||
@ -532,13 +532,16 @@ describe("createWriteExistingFileGuardHook", () => {
|
|||||||
})
|
})
|
||||||
).resolves.toBeDefined()
|
).resolves.toBeDefined()
|
||||||
|
|
||||||
// delete the session to trigger cleanup of any stored permissions/state
|
// read the file again to re-establish permission after first write consumed it
|
||||||
await invoke({
|
await invoke({
|
||||||
tool: "session.deleted",
|
tool: "read",
|
||||||
sessionID,
|
sessionID,
|
||||||
outputArgs: {},
|
outputArgs: { filePath: existingFile },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// delete the session to trigger cleanup of any stored permissions/state
|
||||||
|
await emitSessionDeleted(sessionID)
|
||||||
|
|
||||||
// after session deletion, the previous permissions must no longer apply
|
// after session deletion, the previous permissions must no longer apply
|
||||||
await expect(
|
await expect(
|
||||||
invoke({
|
invoke({
|
||||||
|
|||||||
@ -180,7 +180,7 @@ describe("parseConfigPartially", () => {
|
|||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2");
|
expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2");
|
||||||
expect(result!.disabled_hooks).toBeUndefined();
|
expect(result!.disabled_hooks).toEqual(["not-a-real-hook"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -199,7 +199,7 @@ describe("parseConfigPartially", () => {
|
|||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!.agents).toBeUndefined();
|
expect(result!.agents).toBeUndefined();
|
||||||
expect(result!.disabled_hooks).toBeUndefined();
|
expect(result!.disabled_hooks).toEqual(["not-a-real-hook"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1157,13 +1157,15 @@ describe("config-handler plugin loading error boundary (#1559)", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("per-agent todowrite/todoread deny when task_system enabled", () => {
|
describe("per-agent todowrite/todoread deny when task_system enabled", () => {
|
||||||
const PRIMARY_AGENTS = [
|
const AGENTS_WITH_TODO_DENY = new Set([
|
||||||
getAgentDisplayName("sisyphus"),
|
getAgentDisplayName("sisyphus"),
|
||||||
getAgentDisplayName("hephaestus"),
|
getAgentDisplayName("hephaestus"),
|
||||||
getAgentDisplayName("atlas"),
|
getAgentDisplayName("atlas"),
|
||||||
|
])
|
||||||
|
const AGENTS_WITHOUT_TODO_DENY = new Set([
|
||||||
getAgentDisplayName("prometheus"),
|
getAgentDisplayName("prometheus"),
|
||||||
getAgentDisplayName("sisyphus-junior"),
|
getAgentDisplayName("sisyphus-junior"),
|
||||||
]
|
])
|
||||||
|
|
||||||
test("denies todowrite and todoread for primary agents when task_system is enabled", async () => {
|
test("denies todowrite and todoread for primary agents when task_system is enabled", async () => {
|
||||||
//#given
|
//#given
|
||||||
@ -1200,10 +1202,14 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
|
|||||||
|
|
||||||
//#then
|
//#then
|
||||||
const agentResult = config.agent as Record<string, { permission?: Record<string, unknown> }>
|
const agentResult = config.agent as Record<string, { permission?: Record<string, unknown> }>
|
||||||
for (const agentName of PRIMARY_AGENTS) {
|
for (const agentName of AGENTS_WITH_TODO_DENY) {
|
||||||
expect(agentResult[agentName]?.permission?.todowrite).toBe("deny")
|
expect(agentResult[agentName]?.permission?.todowrite).toBe("deny")
|
||||||
expect(agentResult[agentName]?.permission?.todoread).toBe("deny")
|
expect(agentResult[agentName]?.permission?.todoread).toBe("deny")
|
||||||
}
|
}
|
||||||
|
for (const agentName of AGENTS_WITHOUT_TODO_DENY) {
|
||||||
|
expect(agentResult[agentName]?.permission?.todowrite).toBeUndefined()
|
||||||
|
expect(agentResult[agentName]?.permission?.todoread).toBeUndefined()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not deny todowrite/todoread when task_system is disabled", async () => {
|
test("does not deny todowrite/todoread when task_system is disabled", async () => {
|
||||||
|
|||||||
@ -84,7 +84,6 @@ export function applyToolConfig(params: {
|
|||||||
question: questionPermission,
|
question: questionPermission,
|
||||||
"task_*": "allow",
|
"task_*": "allow",
|
||||||
teammate: "allow",
|
teammate: "allow",
|
||||||
...denyTodoTools,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const junior = agentByKey(params.agentResult, "sisyphus-junior");
|
const junior = agentByKey(params.agentResult, "sisyphus-junior");
|
||||||
@ -94,7 +93,6 @@ export function applyToolConfig(params: {
|
|||||||
task: "allow",
|
task: "allow",
|
||||||
"task_*": "allow",
|
"task_*": "allow",
|
||||||
teammate: "allow",
|
teammate: "allow",
|
||||||
...denyTodoTools,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,24 @@ export function createChatMessageHandler(args: {
|
|||||||
output: ChatMessageHandlerOutput
|
output: ChatMessageHandlerOutput
|
||||||
) => Promise<void> {
|
) => Promise<void> {
|
||||||
const { ctx, pluginConfig, firstMessageVariantGate, hooks } = args
|
const { ctx, pluginConfig, firstMessageVariantGate, hooks } = args
|
||||||
|
const pluginContext = ctx as {
|
||||||
|
client: {
|
||||||
|
tui: {
|
||||||
|
showToast: (input: {
|
||||||
|
body: {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
variant: "warning"
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
}) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isRuntimeFallbackEnabled =
|
||||||
|
hooks.runtimeFallback !== null &&
|
||||||
|
hooks.runtimeFallback !== undefined &&
|
||||||
|
(pluginConfig.runtime_fallback?.enabled ?? true)
|
||||||
|
|
||||||
return async (
|
return async (
|
||||||
input: ChatMessageInput,
|
input: ChatMessageInput,
|
||||||
@ -58,7 +76,9 @@ export function createChatMessageHandler(args: {
|
|||||||
firstMessageVariantGate.markApplied(input.sessionID)
|
firstMessageVariantGate.markApplied(input.sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
await hooks.modelFallback?.["chat.message"]?.(input, output)
|
if (!isRuntimeFallbackEnabled) {
|
||||||
|
await hooks.modelFallback?.["chat.message"]?.(input, output)
|
||||||
|
}
|
||||||
const modelOverride = output.message["model"]
|
const modelOverride = output.message["model"]
|
||||||
if (
|
if (
|
||||||
modelOverride &&
|
modelOverride &&
|
||||||
@ -86,7 +106,7 @@ export function createChatMessageHandler(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasConnectedProvidersCache()) {
|
if (!hasConnectedProvidersCache()) {
|
||||||
ctx.client.tui
|
pluginContext.client.tui
|
||||||
.showToast({
|
.showToast({
|
||||||
body: {
|
body: {
|
||||||
title: "⚠️ Provider Cache Missing",
|
title: "⚠️ Provider Cache Missing",
|
||||||
@ -130,6 +150,6 @@ export function createChatMessageHandler(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyUltraworkModelOverrideOnMessage(pluginConfig, input.agent, output, ctx.client.tui, input.sessionID)
|
applyUltraworkModelOverrideOnMessage(pluginConfig, input.agent, output, pluginContext.client.tui, input.sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,6 +105,23 @@ export function createEventHandler(args: {
|
|||||||
hooks: CreatedHooks
|
hooks: CreatedHooks
|
||||||
}): (input: EventInput) => Promise<void> {
|
}): (input: EventInput) => Promise<void> {
|
||||||
const { ctx, firstMessageVariantGate, managers, hooks } = args
|
const { ctx, firstMessageVariantGate, managers, hooks } = args
|
||||||
|
const pluginContext = ctx as {
|
||||||
|
directory: string
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
abort: (input: { path: { id: string } }) => Promise<unknown>
|
||||||
|
prompt: (input: {
|
||||||
|
path: { id: string }
|
||||||
|
body: { parts: Array<{ type: "text"; text: string }> }
|
||||||
|
query: { directory: string }
|
||||||
|
}) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isRuntimeFallbackEnabled =
|
||||||
|
hooks.runtimeFallback !== null &&
|
||||||
|
hooks.runtimeFallback !== undefined &&
|
||||||
|
(args.pluginConfig.runtime_fallback?.enabled ?? true)
|
||||||
|
|
||||||
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
|
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
|
||||||
const lastHandledModelErrorMessageID = new Map<string, string>()
|
const lastHandledModelErrorMessageID = new Map<string, string>()
|
||||||
@ -250,7 +267,7 @@ export function createEventHandler(args: {
|
|||||||
|
|
||||||
// Model fallback: in practice, API/model failures often surface as assistant message errors.
|
// Model fallback: in practice, API/model failures often surface as assistant message errors.
|
||||||
// session.error events are not guaranteed for all providers, so we also observe message.updated.
|
// session.error events are not guaranteed for all providers, so we also observe message.updated.
|
||||||
if (sessionID && role === "assistant") {
|
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled) {
|
||||||
try {
|
try {
|
||||||
const assistantMessageID = info?.id as string | undefined
|
const assistantMessageID = info?.id as string | undefined
|
||||||
const assistantError = info?.error
|
const assistantError = info?.error
|
||||||
@ -292,12 +309,12 @@ export function createEventHandler(args: {
|
|||||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||||
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
|
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
|
||||||
|
|
||||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||||
await ctx.client.session
|
await pluginContext.client.session
|
||||||
.prompt({
|
.prompt({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: { parts: [{ type: "text", text: "continue" }] },
|
body: { parts: [{ type: "text", text: "continue" }] },
|
||||||
query: { directory: ctx.directory },
|
query: { directory: pluginContext.directory },
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
@ -316,7 +333,7 @@ export function createEventHandler(args: {
|
|||||||
| { type?: string; attempt?: number; message?: string; next?: number }
|
| { type?: string; attempt?: number; message?: string; next?: number }
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
if (sessionID && status?.type === "retry") {
|
if (sessionID && status?.type === "retry" && !isRuntimeFallbackEnabled) {
|
||||||
try {
|
try {
|
||||||
const retryMessage = typeof status.message === "string" ? status.message : ""
|
const retryMessage = typeof status.message === "string" ? status.message : ""
|
||||||
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
|
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
|
||||||
@ -353,12 +370,12 @@ export function createEventHandler(args: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||||
await ctx.client.session
|
await pluginContext.client.session
|
||||||
.prompt({
|
.prompt({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: { parts: [{ type: "text", text: "continue" }] },
|
body: { parts: [{ type: "text", text: "continue" }] },
|
||||||
query: { directory: ctx.directory },
|
query: { directory: pluginContext.directory },
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
@ -395,17 +412,17 @@ export function createEventHandler(args: {
|
|||||||
sessionID === getMainSessionID() &&
|
sessionID === getMainSessionID() &&
|
||||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||||
) {
|
) {
|
||||||
await ctx.client.session
|
await pluginContext.client.session
|
||||||
.prompt({
|
.prompt({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: { parts: [{ type: "text", text: "continue" }] },
|
body: { parts: [{ type: "text", text: "continue" }] },
|
||||||
query: { directory: ctx.directory },
|
query: { directory: pluginContext.directory },
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
|
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
|
||||||
else if (sessionID && shouldRetryError(errorInfo)) {
|
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) {
|
||||||
let agentName = getSessionAgent(sessionID)
|
let agentName = getSessionAgent(sessionID)
|
||||||
|
|
||||||
if (!agentName && sessionID === getMainSessionID()) {
|
if (!agentName && sessionID === getMainSessionID()) {
|
||||||
@ -432,15 +449,15 @@ export function createEventHandler(args: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||||
|
|
||||||
await ctx.client.session
|
await pluginContext.client.session
|
||||||
.prompt({
|
.prompt({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: { parts: [{ type: "text", text: "continue" }] },
|
body: { parts: [{ type: "text", text: "continue" }] },
|
||||||
query: { directory: ctx.directory },
|
query: { directory: pluginContext.directory },
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,8 +56,8 @@ export type SessionHooks = {
|
|||||||
sisyphusJuniorNotepad: ReturnType<typeof createSisyphusJuniorNotepadHook> | null
|
sisyphusJuniorNotepad: ReturnType<typeof createSisyphusJuniorNotepadHook> | null
|
||||||
noSisyphusGpt: ReturnType<typeof createNoSisyphusGptHook> | null
|
noSisyphusGpt: ReturnType<typeof createNoSisyphusGptHook> | null
|
||||||
noHephaestusNonGpt: ReturnType<typeof createNoHephaestusNonGptHook> | null
|
noHephaestusNonGpt: ReturnType<typeof createNoHephaestusNonGptHook> | null
|
||||||
questionLabelTruncator: ReturnType<typeof createQuestionLabelTruncatorHook>
|
questionLabelTruncator: ReturnType<typeof createQuestionLabelTruncatorHook> | null
|
||||||
taskResumeInfo: ReturnType<typeof createTaskResumeInfoHook>
|
taskResumeInfo: ReturnType<typeof createTaskResumeInfoHook> | null
|
||||||
anthropicEffort: ReturnType<typeof createAnthropicEffortHook> | null
|
anthropicEffort: ReturnType<typeof createAnthropicEffortHook> | null
|
||||||
runtimeFallback: ReturnType<typeof createRuntimeFallbackHook> | null
|
runtimeFallback: ReturnType<typeof createRuntimeFallbackHook> | null
|
||||||
}
|
}
|
||||||
@ -234,8 +234,12 @@ export function createSessionHooks(args: {
|
|||||||
? safeHook("no-hephaestus-non-gpt", () => createNoHephaestusNonGptHook(ctx))
|
? safeHook("no-hephaestus-non-gpt", () => createNoHephaestusNonGptHook(ctx))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const questionLabelTruncator = createQuestionLabelTruncatorHook()
|
const questionLabelTruncator = isHookEnabled("question-label-truncator")
|
||||||
const taskResumeInfo = createTaskResumeInfoHook()
|
? safeHook("question-label-truncator", () => createQuestionLabelTruncatorHook())
|
||||||
|
: null
|
||||||
|
const taskResumeInfo = isHookEnabled("task-resume-info")
|
||||||
|
? safeHook("task-resume-info", () => createTaskResumeInfoHook())
|
||||||
|
: null
|
||||||
|
|
||||||
const anthropicEffort = isHookEnabled("anthropic-effort")
|
const anthropicEffort = isHookEnabled("anthropic-effort")
|
||||||
? safeHook("anthropic-effort", () => createAnthropicEffortHook())
|
? safeHook("anthropic-effort", () => createAnthropicEffortHook())
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
createTasksTodowriteDisablerHook,
|
createTasksTodowriteDisablerHook,
|
||||||
createWriteExistingFileGuardHook,
|
createWriteExistingFileGuardHook,
|
||||||
createHashlineReadEnhancerHook,
|
createHashlineReadEnhancerHook,
|
||||||
|
createJsonErrorRecoveryHook,
|
||||||
} from "../../hooks"
|
} from "../../hooks"
|
||||||
import {
|
import {
|
||||||
getOpenCodeVersion,
|
getOpenCodeVersion,
|
||||||
@ -31,6 +32,7 @@ export type ToolGuardHooks = {
|
|||||||
tasksTodowriteDisabler: ReturnType<typeof createTasksTodowriteDisablerHook> | null
|
tasksTodowriteDisabler: ReturnType<typeof createTasksTodowriteDisablerHook> | null
|
||||||
writeExistingFileGuard: ReturnType<typeof createWriteExistingFileGuardHook> | null
|
writeExistingFileGuard: ReturnType<typeof createWriteExistingFileGuardHook> | null
|
||||||
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
|
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
|
||||||
|
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createToolGuardHooks(args: {
|
export function createToolGuardHooks(args: {
|
||||||
@ -99,6 +101,10 @@ export function createToolGuardHooks(args: {
|
|||||||
? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
|
? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const jsonErrorRecovery = isHookEnabled("json-error-recovery")
|
||||||
|
? safeHook("json-error-recovery", () => createJsonErrorRecoveryHook(ctx))
|
||||||
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commentChecker,
|
commentChecker,
|
||||||
toolOutputTruncator,
|
toolOutputTruncator,
|
||||||
@ -109,5 +115,6 @@ export function createToolGuardHooks(args: {
|
|||||||
tasksTodowriteDisabler,
|
tasksTodowriteDisabler,
|
||||||
writeExistingFileGuard,
|
writeExistingFileGuard,
|
||||||
hashlineReadEnhancer,
|
hashlineReadEnhancer,
|
||||||
|
jsonErrorRecovery,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
import { safeCreateHook } from "../../shared/safe-create-hook"
|
import { safeCreateHook } from "../../shared/safe-create-hook"
|
||||||
|
|
||||||
export type TransformHooks = {
|
export type TransformHooks = {
|
||||||
claudeCodeHooks: ReturnType<typeof createClaudeCodeHooksHook>
|
claudeCodeHooks: ReturnType<typeof createClaudeCodeHooksHook> | null
|
||||||
keywordDetector: ReturnType<typeof createKeywordDetectorHook> | null
|
keywordDetector: ReturnType<typeof createKeywordDetectorHook> | null
|
||||||
contextInjectorMessagesTransform: ReturnType<typeof createContextInjectorMessagesTransformHook>
|
contextInjectorMessagesTransform: ReturnType<typeof createContextInjectorMessagesTransformHook>
|
||||||
thinkingBlockValidator: ReturnType<typeof createThinkingBlockValidatorHook> | null
|
thinkingBlockValidator: ReturnType<typeof createThinkingBlockValidatorHook> | null
|
||||||
@ -30,14 +30,21 @@ export function createTransformHooks(args: {
|
|||||||
const { ctx, pluginConfig, isHookEnabled } = args
|
const { ctx, pluginConfig, isHookEnabled } = args
|
||||||
const safeHookEnabled = args.safeHookEnabled ?? true
|
const safeHookEnabled = args.safeHookEnabled ?? true
|
||||||
|
|
||||||
const claudeCodeHooks = createClaudeCodeHooksHook(
|
const claudeCodeHooks = isHookEnabled("claude-code-hooks")
|
||||||
ctx,
|
? safeCreateHook(
|
||||||
{
|
"claude-code-hooks",
|
||||||
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
() =>
|
||||||
keywordDetectorDisabled: !isHookEnabled("keyword-detector"),
|
createClaudeCodeHooksHook(
|
||||||
},
|
ctx,
|
||||||
contextCollector,
|
{
|
||||||
)
|
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
||||||
|
keywordDetectorDisabled: !isHookEnabled("keyword-detector"),
|
||||||
|
},
|
||||||
|
contextCollector,
|
||||||
|
),
|
||||||
|
{ enabled: safeHookEnabled },
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
const keywordDetector = isHookEnabled("keyword-detector")
|
const keywordDetector = isHookEnabled("keyword-detector")
|
||||||
? safeCreateHook(
|
? safeCreateHook(
|
||||||
|
|||||||
@ -44,5 +44,6 @@ export function createToolExecuteAfterHandler(args: {
|
|||||||
await hooks.atlasHook?.["tool.execute.after"]?.(input, output)
|
await hooks.atlasHook?.["tool.execute.after"]?.(input, output)
|
||||||
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)
|
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)
|
||||||
await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output)
|
await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output)
|
||||||
|
await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(input, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,8 +116,10 @@ export function resolveModelPipeline(
|
|||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
const provider = parts[0]
|
const provider = parts[0]
|
||||||
if (connectedSet.has(provider)) {
|
if (connectedSet.has(provider)) {
|
||||||
log("Model resolved via user fallback_models (connected provider)", { model })
|
const modelName = parts.slice(1).join("/")
|
||||||
return { model, provenance: "provider-fallback", attempted }
|
const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}`
|
||||||
|
log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model })
|
||||||
|
return { model: transformedModel, provenance: "provider-fallback", attempted }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isActive = task.status === "pending" || task.status === "running"
|
const isActive = task.status === "pending" || task.status === "running"
|
||||||
const fullSession = args.full_session ?? false
|
const fullSession = args.full_session ?? true
|
||||||
const includeThinking = isActive || (args.include_thinking ?? false)
|
const includeThinking = isActive || (args.include_thinking ?? false)
|
||||||
const includeToolResults = isActive || (args.include_tool_results ?? false)
|
const includeToolResults = isActive || (args.include_tool_results ?? false)
|
||||||
|
|
||||||
|
|||||||
@ -243,8 +243,7 @@ describe("background_output full_session", () => {
|
|||||||
const output = await tool.execute({ task_id: "task-1" }, mockContext)
|
const output = await tool.execute({ task_id: "task-1" }, mockContext)
|
||||||
|
|
||||||
// #then
|
// #then
|
||||||
expect(output).toContain("# Task Status")
|
expect(output).toContain("# Full Session Output")
|
||||||
expect(output).not.toContain("# Full Session Output")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns full session when explicitly requested for running task", async () => {
|
test("returns full session when explicitly requested for running task", async () => {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
import { formatDetailedError } from "./error-formatting"
|
import { formatDetailedError } from "./error-formatting"
|
||||||
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
||||||
import { setSessionTools } from "../../shared/session-tools-store"
|
import { setSessionTools } from "../../shared/session-tools-store"
|
||||||
|
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
||||||
|
|
||||||
type SendSyncPromptDeps = {
|
type SendSyncPromptDeps = {
|
||||||
promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry
|
promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry
|
||||||
@ -56,7 +57,7 @@ export async function sendSyncPrompt(
|
|||||||
agent: input.agentToUse,
|
agent: input.agentToUse,
|
||||||
system: input.systemContent,
|
system: input.systemContent,
|
||||||
tools,
|
tools,
|
||||||
parts: [{ type: "text", text: input.args.prompt }],
|
parts: [createInternalAgentTextPart(input.args.prompt)],
|
||||||
...(input.categoryModel
|
...(input.categoryModel
|
||||||
? { model: { providerID: input.categoryModel.providerID, modelID: input.categoryModel.modelID } }
|
? { model: { providerID: input.categoryModel.providerID, modelID: input.categoryModel.modelID } }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@ -10,6 +10,15 @@ import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
|||||||
import { discoverCommandsSync } from "../slashcommand/command-discovery"
|
import { discoverCommandsSync } from "../slashcommand/command-discovery"
|
||||||
import type { CommandInfo } from "../slashcommand/types"
|
import type { CommandInfo } from "../slashcommand/types"
|
||||||
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
|
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
|
||||||
|
// Priority: project > user > opencode/opencode-project > builtin/config
|
||||||
|
const scopePriority: Record<string, number> = {
|
||||||
|
project: 4,
|
||||||
|
user: 3,
|
||||||
|
opencode: 2,
|
||||||
|
"opencode-project": 2,
|
||||||
|
config: 1,
|
||||||
|
builtin: 1,
|
||||||
|
}
|
||||||
|
|
||||||
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
||||||
return {
|
return {
|
||||||
@ -31,15 +40,7 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[])
|
|||||||
return TOOL_DESCRIPTION_NO_SKILLS
|
return TOOL_DESCRIPTION_NO_SKILLS
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority: project > user > opencode/opencode-project > builtin/config
|
// Uses module-level scopePriority for consistent priority ordering
|
||||||
const scopePriority: Record<string, number> = {
|
|
||||||
project: 4,
|
|
||||||
user: 3,
|
|
||||||
opencode: 2,
|
|
||||||
"opencode-project": 2,
|
|
||||||
config: 1,
|
|
||||||
builtin: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
const allItems: string[] = []
|
const allItems: string[] = []
|
||||||
|
|
||||||
@ -273,8 +274,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
|||||||
return output.join("\n")
|
return output.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check commands (exact match, case-insensitive)
|
// Check commands (exact match, case-insensitive) - sort by priority first
|
||||||
const matchedCommand = commands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
|
const sortedCommands = [...commands].sort((a, b) => {
|
||||||
|
const priorityA = scopePriority[a.scope] || 0
|
||||||
|
const priorityB = scopePriority[b.scope] || 0
|
||||||
|
return priorityB - priorityA // Higher priority first
|
||||||
|
})
|
||||||
|
const matchedCommand = sortedCommands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
|
||||||
|
|
||||||
if (matchedCommand) {
|
if (matchedCommand) {
|
||||||
return await formatLoadedCommand(matchedCommand, args.user_message)
|
return await formatLoadedCommand(matchedCommand, args.user_message)
|
||||||
|
|||||||
@ -76,8 +76,8 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...userCommands,
|
|
||||||
...projectCommands,
|
...projectCommands,
|
||||||
|
...userCommands,
|
||||||
...opencodeProjectCommands,
|
...opencodeProjectCommands,
|
||||||
...opencodeGlobalCommands,
|
...opencodeGlobalCommands,
|
||||||
...builtinCommands,
|
...builtinCommands,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user