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:
@@ -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 { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
||||
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
|
||||
export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer";
|
||||
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";
|
||||
|
||||
@@ -88,7 +88,7 @@ export function createRalphLoopEventHandler(
|
||||
|
||||
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)`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -100,11 +100,9 @@ export function createRalphLoopEventHandler(
|
||||
})
|
||||
options.loopState.clear()
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
||||
})
|
||||
.catch(() => {})
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
||||
}).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,16 +118,14 @@ export function createRalphLoopEventHandler(
|
||||
max: newState.max_iterations,
|
||||
})
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
await ctx.client.tui?.showToast?.({
|
||||
body: {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
|
||||
@@ -9,6 +9,9 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
|
||||
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) {
|
||||
const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps
|
||||
|
||||
@@ -87,6 +90,10 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
const modelParts = newModel.split("/")
|
||||
if (modelParts.length < 2) {
|
||||
log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`)
|
||||
const state = sessionStates.get(sessionID)
|
||||
if (state?.pendingFallbackModel) {
|
||||
state.pendingFallbackModel = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,6 +103,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
}
|
||||
|
||||
sessionRetryInFlight.add(sessionID)
|
||||
let retryDispatched = false
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
@@ -136,6 +144,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
retryDispatched = true
|
||||
}
|
||||
} else {
|
||||
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) })
|
||||
} finally {
|
||||
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> = {
|
||||
enabled: true,
|
||||
retry_on_errors: [400, 429, 503, 529],
|
||||
retry_on_errors: [429, 500, 502, 503, 504],
|
||||
max_fallback_attempts: 3,
|
||||
cooldown_seconds: 60,
|
||||
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-chat-latest": "gpt-5-2-chat-latest-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))
|
||||
|
||||
@@ -532,13 +532,16 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
})
|
||||
).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({
|
||||
tool: "session.deleted",
|
||||
tool: "read",
|
||||
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
|
||||
await expect(
|
||||
invoke({
|
||||
|
||||
Reference in New Issue
Block a user