merge dev into continuation runtime retry
# Conflicts: # src/hooks/ralph-loop/non-abort-error-continuation.test.ts
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { appendSessionId, type BoulderState, upsertTaskSessionState } from "../../features/boulder-state"
|
||||
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
||||
@@ -40,7 +40,7 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
||||
|
||||
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
|
||||
pendingTaskRef,
|
||||
boulderState.active_plan,
|
||||
resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||
)
|
||||
|
||||
if (currentTask && !shouldSkipTaskSessionUpdate) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
resolveBoulderPlanPath,
|
||||
} from "../../features/boulder-state"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { getLastAgentFromSession } from "./session-last-agent"
|
||||
@@ -52,8 +53,12 @@ async function injectContinuation(input: {
|
||||
|
||||
try {
|
||||
const currentBoulder = readBoulderState(input.ctx.directory)
|
||||
const currentPlanPath = currentBoulder
|
||||
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
|
||||
: null
|
||||
const currentTask = currentBoulder
|
||||
? readCurrentTopLevelTask(currentBoulder.active_plan)
|
||||
&& currentPlanPath
|
||||
? readCurrentTopLevelTask(currentPlanPath)
|
||||
: null
|
||||
const preferredTaskSession = currentTask
|
||||
? getTaskSessionState(input.ctx.directory, currentTask.key)
|
||||
@@ -163,7 +168,7 @@ function scheduleRetry(input: {
|
||||
if (!currentBoulder) return
|
||||
if (!currentBoulder.session_ids?.includes(sessionID)) return
|
||||
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
|
||||
if (currentProgress.isComplete) return
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
const canContinueSession = await canContinueTrackedBoulderSession({
|
||||
|
||||
@@ -1494,6 +1494,43 @@ session_id: ses_untrusted_999
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
|
||||
// given
|
||||
const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md")
|
||||
const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`)
|
||||
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-complete-plan.md")
|
||||
mkdirSync(join(TEST_DIR, ".sisyphus", "plans"), { recursive: true })
|
||||
mkdirSync(join(worktreeDir, ".sisyphus", "plans"), { recursive: true })
|
||||
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
|
||||
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n")
|
||||
|
||||
writeBoulderState(TEST_DIR, {
|
||||
active_plan: mainPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "worktree-complete-plan",
|
||||
worktree_path: worktreeDir,
|
||||
})
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
try {
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("should skip when abort error occurred before idle", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { dirname, join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||
@@ -96,4 +96,39 @@ describe("resolveActiveBoulderSession", () => {
|
||||
expect(result?.progress.isComplete).toBe(false)
|
||||
expect(result?.boulderState.session_ids).toContain("ses_appended")
|
||||
})
|
||||
|
||||
test("returns complete progress when a mirrored worktree plan is complete", async () => {
|
||||
// given
|
||||
const mainPlanPath = join(testDirectory, ".sisyphus", "plans", "worktree-plan.md")
|
||||
const worktreeDirectory = join(tmpdir(), `resolve-active-boulder-worktree-${randomUUID()}`)
|
||||
const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "worktree-plan.md")
|
||||
mkdirSync(dirname(mainPlanPath), { recursive: true })
|
||||
mkdirSync(dirname(worktreePlanPath), { recursive: true })
|
||||
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n", "utf-8")
|
||||
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
active_plan: mainPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_tracked"],
|
||||
session_origins: { ses_tracked: "direct" },
|
||||
plan_name: "worktree-plan",
|
||||
worktree_path: worktreeDirectory,
|
||||
})
|
||||
|
||||
try {
|
||||
// when
|
||||
const result = await resolveActiveBoulderSession({
|
||||
client: { session: { get: async () => ({ data: {} }) } } as never,
|
||||
directory: testDirectory,
|
||||
sessionID: "ses_tracked",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.progress.isComplete).toBe(true)
|
||||
expect(result?.progress.completed).toBe(1)
|
||||
} finally {
|
||||
rmSync(worktreeDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
|
||||
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||
|
||||
export async function resolveActiveBoulderSession(input: {
|
||||
@@ -20,7 +20,7 @@ export async function resolveActiveBoulderSession(input: {
|
||||
return null
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
|
||||
if (progress.isComplete) {
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
upsertTaskSessionState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -98,12 +99,13 @@ export function createToolExecuteAfterHandler(input: {
|
||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||
|
||||
if (boulderState) {
|
||||
const progress = getPlanProgress(boulderState.active_plan)
|
||||
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||
const progress = getPlanProgress(planPath)
|
||||
const {
|
||||
currentTask,
|
||||
shouldSkipTaskSessionUpdate,
|
||||
shouldIgnoreCurrentSessionId,
|
||||
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
|
||||
} = resolveTaskContext(pendingTaskRef, planPath)
|
||||
const trackedTaskSession = currentTask
|
||||
? getTaskSessionState(ctx.directory, currentTask.key)
|
||||
: null
|
||||
@@ -136,7 +138,7 @@ export function createToolExecuteAfterHandler(input: {
|
||||
const originalResponse = toolOutput.output
|
||||
const shouldPauseForApproval = sessionState
|
||||
? shouldPauseForFinalWaveApproval({
|
||||
planPath: boulderState.active_plan,
|
||||
planPath,
|
||||
taskOutput: originalResponse,
|
||||
sessionState,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state"
|
||||
import { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
|
||||
import { isSisyphusPath } from "./sisyphus-path"
|
||||
@@ -60,7 +60,7 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
} else {
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const currentTask = boulderState
|
||||
? readCurrentTopLevelTask(boulderState.active_plan)
|
||||
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
|
||||
: null
|
||||
if (currentTask) {
|
||||
const task = {
|
||||
|
||||
@@ -26,6 +26,10 @@ export async function processFilePathForAgentsInjection(input: {
|
||||
sessionID: string;
|
||||
output: { title: string; output: string; metadata: unknown };
|
||||
}): Promise<void> {
|
||||
// Guard: output.output may be non-string at runtime (e.g. MCP bridge format changes).
|
||||
// Consistent with the pattern used in tool-output-truncator and other hooks.
|
||||
if (typeof input.output.output !== "string") return;
|
||||
|
||||
const resolved = resolveFilePath(input.ctx.directory, input.filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("model fallback hook", () => {
|
||||
|
||||
expect(secondOutput.message["model"]).toEqual({
|
||||
providerID: "opencode-go",
|
||||
modelID: "kimi-k2.5",
|
||||
modelID: "kimi-k2.6",
|
||||
})
|
||||
expect(secondOutput.message["variant"]).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -88,7 +88,6 @@ describe("ralph-loop non-abort error continuation", () => {
|
||||
expect(messagesCalls.length).toBeGreaterThan(0)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("continues ultrawork loop immediately after non-abort session error", async () => {
|
||||
// given - an active ULW Loop receives a recoverable runtime error
|
||||
const hook = createRalphLoopHook({
|
||||
|
||||
@@ -132,7 +132,8 @@ export function classifyErrorType(error: unknown): string | undefined {
|
||||
/exhausted\s+your\s+capacity/i.test(message) ||
|
||||
/out\s+of\s+credits?/i.test(message) ||
|
||||
/payment.?required/i.test(message) ||
|
||||
/usage\s+limit/i.test(message)
|
||||
/usage\s+limit/i.test(message) ||
|
||||
/credit\s+balance.*too\s+low/i.test(message)
|
||||
) {
|
||||
return "quota_exceeded"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getPlanName,
|
||||
getPlanProgress,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
writeBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -150,7 +151,8 @@ function buildExistingSessionContext(params: {
|
||||
directory: string
|
||||
}): string {
|
||||
const { existingState, sessionId, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const progress = getPlanProgress(existingState.active_plan)
|
||||
const planPath = resolveBoulderPlanPath(directory, existingState)
|
||||
const progress = getPlanProgress(planPath)
|
||||
if (progress.isComplete) {
|
||||
return `
|
||||
## Previous Work Complete
|
||||
@@ -186,7 +188,7 @@ Looking for new plans...`
|
||||
|
||||
**Status**: RESUMING existing work
|
||||
**Plan**: ${existingState.plan_name}
|
||||
**Path**: ${existingState.active_plan}
|
||||
**Path**: ${planPath}
|
||||
**Progress**: ${progress.completed}/${progress.total} tasks completed
|
||||
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
|
||||
**Started**: ${existingState.started_at}
|
||||
@@ -197,11 +199,16 @@ Read the plan file and continue from the first unchecked task.`
|
||||
}
|
||||
|
||||
function shouldDiscoverPlans(
|
||||
directory: string,
|
||||
existingState: ReturnType<typeof readBoulderState>,
|
||||
explicitPlanName: string | null,
|
||||
): boolean {
|
||||
return (!existingState && !explicitPlanName)
|
||||
|| (existingState !== null && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|
||||
|| (
|
||||
existingState !== null
|
||||
&& !explicitPlanName
|
||||
&& getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete
|
||||
)
|
||||
}
|
||||
|
||||
function buildPlanDiscoveryContext(params: {
|
||||
@@ -303,7 +310,7 @@ export function buildStartWorkContextInfo(params: {
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldDiscoverPlans(existingState, explicitPlanName)) {
|
||||
if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName)) {
|
||||
return buildPlanDiscoveryContext({
|
||||
contextInfo,
|
||||
sessionId,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { dirname, join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createStartWorkHook } from "./index"
|
||||
@@ -1013,5 +1013,39 @@ You are starting a Sisyphus work session.
|
||||
expect(output.parts[0].text).toContain("subagent")
|
||||
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
|
||||
})
|
||||
|
||||
test("should show worktree plan progress and path when the mirrored plan exists", async () => {
|
||||
// given
|
||||
const mainPlanPath = join(testDir, ".sisyphus", "plans", "resume-worktree-plan.md")
|
||||
const worktreeDir = join(testDir, "..", `resume-worktree-${randomUUID()}`)
|
||||
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "resume-worktree-plan.md")
|
||||
mkdirSync(dirname(mainPlanPath), { recursive: true })
|
||||
mkdirSync(dirname(worktreePlanPath), { recursive: true })
|
||||
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
|
||||
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task 1\n- [ ] Worktree task 2\n")
|
||||
writeBoulderState(testDir, {
|
||||
active_plan: mainPlanPath,
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
session_ids: ["old-session"],
|
||||
plan_name: "resume-worktree-plan",
|
||||
worktree_path: worktreeDir,
|
||||
})
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [{ type: "text", text: createStartWorkPrompt() }],
|
||||
}
|
||||
|
||||
try {
|
||||
// when
|
||||
await hook["chat.message"]({ sessionID: "session-worktree-progress" }, output)
|
||||
|
||||
// then
|
||||
expect(output.parts[0].text).toContain(worktreePlanPath)
|
||||
expect(output.parts[0].text).toContain("1/2 tasks completed")
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,45 @@ import { handleSessionIdle } from "./idle-event"
|
||||
import { handleNonIdleEvent } from "./non-idle-events"
|
||||
import { isTokenLimitError } from "./token-limit-detection"
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined
|
||||
}
|
||||
|
||||
function getStringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = record?.[key]
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function extractSessionErrorInfo(error: unknown): { name?: string; message?: string } | undefined {
|
||||
if (!error) return undefined
|
||||
if (typeof error === "string") return { message: error }
|
||||
if (error instanceof Error) return { name: error.name, message: error.message }
|
||||
|
||||
const root = asRecord(error)
|
||||
if (!root) return { message: String(error) }
|
||||
|
||||
const data = asRecord(root.data)
|
||||
const nestedError = asRecord(root.error)
|
||||
const dataError = asRecord(data?.error)
|
||||
|
||||
const name = getStringField(root, "name")
|
||||
?? getStringField(data, "name")
|
||||
?? getStringField(nestedError, "name")
|
||||
?? getStringField(dataError, "name")
|
||||
|
||||
const messageParts = [
|
||||
getStringField(root, "message"),
|
||||
getStringField(data, "message"),
|
||||
getStringField(nestedError, "message"),
|
||||
getStringField(dataError, "message"),
|
||||
getStringField(root, "code"),
|
||||
getStringField(nestedError, "code"),
|
||||
getStringField(dataError, "code"),
|
||||
].filter((message): message is string => typeof message === "string")
|
||||
|
||||
return { name, message: messageParts.join(" ") || undefined }
|
||||
}
|
||||
|
||||
export function createTodoContinuationHandler(args: {
|
||||
ctx: PluginInput
|
||||
sessionStateStore: SessionStateStore
|
||||
@@ -35,7 +74,8 @@ export function createTodoContinuationHandler(args: {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const error = props?.error as { name?: string; message?: string } | undefined
|
||||
const error = extractSessionErrorInfo(props?.error)
|
||||
let shouldCancelCountdown = false
|
||||
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.wasCancelled = true
|
||||
@@ -45,14 +85,18 @@ export function createTodoContinuationHandler(args: {
|
||||
state.awaitingPostInjectionProgressCheck = false
|
||||
state.stagnationCount = 0
|
||||
state.consecutiveFailures = 0
|
||||
shouldCancelCountdown = true
|
||||
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
|
||||
} else if (isTokenLimitError(error)) {
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.tokenLimitDetected = true
|
||||
shouldCancelCountdown = true
|
||||
log(`[${HOOK_NAME}] Token limit error detected via session.error`, { sessionID, errorName: error?.name, errorMessage: error?.message })
|
||||
}
|
||||
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
if (shouldCancelCountdown) {
|
||||
sessionStateStore.cancelCountdown(sessionID)
|
||||
}
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "."
|
||||
|
||||
type PromptCall = {
|
||||
sessionID: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type PromptInput = {
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ text: string }> }
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function createPluginInput(promptCalls: PromptCall[]): Parameters<typeof createTodoContinuationEnforcer>[0] {
|
||||
return {
|
||||
directory: "/tmp/opencode-overload-continuation-test",
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({
|
||||
data: [
|
||||
{ id: "1", content: "Keep working", status: "pending", priority: "high" },
|
||||
],
|
||||
}),
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async (input: PromptInput) => {
|
||||
promptCalls.push({
|
||||
sessionID: input.path.id,
|
||||
text: input.body.parts[0]?.text ?? "",
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async () => ({}),
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof createTodoContinuationEnforcer>[0]
|
||||
}
|
||||
|
||||
describe("todo-continuation-enforcer OpenCode overload errors", () => {
|
||||
test(
|
||||
"#given countdown is armed #when OpenCode reports server_is_overloaded #then continuation still injects",
|
||||
async () => {
|
||||
// given
|
||||
const sessionID = "main-opencode-overload"
|
||||
const promptCalls: PromptCall[] = []
|
||||
_resetForTesting()
|
||||
setMainSession(sessionID)
|
||||
const hook = createTodoContinuationEnforcer(createPluginInput(promptCalls))
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: {
|
||||
type: "error",
|
||||
sequence_number: 2,
|
||||
error: {
|
||||
type: "service_unavailable_error",
|
||||
code: "server_is_overloaded",
|
||||
message: "Our servers are currently overloaded. Please try again later.",
|
||||
param: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(2500)
|
||||
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.sessionID).toBe(sessionID)
|
||||
expect(promptCalls[0]?.text).toContain("TODO CONTINUATION")
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user