Merge remote-tracking branch 'origin/dev' into fix/sync-package-json-to-opencode-intent
This commit is contained in:
@@ -110,6 +110,7 @@ function scheduleRetry(input: {
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
if (currentProgress.isComplete) return
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
if (options?.shouldSkipContinuation?.(sessionID)) return
|
||||
if (hasRunningBackgroundTasks(sessionID, options)) return
|
||||
|
||||
await injectContinuation({
|
||||
@@ -192,6 +193,11 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.shouldSkipContinuation?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
|
||||
scheduleRetry({ ctx, sessionID, sessionState, options })
|
||||
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
|
||||
|
||||
@@ -1042,6 +1042,37 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should skip when another continuation hook already injected", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput, {
|
||||
directory: TEST_DIR,
|
||||
shouldSkipContinuation: (sessionID: string) => sessionID === MAIN_SESSION_ID,
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should not call prompt because another continuation already handled it
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should clear abort state on message.updated", async () => {
|
||||
// given - boulder with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AtlasHookOptions {
|
||||
directory: string
|
||||
backgroundManager?: BackgroundManager
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||
agentOverrides?: AgentOverrides
|
||||
/** Enable auto-commit after each atomic task completion (default: true) */
|
||||
autoCommit?: boolean
|
||||
|
||||
@@ -3,7 +3,7 @@ export { getLocalDevVersion } from "./checker/local-dev-version"
|
||||
export { findPluginEntry } from "./checker/plugin-entry"
|
||||
export type { PluginEntryInfo } from "./checker/plugin-entry"
|
||||
export { getCachedVersion } from "./checker/cached-version"
|
||||
export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater"
|
||||
export { updatePinnedVersion } from "./checker/pinned-version-updater"
|
||||
export { getLatestVersion } from "./checker/latest-version"
|
||||
export { checkForUpdate } from "./checker/check-for-update"
|
||||
export { syncCachePackageJsonToIntent } from "./checker/sync-package-json"
|
||||
|
||||
@@ -10,11 +10,8 @@ interface CachePackageJson {
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
/** Whether the package.json was successfully synced/updated */
|
||||
synced: boolean
|
||||
/** Whether there was an error during sync (null if no error) */
|
||||
error: "file_not_found" | "plugin_not_in_deps" | "parse_error" | "write_error" | null
|
||||
/** Human-readable message describing what happened */
|
||||
message?: string
|
||||
}
|
||||
|
||||
@@ -28,35 +25,13 @@ function safeUnlink(filePath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the version specifier to use in cache package.json based on opencode.json intent.
|
||||
*
|
||||
* - "oh-my-opencode" (no version) → "latest"
|
||||
* - "oh-my-opencode@latest" → "latest"
|
||||
* - "oh-my-opencode@next" → "next"
|
||||
* - "oh-my-opencode@3.10.0" → "3.10.0" (pinned, use as-is)
|
||||
*/
|
||||
function getIntentVersion(pluginInfo: PluginEntryInfo): string {
|
||||
if (!pluginInfo.pinnedVersion) {
|
||||
// No version specified in opencode.json, default to latest
|
||||
return "latest"
|
||||
}
|
||||
return pluginInfo.pinnedVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the cache package.json to match the opencode.json plugin intent.
|
||||
*
|
||||
* OpenCode pins resolved versions in cache package.json (e.g., "3.11.0" instead of "latest").
|
||||
* This causes issues when users switch from pinned to tag in opencode.json:
|
||||
* - User changes opencode.json from "oh-my-opencode@3.10.0" to "oh-my-opencode@latest"
|
||||
* - Cache package.json still has "3.10.0"
|
||||
* - bun install reinstalls 3.10.0 instead of resolving @latest
|
||||
*
|
||||
* This function updates cache package.json to match the user's intent before bun install.
|
||||
*
|
||||
* @returns SyncResult with synced status and any error information
|
||||
*/
|
||||
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncResult {
|
||||
const cachePackageJsonPath = path.join(CACHE_DIR, "package.json")
|
||||
|
||||
@@ -95,9 +70,6 @@ export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): SyncR
|
||||
return { synced: false, error: null, message: `Already matches intent: ${intentVersion}` }
|
||||
}
|
||||
|
||||
// Check if this is a meaningful change:
|
||||
// - If intent is a tag (latest, next, beta) and current is semver, we need to update
|
||||
// - If both are semver but different, user explicitly changed versions
|
||||
const intentIsTag = !EXACT_SEMVER_REGEX.test(intentVersion.trim())
|
||||
const currentIsSemver = EXACT_SEMVER_REGEX.test(currentVersion.trim())
|
||||
|
||||
|
||||
@@ -54,6 +54,26 @@ function createPluginInput() {
|
||||
} as never
|
||||
}
|
||||
|
||||
async function flushScheduledWork(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0)
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function runSessionCreatedEvent(
|
||||
hook: ReturnType<HookFactory>,
|
||||
properties?: { info?: { parentID?: string } }
|
||||
): void {
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockShowConfigErrorsIfAny.mockClear()
|
||||
mockShowModelCacheWarningIfNeeded.mockClear()
|
||||
@@ -85,13 +105,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
})
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { parentID: undefined } },
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook, { info: { parentID: undefined } })
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - no update checker side effects run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
@@ -108,12 +123,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event arrives on primary session
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook)
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - startup checks, toast, and background check run
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
@@ -129,13 +140,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event contains parentID
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { parentID: "parent-123" } },
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook, { info: { parentID: "parent-123" } })
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
@@ -152,17 +158,9 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event is fired twice
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook)
|
||||
runSessionCreatedEvent(hook)
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - side effects execute only once
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
@@ -179,12 +177,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput())
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook)
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - local dev toast is shown and background check is skipped
|
||||
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
|
||||
@@ -206,7 +200,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
type: "session.deleted",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - no startup actions run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
@@ -225,12 +219,8 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
})
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
runSessionCreatedEvent(hook)
|
||||
await flushScheduledWork()
|
||||
|
||||
//#then - startup toast includes sisyphus wording
|
||||
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
createSystemDirective,
|
||||
SystemDirectiveTypes,
|
||||
} from "../../shared/system-directive"
|
||||
|
||||
export const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
|
||||
|
||||
When summarizing this session, you MUST include the following sections in your summary:
|
||||
|
||||
## 1. User Requests (As-Is)
|
||||
- List all original user requests exactly as they were stated
|
||||
- Preserve the user's exact wording and intent
|
||||
|
||||
## 2. Final Goal
|
||||
- What the user ultimately wanted to achieve
|
||||
- The end result or deliverable expected
|
||||
|
||||
## 3. Work Completed
|
||||
- What has been done so far
|
||||
- Files created/modified
|
||||
- Features implemented
|
||||
- Problems solved
|
||||
|
||||
## 4. Remaining Tasks
|
||||
- What still needs to be done
|
||||
- Pending items from the original request
|
||||
- Follow-up tasks identified during the work
|
||||
|
||||
## 5. Active Working Context (For Seamless Continuation)
|
||||
- **Files**: Paths of files currently being edited or frequently referenced
|
||||
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
|
||||
- **External References**: Documentation URLs, library APIs, or external resources being consulted
|
||||
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
|
||||
|
||||
## 6. Explicit Constraints (Verbatim Only)
|
||||
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
|
||||
- Quote constraints verbatim (do not paraphrase)
|
||||
- Do NOT invent, add, or modify constraints
|
||||
- If no explicit constraints exist, write "None"
|
||||
|
||||
## 7. Agent Verification State (Critical for Reviewers)
|
||||
- **Current Agent**: What agent is running (momus, oracle, etc.)
|
||||
- **Verification Progress**: Files already verified/validated
|
||||
- **Pending Verifications**: Files still needing verification
|
||||
- **Previous Rejections**: If reviewer agent, what was rejected and why
|
||||
- **Acceptance Status**: Current state of review process
|
||||
|
||||
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
|
||||
|
||||
## 8. Delegated Agent Sessions
|
||||
- List ALL background agent tasks spawned during this session
|
||||
- For each: agent name, category, status, description, and **session_id**
|
||||
- **RESUME, DON'T RESTART.** Each listed session retains full context. After compaction, use \`session_id\` to continue existing agent sessions instead of spawning new ones. This saves tokens, preserves learned context, and prevents duplicate work.
|
||||
|
||||
This context is critical for maintaining continuity after compaction.
|
||||
`
|
||||
@@ -0,0 +1,5 @@
|
||||
export const HOOK_NAME = "compaction-context-injector"
|
||||
export const AGENT_RECOVERY_PROMPT = "[restore checkpointed session agent configuration after compaction]"
|
||||
export const NO_TEXT_TAIL_THRESHOLD = 5
|
||||
export const RECOVERY_COOLDOWN_MS = 60_000
|
||||
export const RECENT_COMPACTION_WINDOW_MS = 10 * 60 * 1000
|
||||
@@ -1,63 +1,60 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import {
|
||||
createSystemDirective,
|
||||
SystemDirectiveTypes,
|
||||
} from "../../shared/system-directive"
|
||||
clearCompactionAgentConfigCheckpoint,
|
||||
setCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { log } from "../../shared/logger"
|
||||
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
|
||||
import { resolveSessionPromptConfig } from "./session-prompt-config-resolver"
|
||||
import { finalizeTrackedAssistantMessage, shouldTreatAssistantPartAsOutput, trackAssistantOutput, type TailMonitorState } from "./tail-monitor"
|
||||
import { resolveSessionID } from "./session-id"
|
||||
import type { CompactionContextClient, CompactionContextInjector } from "./types"
|
||||
import { createRecoveryLogic } from "./recovery"
|
||||
|
||||
const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
|
||||
export function createCompactionContextInjector(options?: {
|
||||
ctx?: CompactionContextClient
|
||||
backgroundManager?: BackgroundManager
|
||||
}): CompactionContextInjector {
|
||||
const ctx = options?.ctx
|
||||
const backgroundManager = options?.backgroundManager
|
||||
const tailStates = new Map<string, TailMonitorState>()
|
||||
|
||||
When summarizing this session, you MUST include the following sections in your summary:
|
||||
const getTailState = (sessionID: string): TailMonitorState => {
|
||||
const existing = tailStates.get(sessionID)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
## 1. User Requests (As-Is)
|
||||
- List all original user requests exactly as they were stated
|
||||
- Preserve the user's exact wording and intent
|
||||
const created: TailMonitorState = {
|
||||
currentHasOutput: false,
|
||||
consecutiveNoTextMessages: 0,
|
||||
}
|
||||
tailStates.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
|
||||
## 2. Final Goal
|
||||
- What the user ultimately wanted to achieve
|
||||
- The end result or deliverable expected
|
||||
const { recoverCheckpointedAgentConfig, maybeWarnAboutNoTextTail } = createRecoveryLogic(ctx, getTailState)
|
||||
|
||||
## 3. Work Completed
|
||||
- What has been done so far
|
||||
- Files created/modified
|
||||
- Features implemented
|
||||
- Problems solved
|
||||
const capture = async (sessionID: string): Promise<void> => {
|
||||
if (!ctx || !sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
## 4. Remaining Tasks
|
||||
- What still needs to be done
|
||||
- Pending items from the original request
|
||||
- Follow-up tasks identified during the work
|
||||
const promptConfig = await resolveSessionPromptConfig(ctx, sessionID)
|
||||
if (!promptConfig.agent && !promptConfig.model && !promptConfig.tools) {
|
||||
return
|
||||
}
|
||||
|
||||
## 5. Active Working Context (For Seamless Continuation)
|
||||
- **Files**: Paths of files currently being edited or frequently referenced
|
||||
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
|
||||
- **External References**: Documentation URLs, library APIs, or external resources being consulted
|
||||
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
|
||||
setCompactionAgentConfigCheckpoint(sessionID, promptConfig)
|
||||
log(`[compaction-context-injector] Captured agent checkpoint before compaction`, {
|
||||
sessionID,
|
||||
agent: promptConfig.agent,
|
||||
model: promptConfig.model,
|
||||
hasTools: !!promptConfig.tools,
|
||||
})
|
||||
}
|
||||
|
||||
## 6. Explicit Constraints (Verbatim Only)
|
||||
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
|
||||
- Quote constraints verbatim (do not paraphrase)
|
||||
- Do NOT invent, add, or modify constraints
|
||||
- If no explicit constraints exist, write "None"
|
||||
|
||||
## 7. Agent Verification State (Critical for Reviewers)
|
||||
- **Current Agent**: What agent is running (momus, oracle, etc.)
|
||||
- **Verification Progress**: Files already verified/validated
|
||||
- **Pending Verifications**: Files still needing verification
|
||||
- **Previous Rejections**: If reviewer agent, what was rejected and why
|
||||
- **Acceptance Status**: Current state of review process
|
||||
|
||||
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
|
||||
|
||||
## 8. Delegated Agent Sessions
|
||||
- List ALL background agent tasks spawned during this session
|
||||
- For each: agent name, category, status, description, and **session_id**
|
||||
- **RESUME, DON'T RESTART.** Each listed session retains full context. After compaction, use \`session_id\` to continue existing agent sessions instead of spawning new ones. This saves tokens, preserves learned context, and prevents duplicate work.
|
||||
|
||||
This context is critical for maintaining continuity after compaction.
|
||||
`
|
||||
|
||||
export function createCompactionContextInjector(backgroundManager?: BackgroundManager) {
|
||||
return (sessionID?: string): string => {
|
||||
const inject = (sessionID?: string): string => {
|
||||
let prompt = COMPACTION_CONTEXT_PROMPT
|
||||
|
||||
if (backgroundManager && sessionID) {
|
||||
@@ -69,4 +66,99 @@ export function createCompactionContextInjector(backgroundManager?: BackgroundMa
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
const event = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (sessionID) {
|
||||
clearCompactionAgentConfigCheckpoint(sessionID)
|
||||
tailStates.delete(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const noTextCount = finalizeTrackedAssistantMessage(getTailState(sessionID))
|
||||
if (noTextCount > 0) {
|
||||
await maybeWarnAboutNoTextTail(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const tailState = getTailState(sessionID)
|
||||
finalizeTrackedAssistantMessage(tailState)
|
||||
tailState.lastCompactedAt = Date.now()
|
||||
await maybeWarnAboutNoTextTail(sessionID)
|
||||
await recoverCheckpointedAgentConfig(sessionID, "session.compacted")
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
} | undefined
|
||||
|
||||
if (!info?.sessionID || info.role !== "assistant" || !info.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const tailState = getTailState(info.sessionID)
|
||||
if (tailState.currentMessageID && tailState.currentMessageID !== info.id) {
|
||||
finalizeTrackedAssistantMessage(tailState)
|
||||
await maybeWarnAboutNoTextTail(info.sessionID)
|
||||
}
|
||||
|
||||
if (tailState.currentMessageID !== info.id) {
|
||||
tailState.currentMessageID = info.id
|
||||
tailState.currentHasOutput = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.part.delta") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const messageID = props?.messageID as string | undefined
|
||||
const field = props?.field as string | undefined
|
||||
const delta = props?.delta as string | undefined
|
||||
|
||||
if (!sessionID || field !== "text" || !delta?.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
trackAssistantOutput(getTailState(sessionID), messageID)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = props?.part as {
|
||||
messageID?: string
|
||||
sessionID?: string
|
||||
type?: string
|
||||
text?: string
|
||||
} | undefined
|
||||
|
||||
if (!part?.sessionID || !shouldTreatAssistantPartAsOutput(part)) {
|
||||
return
|
||||
}
|
||||
|
||||
trackAssistantOutput(getTailState(part.sessionID), part.messageID)
|
||||
}
|
||||
}
|
||||
|
||||
return { capture, inject, event }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,27 @@ mock.module("../../shared/system-directive", () => ({
|
||||
import { createCompactionContextInjector } from "./index"
|
||||
import { TaskHistory } from "../../features/background-agent/task-history"
|
||||
|
||||
function createMockContext(
|
||||
messageResponses: Array<Array<{ info?: Record<string, unknown> }>>,
|
||||
promptAsyncMock = mock(async () => ({})),
|
||||
) {
|
||||
let callIndex = 0
|
||||
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: mock(async () => {
|
||||
const response = messageResponses[Math.min(callIndex, messageResponses.length - 1)] ?? []
|
||||
callIndex += 1
|
||||
return { data: response }
|
||||
}),
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
describe("createCompactionContextInjector", () => {
|
||||
describe("Agent Verification State preservation", () => {
|
||||
it("includes Agent Verification State section in compaction prompt", async () => {
|
||||
@@ -24,7 +45,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Agent Verification State")
|
||||
@@ -37,7 +58,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Previous Rejections")
|
||||
@@ -50,7 +71,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Pending Verifications")
|
||||
@@ -63,7 +84,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Explicit Constraints (Verbatim Only)")
|
||||
@@ -77,7 +98,7 @@ describe("createCompactionContextInjector", () => {
|
||||
const injector = createCompactionContextInjector()
|
||||
|
||||
//#when
|
||||
const prompt = injector()
|
||||
const prompt = injector.inject()
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Delegated Agent Sessions")
|
||||
@@ -89,10 +110,10 @@ describe("createCompactionContextInjector", () => {
|
||||
//#given
|
||||
const mockManager = { taskHistory: new TaskHistory() } as any
|
||||
mockManager.taskHistory.record("ses_parent", { id: "t1", sessionID: "ses_child", agent: "explore", description: "Find patterns", status: "completed", category: "quick" })
|
||||
const injector = createCompactionContextInjector(mockManager)
|
||||
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
|
||||
|
||||
//#when
|
||||
const prompt = injector("ses_parent")
|
||||
const prompt = injector.inject("ses_parent")
|
||||
|
||||
//#then
|
||||
expect(prompt).toContain("Active/Recent Delegated Sessions")
|
||||
@@ -104,13 +125,152 @@ describe("createCompactionContextInjector", () => {
|
||||
it("does not inject task history section when no entries exist", async () => {
|
||||
//#given
|
||||
const mockManager = { taskHistory: new TaskHistory() } as any
|
||||
const injector = createCompactionContextInjector(mockManager)
|
||||
const injector = createCompactionContextInjector({ backgroundManager: mockManager })
|
||||
|
||||
//#when
|
||||
const prompt = injector("ses_empty")
|
||||
const prompt = injector.inject("ses_empty")
|
||||
|
||||
//#then
|
||||
expect(prompt).not.toContain("Active/Recent Delegated Sessions")
|
||||
})
|
||||
})
|
||||
|
||||
describe("agent checkpoint recovery", () => {
|
||||
it("re-injects checkpointed agent config after compaction when latest agent is lost", async () => {
|
||||
//#given
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: "allow" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
promptAsyncMock,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
//#when
|
||||
await injector.capture("ses_checkpoint")
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID: "ses_checkpoint" } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncMock).toHaveBeenCalledWith({
|
||||
path: { id: "ses_checkpoint" },
|
||||
body: {
|
||||
noReply: true,
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: expect.stringContaining("restore checkpointed session agent configuration"),
|
||||
},
|
||||
],
|
||||
},
|
||||
query: { directory: "/tmp/test" },
|
||||
})
|
||||
})
|
||||
|
||||
it("recovers after five consecutive assistant messages with no text", async () => {
|
||||
//#given
|
||||
const promptAsyncMock = mock(async () => ({}))
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
promptAsyncMock,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
await injector.capture("ses_no_text_tail")
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID: "ses_no_text_tail" } },
|
||||
})
|
||||
|
||||
//#when
|
||||
for (let index = 1; index <= 5; index++) {
|
||||
await injector.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: `msg_${index}`,
|
||||
role: "assistant",
|
||||
sessionID: "ses_no_text_tail",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
await injector.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "ses_no_text_tail" } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(promptAsyncMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: { id: "ses_no_text_tail" },
|
||||
body: expect.objectContaining({
|
||||
noReply: true,
|
||||
agent: "atlas",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
|
||||
export type RecoveryPromptConfig = CompactionAgentConfigCheckpoint & {
|
||||
agent: string
|
||||
}
|
||||
|
||||
function isCompactionAgent(agent: string | undefined): boolean {
|
||||
return agent?.trim().toLowerCase() === "compaction"
|
||||
}
|
||||
|
||||
function matchesExpectedModel(
|
||||
actualModel: CompactionAgentConfigCheckpoint["model"],
|
||||
expectedModel: CompactionAgentConfigCheckpoint["model"],
|
||||
): boolean {
|
||||
if (!expectedModel) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
actualModel?.providerID === expectedModel.providerID &&
|
||||
actualModel.modelID === expectedModel.modelID
|
||||
)
|
||||
}
|
||||
|
||||
function matchesExpectedTools(
|
||||
actualTools: CompactionAgentConfigCheckpoint["tools"],
|
||||
expectedTools: CompactionAgentConfigCheckpoint["tools"],
|
||||
): boolean {
|
||||
if (!expectedTools) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!actualTools) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expectedEntries = Object.entries(expectedTools)
|
||||
if (expectedEntries.length !== Object.keys(actualTools).length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return expectedEntries.every(
|
||||
([toolName, isAllowed]) => actualTools[toolName] === isAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
export function createExpectedRecoveryPromptConfig(
|
||||
checkpoint: Pick<RecoveryPromptConfig, "agent"> & CompactionAgentConfigCheckpoint,
|
||||
currentPromptConfig: CompactionAgentConfigCheckpoint,
|
||||
): RecoveryPromptConfig {
|
||||
const model = checkpoint.model ?? currentPromptConfig.model
|
||||
const tools = checkpoint.tools ?? currentPromptConfig.tools
|
||||
|
||||
return {
|
||||
agent: checkpoint.agent,
|
||||
...(model ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromptConfigRecovered(
|
||||
actualPromptConfig: CompactionAgentConfigCheckpoint,
|
||||
expectedPromptConfig: RecoveryPromptConfig,
|
||||
): boolean {
|
||||
const actualAgent = actualPromptConfig.agent
|
||||
const agentMatches =
|
||||
typeof actualAgent === "string" &&
|
||||
!isCompactionAgent(actualAgent) &&
|
||||
actualAgent.toLowerCase() === expectedPromptConfig.agent.toLowerCase()
|
||||
|
||||
return (
|
||||
agentMatches &&
|
||||
matchesExpectedModel(actualPromptConfig.model, expectedPromptConfig.model) &&
|
||||
matchesExpectedTools(actualPromptConfig.tools, expectedPromptConfig.tools)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { createCompactionContextInjector } from "./index"
|
||||
|
||||
type SessionMessageResponse = Array<{
|
||||
info?: Record<string, unknown>
|
||||
}>
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path: { id: string }
|
||||
body: {
|
||||
noReply?: boolean
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, boolean>
|
||||
parts: Array<{ type: "text"; text: string }>
|
||||
}
|
||||
query?: { directory: string }
|
||||
}
|
||||
|
||||
function createPromptAsyncRecorder(): {
|
||||
calls: PromptAsyncInput[]
|
||||
promptAsync: (input: PromptAsyncInput) => Promise<Record<string, never>>
|
||||
} {
|
||||
const calls: PromptAsyncInput[] = []
|
||||
|
||||
return {
|
||||
calls,
|
||||
promptAsync: async (input: PromptAsyncInput) => {
|
||||
calls.push(input)
|
||||
return {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
messageResponses: SessionMessageResponse[],
|
||||
promptAsync: (input: PromptAsyncInput) => Promise<Record<string, never>>,
|
||||
) {
|
||||
let callIndex = 0
|
||||
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => {
|
||||
const response =
|
||||
messageResponses[Math.min(callIndex, messageResponses.length - 1)] ?? []
|
||||
callIndex += 1
|
||||
return { data: response }
|
||||
},
|
||||
promptAsync,
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
function createAssistantMessageUpdatedEvent(sessionID: string, messageID: string) {
|
||||
return {
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: messageID,
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
}
|
||||
|
||||
function createMeaningfulPartUpdatedEvent(
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
type: "reasoning" | "tool_use",
|
||||
) {
|
||||
return {
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
messageID,
|
||||
sessionID,
|
||||
type,
|
||||
...(type === "reasoning" ? { text: "thinking" } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
}
|
||||
|
||||
describe("createCompactionContextInjector recovery", () => {
|
||||
it("re-injects after compaction when agent and model match but tools are missing", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
//#when
|
||||
await injector.capture("ses_missing_tools")
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID: "ses_missing_tools" } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(1)
|
||||
expect(promptAsyncRecorder.calls[0]?.body.agent).toBe("atlas")
|
||||
expect(promptAsyncRecorder.calls[0]?.body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
})
|
||||
expect(promptAsyncRecorder.calls[0]?.body.tools).toEqual({ bash: true })
|
||||
})
|
||||
|
||||
it("retries recovery when the recovered prompt config still mismatches expected model or tools", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const mismatchResponse = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-4.1" },
|
||||
},
|
||||
},
|
||||
]
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
mismatchResponse,
|
||||
],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
//#when
|
||||
await injector.capture("ses_retry_incomplete_recovery")
|
||||
await injector.event({
|
||||
event: {
|
||||
type: "session.compacted",
|
||||
properties: { sessionID: "ses_retry_incomplete_recovery" },
|
||||
},
|
||||
})
|
||||
await injector.event({
|
||||
event: {
|
||||
type: "session.compacted",
|
||||
properties: { sessionID: "ses_retry_incomplete_recovery" },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(2)
|
||||
})
|
||||
|
||||
it("does not treat reasoning-only assistant messages as a no-text tail", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const matchingPromptConfig = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
]
|
||||
const ctx = createMockContext(
|
||||
[matchingPromptConfig, matchingPromptConfig, matchingPromptConfig],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
const sessionID = "ses_reasoning_tail"
|
||||
|
||||
await injector.capture(sessionID)
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID } },
|
||||
})
|
||||
|
||||
//#when
|
||||
for (let index = 1; index <= 5; index++) {
|
||||
const messageID = `msg_reasoning_${index}`
|
||||
await injector.event(createAssistantMessageUpdatedEvent(sessionID, messageID))
|
||||
await injector.event(
|
||||
createMeaningfulPartUpdatedEvent(sessionID, messageID, "reasoning"),
|
||||
)
|
||||
await injector.event({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it("does not treat tool_use-only assistant messages as a no-text tail", async () => {
|
||||
//#given
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
const matchingPromptConfig = [
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
]
|
||||
const ctx = createMockContext(
|
||||
[matchingPromptConfig, matchingPromptConfig, matchingPromptConfig],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
const sessionID = "ses_tool_use_tail"
|
||||
|
||||
await injector.capture(sessionID)
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID } },
|
||||
})
|
||||
|
||||
//#when
|
||||
for (let index = 1; index <= 5; index++) {
|
||||
const messageID = `msg_tool_use_${index}`
|
||||
await injector.event(createAssistantMessageUpdatedEvent(sessionID, messageID))
|
||||
await injector.event(
|
||||
createMeaningfulPartUpdatedEvent(sessionID, messageID, "tool_use"),
|
||||
)
|
||||
await injector.event({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it("falls back to the current non-compaction model when a checkpoint model is poisoned", async () => {
|
||||
//#given
|
||||
const sessionID = "ses_poisoned_checkpoint_model"
|
||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||
setCompactionAgentConfigCheckpoint(sessionID, {
|
||||
agent: "atlas",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
const ctx = createMockContext(
|
||||
[
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
info: {
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
promptAsyncRecorder.promptAsync,
|
||||
)
|
||||
const injector = createCompactionContextInjector({ ctx })
|
||||
|
||||
//#when
|
||||
await injector.event({
|
||||
event: { type: "session.compacted", properties: { sessionID } },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(promptAsyncRecorder.calls.length).toBe(1)
|
||||
expect(promptAsyncRecorder.calls[0]?.body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import {
|
||||
getCompactionAgentConfigCheckpoint,
|
||||
} from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
|
||||
import { log } from "../../shared/logger"
|
||||
import { setSessionModel } from "../../shared/session-model-state"
|
||||
import { setSessionTools } from "../../shared/session-tools-store"
|
||||
import {
|
||||
createExpectedRecoveryPromptConfig,
|
||||
isPromptConfigRecovered,
|
||||
} from "./recovery-prompt-config"
|
||||
import { validateCheckpointModel } from "./validated-model"
|
||||
import {
|
||||
resolveLatestSessionPromptConfig,
|
||||
resolveSessionPromptConfig,
|
||||
} from "./session-prompt-config-resolver"
|
||||
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
|
||||
import type { CompactionContextClient } from "./types"
|
||||
import type { TailMonitorState } from "./tail-monitor"
|
||||
|
||||
export function createRecoveryLogic(
|
||||
ctx: CompactionContextClient | undefined,
|
||||
getTailState: (sessionID: string) => TailMonitorState,
|
||||
) {
|
||||
const recoverCheckpointedAgentConfig = async (
|
||||
sessionID: string,
|
||||
reason: "session.compacted" | "no-text-tail",
|
||||
): Promise<boolean> => {
|
||||
if (!ctx) {
|
||||
return false
|
||||
}
|
||||
|
||||
const checkpoint = getCompactionAgentConfigCheckpoint(sessionID)
|
||||
if (!checkpoint?.agent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tailState = getTailState(sessionID)
|
||||
const now = Date.now()
|
||||
if (tailState.lastRecoveryAt && now - tailState.lastRecoveryAt < RECOVERY_COOLDOWN_MS) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentPromptConfig = await resolveSessionPromptConfig(ctx, sessionID)
|
||||
const validatedCheckpointModel = validateCheckpointModel(
|
||||
checkpoint.model,
|
||||
currentPromptConfig.model,
|
||||
)
|
||||
const { model: checkpointModel, ...checkpointWithoutModel } = checkpoint
|
||||
const checkpointWithAgent = {
|
||||
...checkpointWithoutModel,
|
||||
agent: checkpoint.agent,
|
||||
...(validatedCheckpointModel ? { model: validatedCheckpointModel } : {}),
|
||||
}
|
||||
|
||||
if (checkpointModel && !validatedCheckpointModel) {
|
||||
log(`[compaction-context-injector] Ignoring checkpoint model that disagrees with current prompt config`, {
|
||||
sessionID,
|
||||
checkpointModel,
|
||||
currentModel: currentPromptConfig.model,
|
||||
})
|
||||
}
|
||||
|
||||
const expectedPromptConfig = createExpectedRecoveryPromptConfig(
|
||||
checkpointWithAgent,
|
||||
currentPromptConfig,
|
||||
)
|
||||
const model = expectedPromptConfig.model
|
||||
const tools = expectedPromptConfig.tools
|
||||
|
||||
if (reason === "session.compacted") {
|
||||
const latestPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
||||
if (isPromptConfigRecovered(latestPromptConfig, expectedPromptConfig)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: true,
|
||||
agent: expectedPromptConfig.agent,
|
||||
...(model ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
const recoveredPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
||||
if (!isPromptConfigRecovered(recoveredPromptConfig, expectedPromptConfig)) {
|
||||
log(`[compaction-context-injector] Re-injected agent config but recovery is still incomplete`, {
|
||||
sessionID,
|
||||
reason,
|
||||
agent: expectedPromptConfig.agent,
|
||||
model,
|
||||
hasTools: !!tools,
|
||||
recoveredPromptConfig,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
updateSessionAgent(sessionID, expectedPromptConfig.agent)
|
||||
if (model) {
|
||||
setSessionModel(sessionID, model)
|
||||
}
|
||||
if (tools) {
|
||||
setSessionTools(sessionID, tools)
|
||||
}
|
||||
|
||||
tailState.lastRecoveryAt = now
|
||||
tailState.consecutiveNoTextMessages = 0
|
||||
|
||||
log(`[compaction-context-injector] Re-injected checkpointed agent config`, {
|
||||
sessionID,
|
||||
reason,
|
||||
agent: expectedPromptConfig.agent,
|
||||
model,
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log(`[compaction-context-injector] Failed to re-inject checkpointed agent config`, {
|
||||
sessionID,
|
||||
reason,
|
||||
error: String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const maybeWarnAboutNoTextTail = async (sessionID: string): Promise<void> => {
|
||||
const tailState = getTailState(sessionID)
|
||||
if (tailState.consecutiveNoTextMessages < NO_TEXT_TAIL_THRESHOLD) {
|
||||
return
|
||||
}
|
||||
|
||||
const recentlyCompacted =
|
||||
tailState.lastCompactedAt !== undefined &&
|
||||
Date.now() - tailState.lastCompactedAt < RECENT_COMPACTION_WINDOW_MS
|
||||
|
||||
log(`[compaction-context-injector] Detected consecutive assistant messages with no text`, {
|
||||
sessionID,
|
||||
consecutiveNoTextMessages: tailState.consecutiveNoTextMessages,
|
||||
recentlyCompacted,
|
||||
})
|
||||
|
||||
if (recentlyCompacted) {
|
||||
await recoverCheckpointedAgentConfig(sessionID, "no-text-tail")
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
recoverCheckpointedAgentConfig,
|
||||
maybeWarnAboutNoTextTail,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function isCompactionAgent(agent: string | undefined): boolean {
|
||||
return agent?.trim().toLowerCase() === "compaction"
|
||||
}
|
||||
|
||||
export function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
return (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
|
||||
import { _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import { clearSessionModel, setSessionModel } from "../../shared/session-model-state"
|
||||
import { clearSessionTools } from "../../shared/session-tools-store"
|
||||
import {
|
||||
resolveLatestSessionPromptConfig,
|
||||
resolveSessionPromptConfig,
|
||||
} from "./session-prompt-config-resolver"
|
||||
|
||||
type SessionMessage = {
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
}
|
||||
}
|
||||
|
||||
function createMockContext(messages: SessionMessage[]) {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: messages }),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
describe("session prompt config resolver", () => {
|
||||
const sessionID = "ses_compaction_model_validation"
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTesting()
|
||||
clearSessionModel(sessionID)
|
||||
clearSessionTools()
|
||||
})
|
||||
|
||||
it("prefers the latest non-compaction model over poisoned session state", async () => {
|
||||
// given
|
||||
setSessionModel(sessionID, {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-1",
|
||||
})
|
||||
const ctx = createMockContext([
|
||||
{
|
||||
info: {
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: "allow" },
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// when
|
||||
const promptConfig = await resolveSessionPromptConfig(ctx, sessionID)
|
||||
|
||||
// then
|
||||
expect(promptConfig).toEqual({
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
tools: { bash: true },
|
||||
})
|
||||
})
|
||||
|
||||
it("omits a compaction model from the latest prompt config", async () => {
|
||||
// given
|
||||
const ctx = createMockContext([
|
||||
{
|
||||
info: {
|
||||
agent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
agent: "compaction",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// when
|
||||
const promptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
||||
|
||||
// then
|
||||
expect(promptConfig).toEqual({ agent: "compaction" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import type { CompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { log } from "../../shared/logger"
|
||||
import { normalizeSDKResponse } from "../../shared/normalize-sdk-response"
|
||||
import { normalizePromptTools } from "../../shared/prompt-tools"
|
||||
import { getSessionModel } from "../../shared/session-model-state"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
import { isCompactionAgent } from "./session-id"
|
||||
import { resolveValidatedModel } from "./validated-model"
|
||||
|
||||
type SessionMessage = {
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
tools?: Record<string, boolean | "allow" | "deny" | "ask">
|
||||
}
|
||||
}
|
||||
|
||||
type ResolverContext = {
|
||||
client: {
|
||||
session: {
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
directory: string
|
||||
}
|
||||
|
||||
export async function resolveSessionPromptConfig(
|
||||
ctx: ResolverContext,
|
||||
sessionID: string,
|
||||
): Promise<CompactionAgentConfigCheckpoint> {
|
||||
const storedModel = getSessionModel(sessionID)
|
||||
const promptConfig: CompactionAgentConfigCheckpoint = {
|
||||
agent: getSessionAgent(sessionID),
|
||||
tools: getSessionTools(sessionID),
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const info = messages[index].info
|
||||
|
||||
if (!promptConfig.agent && info?.agent && !isCompactionAgent(info.agent)) {
|
||||
promptConfig.agent = info.agent
|
||||
}
|
||||
|
||||
if (!promptConfig.model) {
|
||||
const model = resolveValidatedModel(info)
|
||||
if (model) {
|
||||
promptConfig.model = model
|
||||
}
|
||||
}
|
||||
|
||||
if (!promptConfig.tools) {
|
||||
const tools = normalizePromptTools(info?.tools)
|
||||
if (tools) {
|
||||
promptConfig.tools = tools
|
||||
}
|
||||
}
|
||||
|
||||
if (promptConfig.agent && promptConfig.model && promptConfig.tools) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log("[compaction-context-injector] Failed to resolve prompt config from messages", {
|
||||
sessionID,
|
||||
directory: ctx.directory,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
|
||||
if (!promptConfig.model && storedModel) {
|
||||
promptConfig.model = storedModel
|
||||
}
|
||||
|
||||
return promptConfig
|
||||
}
|
||||
|
||||
export async function resolveLatestSessionPromptConfig(
|
||||
ctx: ResolverContext,
|
||||
sessionID: string,
|
||||
): Promise<CompactionAgentConfigCheckpoint> {
|
||||
try {
|
||||
const response = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = normalizeSDKResponse(response, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
const latestInfo = messages.at(-1)?.info
|
||||
|
||||
if (!latestInfo) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const model = resolveValidatedModel(latestInfo)
|
||||
const tools = normalizePromptTools(latestInfo.tools)
|
||||
|
||||
return {
|
||||
...(latestInfo.agent ? { agent: latestInfo.agent } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
}
|
||||
} catch (error) {
|
||||
log("[compaction-context-injector] Failed to resolve latest prompt config", {
|
||||
sessionID,
|
||||
directory: ctx.directory,
|
||||
error: String(error),
|
||||
})
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const MEANINGFUL_ASSISTANT_PART_TYPES = new Set([
|
||||
"reasoning",
|
||||
"tool",
|
||||
"tool_use",
|
||||
])
|
||||
|
||||
export type TailMonitorState = {
|
||||
currentMessageID?: string
|
||||
currentHasOutput: boolean
|
||||
consecutiveNoTextMessages: number
|
||||
lastCompactedAt?: number
|
||||
lastRecoveryAt?: number
|
||||
}
|
||||
|
||||
export function finalizeTrackedAssistantMessage(
|
||||
state: TailMonitorState,
|
||||
): number {
|
||||
if (!state.currentMessageID) {
|
||||
return state.consecutiveNoTextMessages
|
||||
}
|
||||
|
||||
state.consecutiveNoTextMessages = state.currentHasOutput
|
||||
? 0
|
||||
: state.consecutiveNoTextMessages + 1
|
||||
state.currentMessageID = undefined
|
||||
state.currentHasOutput = false
|
||||
|
||||
return state.consecutiveNoTextMessages
|
||||
}
|
||||
|
||||
export function shouldTreatAssistantPartAsOutput(part: {
|
||||
type?: string
|
||||
text?: string
|
||||
}): boolean {
|
||||
if (part.type === "text") {
|
||||
return !!part.text?.trim()
|
||||
}
|
||||
|
||||
return typeof part.type === "string" && MEANINGFUL_ASSISTANT_PART_TYPES.has(part.type)
|
||||
}
|
||||
|
||||
export function trackAssistantOutput(
|
||||
state: TailMonitorState,
|
||||
messageID?: string,
|
||||
): void {
|
||||
if (messageID && !state.currentMessageID) {
|
||||
state.currentMessageID = messageID
|
||||
}
|
||||
|
||||
state.currentHasOutput = true
|
||||
state.consecutiveNoTextMessages = 0
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface CompactionContextInjector {
|
||||
capture: (sessionID: string) => Promise<void>
|
||||
inject: (sessionID?: string) => string
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
}
|
||||
|
||||
export type CompactionContextClient = {
|
||||
client: {
|
||||
session: {
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: {
|
||||
noReply?: boolean
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, boolean>
|
||||
parts: Array<{ type: "text"; text: string }>
|
||||
}
|
||||
query?: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
directory: string
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||
import { isCompactionAgent } from "./session-id"
|
||||
|
||||
type PromptConfigInfo = {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export function resolveValidatedModel(
|
||||
info: PromptConfigInfo | undefined,
|
||||
): CompactionAgentConfigCheckpoint["model"] | undefined {
|
||||
if (isCompactionAgent(info?.agent)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const providerID = info?.model?.providerID ?? info?.providerID
|
||||
const modelID = info?.model?.modelID ?? info?.modelID
|
||||
|
||||
if (!providerID || !modelID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { providerID, modelID }
|
||||
}
|
||||
|
||||
export function validateCheckpointModel(
|
||||
checkpointModel: CompactionAgentConfigCheckpoint["model"],
|
||||
currentModel: CompactionAgentConfigCheckpoint["model"],
|
||||
): CompactionAgentConfigCheckpoint["model"] | undefined {
|
||||
if (!checkpointModel) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!currentModel) {
|
||||
return checkpointModel
|
||||
}
|
||||
|
||||
return checkpointModel.providerID === currentModel.providerID &&
|
||||
checkpointModel.modelID === currentModel.modelID
|
||||
? checkpointModel
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createContextWindowMonitorHook } from "./context-window-monitor"
|
||||
|
||||
function createOutput() {
|
||||
return { title: "", output: "original", metadata: null }
|
||||
}
|
||||
|
||||
describe("context-window-monitor modelContextLimitsCache", () => {
|
||||
it("does not append reminder below cached non-anthropic threshold", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_non_anthropic_below_threshold"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "opencode",
|
||||
modelID: "kimi-k2.5-free",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 150000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
|
||||
it("appends reminder above cached non-anthropic threshold", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_non_anthropic_above_threshold"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "opencode",
|
||||
modelID: "kimi-k2.5-free",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 180000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("context remaining")
|
||||
expect(output.output).toContain("262,144-token context window")
|
||||
expect(output.output).toContain("[Context Status: 72.5% used (190,000/262,144 tokens), 27.5% remaining]")
|
||||
expect(output.output).not.toContain("1,000,000")
|
||||
})
|
||||
|
||||
describe("#given Anthropic provider with cached context limit and 1M mode enabled", () => {
|
||||
describe("#when cached usage would exceed 200K but stay below 1M", () => {
|
||||
it("#then should ignore the cached limit and skip the reminder", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 200000)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: true,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_anthropic_1m_overrides_cached_limit"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 300000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Anthropic provider with cached context limit and 1M mode disabled", () => {
|
||||
describe("#when cached usage exceeds the Anthropic default limit", () => {
|
||||
it("#then should ignore the cached limit and append the reminder from the default Anthropic limit", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_anthropic_default_overrides_cached_limit"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 150000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const output = createOutput()
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("context remaining")
|
||||
expect(output.output).toContain("200,000-token context window")
|
||||
expect(output.output).not.toContain("500,000-token context window")
|
||||
expect(output.output).not.toContain("1,000,000-token context window")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,21 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
resolveActualContextLimit,
|
||||
type ContextLimitModelCacheState,
|
||||
} from "../shared/context-limit-resolver"
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
|
||||
|
||||
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
|
||||
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
|
||||
type ModelCacheStateLike = {
|
||||
anthropicContext1MEnabled: boolean
|
||||
}
|
||||
function createContextReminder(actualLimit: number): string {
|
||||
const limitTokens = actualLimit.toLocaleString()
|
||||
|
||||
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
|
||||
return (modelCacheState?.anthropicContext1MEnabled ?? false) ||
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
|
||||
}
|
||||
return `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
|
||||
|
||||
const CONTEXT_REMINDER = `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
|
||||
|
||||
You are using Anthropic Claude with 1M context window.
|
||||
You have plenty of context remaining - do NOT rush or skip tasks.
|
||||
You are using a ${limitTokens}-token context window.
|
||||
You still have context remaining - do NOT rush or skip tasks.
|
||||
Complete your work thoroughly and methodically.`
|
||||
}
|
||||
|
||||
interface TokenInfo {
|
||||
input: number
|
||||
@@ -32,16 +26,13 @@ interface TokenInfo {
|
||||
|
||||
interface CachedTokenState {
|
||||
providerID: string
|
||||
modelID: string
|
||||
tokens: TokenInfo
|
||||
}
|
||||
|
||||
function isAnthropicProvider(providerID: string): boolean {
|
||||
return providerID === "anthropic" || providerID === "google-vertex-anthropic"
|
||||
}
|
||||
|
||||
export function createContextWindowMonitorHook(
|
||||
_ctx: PluginInput,
|
||||
modelCacheState?: ModelCacheStateLike,
|
||||
modelCacheState?: ContextLimitModelCacheState,
|
||||
) {
|
||||
const remindedSessions = new Set<string>()
|
||||
const tokenCache = new Map<string, CachedTokenState>()
|
||||
@@ -57,25 +48,29 @@ export function createContextWindowMonitorHook(
|
||||
const cached = tokenCache.get(sessionID)
|
||||
if (!cached) return
|
||||
|
||||
if (!isAnthropicProvider(cached.providerID)) return
|
||||
const actualLimit = resolveActualContextLimit(
|
||||
cached.providerID,
|
||||
cached.modelID,
|
||||
modelCacheState,
|
||||
)
|
||||
|
||||
if (!actualLimit) return
|
||||
|
||||
const lastTokens = cached.tokens
|
||||
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||
|
||||
const actualUsagePercentage =
|
||||
totalInputTokens / getAnthropicActualLimit(modelCacheState)
|
||||
const actualUsagePercentage = totalInputTokens / actualLimit
|
||||
|
||||
if (actualUsagePercentage < CONTEXT_WARNING_THRESHOLD) return
|
||||
|
||||
remindedSessions.add(sessionID)
|
||||
|
||||
const displayUsagePercentage = totalInputTokens / ANTHROPIC_DISPLAY_LIMIT
|
||||
const usedPct = (displayUsagePercentage * 100).toFixed(1)
|
||||
const remainingPct = ((1 - displayUsagePercentage) * 100).toFixed(1)
|
||||
const usedPct = (actualUsagePercentage * 100).toFixed(1)
|
||||
const remainingPct = ((1 - actualUsagePercentage) * 100).toFixed(1)
|
||||
const usedTokens = totalInputTokens.toLocaleString()
|
||||
const limitTokens = ANTHROPIC_DISPLAY_LIMIT.toLocaleString()
|
||||
const limitTokens = actualLimit.toLocaleString()
|
||||
|
||||
output.output += `\n\n${CONTEXT_REMINDER}
|
||||
output.output += `\n\n${createContextReminder(actualLimit)}
|
||||
[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]`
|
||||
}
|
||||
|
||||
@@ -95,6 +90,7 @@ export function createContextWindowMonitorHook(
|
||||
role?: string
|
||||
sessionID?: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
finish?: boolean
|
||||
tokens?: TokenInfo
|
||||
} | undefined
|
||||
@@ -104,6 +100,7 @@ export function createContextWindowMonitorHook(
|
||||
|
||||
tokenCache.set(info.sessionID, {
|
||||
providerID: info.providerID,
|
||||
modelID: info.modelID ?? "",
|
||||
tokens: info.tokens,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
type TextPart = {
|
||||
type?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
type MessageInfo = {
|
||||
id?: string
|
||||
role?: string
|
||||
error?: unknown
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export type SessionMessage = {
|
||||
info?: MessageInfo
|
||||
parts?: TextPart[]
|
||||
}
|
||||
|
||||
export function getLastAssistantMessage(messages: SessionMessage[]): SessionMessage | null {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (messages[index].info?.role === "assistant") {
|
||||
return messages[index]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractAssistantText(message: SessionMessage): string {
|
||||
return (message.parts ?? [])
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function isGptAssistantMessage(message: SessionMessage): boolean {
|
||||
const modelID = message.info?.model?.modelID ?? message.info?.modelID
|
||||
return typeof modelID === "string" && modelID.toLowerCase().includes("gpt")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const HOOK_NAME = "gpt-permission-continuation"
|
||||
export const CONTINUATION_PROMPT = "continue"
|
||||
|
||||
export const DEFAULT_STALL_PATTERNS = [
|
||||
"if you want",
|
||||
"would you like",
|
||||
"shall i",
|
||||
"do you want me to",
|
||||
"let me know if",
|
||||
] as const
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DEFAULT_STALL_PATTERNS } from "./constants"
|
||||
|
||||
function getTrailingSegment(text: string): string {
|
||||
const normalized = text.trim().replace(/\s+/g, " ")
|
||||
if (!normalized) return ""
|
||||
|
||||
const sentenceParts = normalized.split(/(?<=[.!?])\s+/)
|
||||
return sentenceParts[sentenceParts.length - 1]?.trim().toLowerCase() ?? ""
|
||||
}
|
||||
|
||||
export function detectStallPattern(
|
||||
text: string,
|
||||
patterns: readonly string[] = DEFAULT_STALL_PATTERNS,
|
||||
): boolean {
|
||||
if (!text.trim()) return false
|
||||
|
||||
const tail = text.slice(-800)
|
||||
const lines = tail.split("\n").map((line) => line.trim()).filter(Boolean)
|
||||
const hotZone = lines.slice(-3).join(" ")
|
||||
const trailingSegment = getTrailingSegment(hotZone)
|
||||
|
||||
return patterns.some((pattern) => trailingSegment.startsWith(pattern.toLowerCase()))
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { createGptPermissionContinuationHook } from "."
|
||||
|
||||
type SessionMessage = {
|
||||
info: {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
modelID?: string
|
||||
}
|
||||
parts?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
function createMockPluginInput(messages: SessionMessage[]) {
|
||||
const promptCalls: string[] = []
|
||||
|
||||
const ctx = {
|
||||
directory: "/tmp/test",
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: messages }),
|
||||
prompt: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
return { ctx, promptCalls }
|
||||
}
|
||||
|
||||
describe("gpt-permission-continuation", () => {
|
||||
test("injects continue when the last GPT assistant reply asks for permission", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "I finished the analysis. If you want, I can apply the changes next." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
})
|
||||
|
||||
test("does not inject when the last assistant model is not GPT", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: {
|
||||
id: "msg-1",
|
||||
role: "assistant",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
},
|
||||
parts: [{ type: "text", text: "If you want, I can keep going." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject when the last assistant reply is not a stall pattern", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "I completed the refactor and all tests pass." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject when a permission phrase appears before the final sentence", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "If you want, I can keep going. The current work is complete." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject when continuation is stopped for the session", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "If you want, I can continue with the fix." }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx, {
|
||||
isContinuationStopped: (sessionID) => sessionID === "ses-1",
|
||||
})
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("does not inject twice for the same assistant message", async () => {
|
||||
// given
|
||||
const { ctx, promptCalls } = createMockPluginInput([
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "Would you like me to continue with the fix?" }],
|
||||
},
|
||||
])
|
||||
const hook = createGptPermissionContinuationHook(ctx)
|
||||
|
||||
// when
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: "ses-1" } } })
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
import {
|
||||
extractAssistantText,
|
||||
getLastAssistantMessage,
|
||||
isGptAssistantMessage,
|
||||
type SessionMessage,
|
||||
} from "./assistant-message"
|
||||
import { CONTINUATION_PROMPT, HOOK_NAME } from "./constants"
|
||||
import { detectStallPattern } from "./detector"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
|
||||
async function promptContinuation(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
parts: [{ type: "text" as const, text: CONTINUATION_PROMPT }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
}
|
||||
|
||||
if (typeof ctx.client.session.promptAsync === "function") {
|
||||
await ctx.client.session.promptAsync(payload)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.session.prompt(payload)
|
||||
}
|
||||
|
||||
export function createGptPermissionContinuationHandler(args: {
|
||||
ctx: PluginInput
|
||||
sessionStateStore: SessionStateStore
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||
const { ctx, sessionStateStore, isContinuationStopped } = args
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const properties = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = (properties?.info as { id?: string } | undefined)?.id
|
||||
if (sessionID) {
|
||||
sessionStateStore.cleanup(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type !== "session.idle") return
|
||||
|
||||
const sessionID = properties?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (isContinuationStopped?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
if (state.inFlight) {
|
||||
log(`[${HOOK_NAME}] Skipped: prompt already in flight`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const messagesResponse = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const messages = normalizeSDKResponse(messagesResponse, [] as SessionMessage[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
const lastAssistantMessage = getLastAssistantMessage(messages)
|
||||
if (!lastAssistantMessage) return
|
||||
|
||||
const messageID = lastAssistantMessage.info?.id
|
||||
if (messageID && state.lastHandledMessageID === messageID) {
|
||||
log(`[${HOOK_NAME}] Skipped: already handled assistant message`, { sessionID, messageID })
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAssistantMessage.info?.error) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message has error`, { sessionID, messageID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isGptAssistantMessage(lastAssistantMessage)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant model is not GPT`, { sessionID, messageID })
|
||||
return
|
||||
}
|
||||
|
||||
const assistantText = extractAssistantText(lastAssistantMessage)
|
||||
if (!detectStallPattern(assistantText)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.inFlight = true
|
||||
await promptContinuation(ctx, sessionID)
|
||||
state.lastHandledMessageID = messageID
|
||||
state.lastInjectedAt = Date.now()
|
||||
log(`[${HOOK_NAME}] Injected continuation prompt`, { sessionID, messageID })
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Failed to inject continuation prompt`, {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
} finally {
|
||||
state.inFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { createGptPermissionContinuationHandler } from "./handler"
|
||||
import { createSessionStateStore } from "./session-state"
|
||||
|
||||
export type GptPermissionContinuationHook = {
|
||||
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
wasRecentlyInjected: (sessionID: string) => boolean
|
||||
}
|
||||
|
||||
export function createGptPermissionContinuationHook(
|
||||
ctx: PluginInput,
|
||||
options?: {
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
},
|
||||
): GptPermissionContinuationHook {
|
||||
const sessionStateStore = createSessionStateStore()
|
||||
|
||||
return {
|
||||
handler: createGptPermissionContinuationHandler({
|
||||
ctx,
|
||||
sessionStateStore,
|
||||
isContinuationStopped: options?.isContinuationStopped,
|
||||
}),
|
||||
wasRecentlyInjected(sessionID: string): boolean {
|
||||
return sessionStateStore.wasRecentlyInjected(sessionID, 5_000)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
type SessionState = {
|
||||
inFlight: boolean
|
||||
lastHandledMessageID?: string
|
||||
lastInjectedAt?: number
|
||||
}
|
||||
|
||||
export type SessionStateStore = ReturnType<typeof createSessionStateStore>
|
||||
|
||||
export function createSessionStateStore() {
|
||||
const states = new Map<string, SessionState>()
|
||||
|
||||
const getState = (sessionID: string): SessionState => {
|
||||
const existing = states.get(sessionID)
|
||||
if (existing) return existing
|
||||
|
||||
const created: SessionState = {
|
||||
inFlight: false,
|
||||
}
|
||||
states.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
|
||||
return {
|
||||
getState,
|
||||
wasRecentlyInjected(sessionID: string, windowMs: number): boolean {
|
||||
const state = states.get(sessionID)
|
||||
if (!state?.lastInjectedAt) return false
|
||||
return Date.now() - state.lastInjectedAt <= windowMs
|
||||
},
|
||||
cleanup(sessionID: string): void {
|
||||
states.delete(sessionID)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { createTodoContinuationEnforcer } from "../todo-continuation-enforcer"
|
||||
import { createGptPermissionContinuationHook } from "."
|
||||
|
||||
describe("gpt-permission-continuation coordination", () => {
|
||||
test("injects only once when GPT permission continuation and todo continuation are both eligible", async () => {
|
||||
// given
|
||||
const promptCalls: string[] = []
|
||||
const toastCalls: string[] = []
|
||||
const sessionID = "ses-dual-continuation"
|
||||
const ctx = {
|
||||
directory: "/tmp/test",
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { id: "msg-1", role: "assistant", modelID: "gpt-5.4" },
|
||||
parts: [{ type: "text", text: "If you want, I can implement the fix next." }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
todo: async () => ({
|
||||
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
|
||||
}),
|
||||
prompt: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
|
||||
promptCalls.push(input.body.parts[0]?.text ?? "")
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (input: { body: { title: string } }) => {
|
||||
toastCalls.push(input.body.title)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const gptPermissionContinuation = createGptPermissionContinuationHook(ctx)
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx, {
|
||||
shouldSkipContinuation: (id) => gptPermissionContinuation.wasRecentlyInjected(id),
|
||||
})
|
||||
|
||||
// when
|
||||
await gptPermissionContinuation.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await todoContinuationEnforcer.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptCalls).toEqual(["continue"])
|
||||
expect(toastCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,7 @@ export { createCategorySkillReminderHook } from "./category-skill-reminder";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
export { createNoSisyphusGptHook } from "./no-sisyphus-gpt";
|
||||
export { createNoHephaestusNonGptHook } from "./no-hephaestus-non-gpt";
|
||||
export { createGptPermissionContinuationHook, type GptPermissionContinuationHook } from "./gpt-permission-continuation"
|
||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) {
|
||||
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
|
||||
if (typeof message["variant"] === "string") {
|
||||
return message["variant"]
|
||||
}
|
||||
|
||||
return typeof input.variant === "string" ? input.variant : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
@@ -21,6 +29,7 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
variant?: string
|
||||
},
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
@@ -72,15 +81,21 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
const runtimeVariant = getRuntimeVariant(input, output.message)
|
||||
const isRuntimeMax = runtimeVariant === "max"
|
||||
|
||||
output.message.variant = "max"
|
||||
log(`[keyword-detector] Ultrawork mode activated`, {
|
||||
sessionID: input.sessionID,
|
||||
runtimeVariant,
|
||||
})
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
message: isRuntimeMax
|
||||
? "Maximum precision engaged. All agents at your disposal."
|
||||
: "Runtime variant preserved. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
|
||||
@@ -169,8 +169,8 @@ describe("keyword-detector session filtering", () => {
|
||||
output
|
||||
)
|
||||
|
||||
// then - ultrawork should still work (variant set to max)
|
||||
expect(output.message.variant).toBe("max")
|
||||
// then - ultrawork should still work without forcing a new variant
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
@@ -214,12 +214,12 @@ describe("keyword-detector session filtering", () => {
|
||||
output
|
||||
)
|
||||
|
||||
// then - all keywords should work
|
||||
expect(output.message.variant).toBe("max")
|
||||
// then - all keywords should work without forcing a new variant
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should override existing variant when ultrawork keyword is used", async () => {
|
||||
test("should preserve existing runtime variant when ultrawork keyword is used", async () => {
|
||||
// given - main session set with pre-existing variant from TUI
|
||||
setMainSession("main-123")
|
||||
|
||||
@@ -236,8 +236,8 @@ describe("keyword-detector session filtering", () => {
|
||||
output
|
||||
)
|
||||
|
||||
// then - ultrawork should override TUI variant to max
|
||||
expect(output.message.variant).toBe("max")
|
||||
// then - ultrawork should preserve the already resolved runtime variant
|
||||
expect(output.message.variant).toBe("low")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
@@ -311,8 +311,8 @@ describe("keyword-detector word boundary", () => {
|
||||
output
|
||||
)
|
||||
|
||||
// then - ultrawork should be triggered
|
||||
expect(output.message.variant).toBe("max")
|
||||
// then - ultrawork should be triggered without forcing max
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
||||
|
||||
function createMockPluginInput(toastMessages: string[]) {
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { message: string } }) => {
|
||||
toastMessages.push(opts.body.message)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
describe("keyword-detector ultrawork runtime variant gating", () => {
|
||||
test("#given runtime max variant #when ultrawork activates #then maximum precision toast is preserved", async () => {
|
||||
// given
|
||||
_resetForTesting()
|
||||
setMainSession("main-session")
|
||||
const toastMessages: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(toastMessages))
|
||||
const output = {
|
||||
message: { variant: "max" } as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do it" }],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"]({ sessionID: "main-session", variant: "max" }, output)
|
||||
|
||||
// then
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastMessages).toEqual(["Maximum precision engaged. All agents at your disposal."])
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
test("#given runtime non-max variant #when ultrawork activates #then variant stays unchanged and toast does not claim max", async () => {
|
||||
// given
|
||||
_resetForTesting()
|
||||
setMainSession("main-session")
|
||||
const toastMessages: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(toastMessages))
|
||||
const output = {
|
||||
message: { variant: "medium" } as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do it" }],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"]({ sessionID: "main-session", variant: "medium" }, output)
|
||||
|
||||
// then
|
||||
expect(output.message.variant).toBe("medium")
|
||||
expect(toastMessages).toEqual(["Runtime variant preserved. All agents at your disposal."])
|
||||
_resetForTesting()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,13 @@
|
||||
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test"
|
||||
import { createSessionNotification } from "./session-notification"
|
||||
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
|
||||
import * as utils from "./session-notification-utils"
|
||||
import * as sender from "./session-notification-sender"
|
||||
|
||||
const originalSetTimeout = globalThis.setTimeout
|
||||
const originalClearTimeout = globalThis.clearTimeout
|
||||
const originalDateNow = Date.now
|
||||
|
||||
describe("session-notification", () => {
|
||||
let notificationCalls: string[]
|
||||
|
||||
@@ -31,6 +34,10 @@ describe("session-notification", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers()
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
globalThis.clearTimeout = originalClearTimeout
|
||||
Date.now = originalDateNow
|
||||
_resetForTesting()
|
||||
notificationCalls = []
|
||||
|
||||
@@ -42,13 +49,24 @@ describe("session-notification", () => {
|
||||
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
|
||||
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
|
||||
spyOn(sender, "detectPlatform").mockReturnValue("darwin")
|
||||
spyOn(sender, "sendSessionNotification").mockImplementation(async (_ctx, _platform, _title, message) => {
|
||||
notificationCalls.push(message)
|
||||
})
|
||||
spyOn(sender, "sendSessionNotification").mockImplementation(
|
||||
async (
|
||||
_ctx: Parameters<typeof sender.sendSessionNotification>[0],
|
||||
_platform: Parameters<typeof sender.sendSessionNotification>[1],
|
||||
_title: Parameters<typeof sender.sendSessionNotification>[2],
|
||||
message: Parameters<typeof sender.sendSessionNotification>[3]
|
||||
) => {
|
||||
notificationCalls.push(message)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// given - cleanup after each test
|
||||
jest.useRealTimers()
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
globalThis.clearTimeout = originalClearTimeout
|
||||
Date.now = originalDateNow
|
||||
subagentSessions.clear()
|
||||
_resetForTesting()
|
||||
})
|
||||
@@ -514,55 +532,68 @@ describe("session-notification", () => {
|
||||
})
|
||||
|
||||
test("should ignore activity events within grace period", async () => {
|
||||
// given - main session is set
|
||||
const mainSessionID = "main-grace"
|
||||
setMainSession(mainSessionID)
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"))
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 50,
|
||||
skipIfIncompleteTodos: false,
|
||||
activityGracePeriodMs: 100,
|
||||
})
|
||||
try {
|
||||
// given - a regular session notification is scheduled
|
||||
const sessionID = "main-grace"
|
||||
|
||||
// when - session goes idle
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 50,
|
||||
skipIfIncompleteTodos: false,
|
||||
activityGracePeriodMs: 100,
|
||||
enforceMainSessionFilter: false,
|
||||
})
|
||||
|
||||
// when - activity happens immediately (within grace period)
|
||||
await hook({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: { sessionID: mainSessionID },
|
||||
},
|
||||
})
|
||||
// when - session goes idle
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for idle delay to pass
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
// when - activity happens immediately (within grace period)
|
||||
await hook({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - notification SHOULD be sent (activity was within grace period, ignored)
|
||||
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
|
||||
// when - idle confirmation delay passes deterministically
|
||||
jest.advanceTimersByTime(50)
|
||||
jest.runOnlyPendingTimers()
|
||||
await Promise.resolve()
|
||||
|
||||
// then - notification SHOULD be sent (activity was within grace period, ignored)
|
||||
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
|
||||
} finally {
|
||||
jest.clearAllTimers()
|
||||
jest.useRealTimers()
|
||||
globalThis.setTimeout = originalSetTimeout
|
||||
globalThis.clearTimeout = originalClearTimeout
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("should cancel notification for activity after grace period", async () => {
|
||||
// given - main session is set
|
||||
const mainSessionID = "main-grace-cancel"
|
||||
setMainSession(mainSessionID)
|
||||
// given - a regular session notification is scheduled
|
||||
const sessionID = "main-grace-cancel"
|
||||
|
||||
const hook = createSessionNotification(createMockPluginInput(), {
|
||||
idleConfirmationDelay: 200,
|
||||
skipIfIncompleteTodos: false,
|
||||
activityGracePeriodMs: 50,
|
||||
enforceMainSessionFilter: false,
|
||||
})
|
||||
|
||||
// when - session goes idle
|
||||
await hook({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: mainSessionID },
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -573,7 +604,7 @@ describe("session-notification", () => {
|
||||
await hook({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: { sessionID: mainSessionID },
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -18,5 +18,6 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500
|
||||
|
||||
export const ABORT_WINDOW_MS = 3000
|
||||
export const CONTINUATION_COOLDOWN_MS = 5_000
|
||||
export const MAX_STAGNATION_COUNT = 3
|
||||
export const MAX_CONSECUTIVE_FAILURES = 5
|
||||
export const FAILURE_RESET_WINDOW_MS = 5 * 60 * 1000
|
||||
|
||||
@@ -164,6 +164,7 @@ ${todoList}`
|
||||
if (injectionState) {
|
||||
injectionState.inFlight = false
|
||||
injectionState.lastInjectedAt = Date.now()
|
||||
injectionState.awaitingPostInjectionProgressCheck = true
|
||||
injectionState.consecutiveFailures = 0
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,6 +17,7 @@ export function createTodoContinuationHandler(args: {
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||
}): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
|
||||
const {
|
||||
ctx,
|
||||
@@ -24,6 +25,7 @@ export function createTodoContinuationHandler(args: {
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
} = args
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
@@ -56,6 +58,7 @@ export function createTodoContinuationHandler(args: {
|
||||
backgroundManager,
|
||||
skipAgents,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "./constants"
|
||||
import { isLastAssistantMessageAborted } from "./abort-detection"
|
||||
import { hasUnansweredQuestion } from "./pending-question-detection"
|
||||
import { shouldStopForStagnation } from "./stagnation-detection"
|
||||
import { getIncompleteCount } from "./todo"
|
||||
import type { MessageInfo, ResolvedMessageInfo, Todo } from "./types"
|
||||
import type { SessionStateStore } from "./session-state"
|
||||
@@ -28,6 +29,7 @@ export async function handleSessionIdle(args: {
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||
}): Promise<void> {
|
||||
const {
|
||||
ctx,
|
||||
@@ -36,6 +38,7 @@ export async function handleSessionIdle(args: {
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
} = args
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
@@ -93,12 +96,14 @@ export async function handleSessionIdle(args: {
|
||||
}
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const incompleteCount = getIncompleteCount(todos)
|
||||
if (incompleteCount === 0) {
|
||||
sessionStateStore.resetContinuationProgress(sessionID)
|
||||
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
||||
return
|
||||
}
|
||||
@@ -183,6 +188,16 @@ export async function handleSessionIdle(args: {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldSkipContinuation?.(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: another continuation hook already injected`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, incompleteCount, todos)
|
||||
if (shouldStopForStagnation({ sessionID, incompleteCount, progressUpdate })) {
|
||||
return
|
||||
}
|
||||
|
||||
startCountdown({
|
||||
ctx,
|
||||
sessionID,
|
||||
|
||||
@@ -17,6 +17,7 @@ export function createTodoContinuationEnforcer(
|
||||
backgroundManager,
|
||||
skipAgents = DEFAULT_SKIP_AGENTS,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
} = options
|
||||
|
||||
const sessionStateStore = createSessionStateStore()
|
||||
@@ -42,6 +43,7 @@ export function createTodoContinuationEnforcer(
|
||||
backgroundManager,
|
||||
skipAgents,
|
||||
isContinuationStopped,
|
||||
shouldSkipContinuation,
|
||||
})
|
||||
|
||||
const cancelAllCountdowns = (): void => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it as test } from "bun:test"
|
||||
|
||||
import { MAX_STAGNATION_COUNT } from "./constants"
|
||||
import { createSessionStateStore, type SessionStateStore } from "./session-state"
|
||||
|
||||
describe("createSessionStateStore regressions", () => {
|
||||
let sessionStateStore: SessionStateStore
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStateStore = createSessionStateStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionStateStore.shutdown()
|
||||
})
|
||||
|
||||
describe("#given external activity happens after a successful continuation", () => {
|
||||
describe("#when todos stay unchanged", () => {
|
||||
test("#then it treats the activity as progress instead of stagnation", () => {
|
||||
const sessionID = "ses-activity-progress"
|
||||
const todos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
|
||||
const trackedState = sessionStateStore.getExistingState(sessionID)
|
||||
if (!trackedState) {
|
||||
throw new Error("Expected tracked session state")
|
||||
}
|
||||
|
||||
trackedState.abortDetectedAt = undefined
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
expect(progressUpdate.hasProgressed).toBe(true)
|
||||
expect(progressUpdate.progressSource).toBe("activity")
|
||||
expect(progressUpdate.stagnationCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given todos only change order between idle checks", () => {
|
||||
describe("#when the same todos are compared again", () => {
|
||||
test("#then it keeps the snapshot stable and counts stagnation", () => {
|
||||
const sessionID = "ses-stable-snapshot"
|
||||
const firstTodos = [
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
]
|
||||
const reorderedTodos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, firstTodos)
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, reorderedTodos)
|
||||
|
||||
expect(progressUpdate.hasProgressed).toBe(false)
|
||||
expect(progressUpdate.progressSource).toBe("none")
|
||||
expect(progressUpdate.stagnationCount).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given stagnation already halted a session", () => {
|
||||
describe("#when new activity appears before the next idle check", () => {
|
||||
test("#then it resets the stop condition on the next progress check", () => {
|
||||
const sessionID = "ses-stagnation-recovery"
|
||||
const todos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
for (let index = 0; index < MAX_STAGNATION_COUNT; index++) {
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
}
|
||||
|
||||
const trackedState = sessionStateStore.getExistingState(sessionID)
|
||||
if (!trackedState) {
|
||||
throw new Error("Expected tracked session state")
|
||||
}
|
||||
|
||||
trackedState.abortDetectedAt = undefined
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
|
||||
|
||||
expect(progressUpdate.previousStagnationCount).toBe(MAX_STAGNATION_COUNT)
|
||||
expect(progressUpdate.hasProgressed).toBe(true)
|
||||
expect(progressUpdate.progressSource).toBe("activity")
|
||||
expect(progressUpdate.stagnationCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it as test } from "bun:test"
|
||||
|
||||
import { createSessionStateStore, type SessionStateStore } from "./session-state"
|
||||
|
||||
describe("createSessionStateStore", () => {
|
||||
let sessionStateStore: SessionStateStore
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStateStore = createSessionStateStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionStateStore.shutdown()
|
||||
})
|
||||
|
||||
test("given repeated incomplete counts after a continuation, tracks stagnation", () => {
|
||||
// given
|
||||
const sessionID = "ses-stagnation"
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
|
||||
// when
|
||||
const firstUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
const secondUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
const thirdUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||
|
||||
// then
|
||||
expect(firstUpdate.stagnationCount).toBe(0)
|
||||
expect(secondUpdate.stagnationCount).toBe(1)
|
||||
expect(thirdUpdate.stagnationCount).toBe(2)
|
||||
})
|
||||
|
||||
test("given injection did not succeed, repeated incomplete counts do not track stagnation", () => {
|
||||
// given
|
||||
const sessionID = "ses-failed-injection"
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.lastInjectedAt = Date.now()
|
||||
|
||||
// when
|
||||
const firstUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||
const secondUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||
const thirdUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||
|
||||
// then
|
||||
expect(firstUpdate.stagnationCount).toBe(0)
|
||||
expect(secondUpdate.stagnationCount).toBe(0)
|
||||
expect(thirdUpdate.stagnationCount).toBe(0)
|
||||
})
|
||||
|
||||
test("given incomplete count decreases, resets stagnation tracking", () => {
|
||||
// given
|
||||
const sessionID = "ses-progress-reset"
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.lastInjectedAt = Date.now()
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 3)
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 3)
|
||||
|
||||
// when
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2)
|
||||
|
||||
// then
|
||||
expect(progressUpdate.hasProgressed).toBe(true)
|
||||
expect(progressUpdate.stagnationCount).toBe(0)
|
||||
expect(sessionStateStore.getState(sessionID).lastIncompleteCount).toBe(2)
|
||||
})
|
||||
|
||||
test("given one todo completes while another is added, resets stagnation even when incomplete count stays the same", () => {
|
||||
// given
|
||||
const sessionID = "ses-completion-with-addition"
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.lastInjectedAt = Date.now()
|
||||
const initialTodos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
const progressedTodos = [
|
||||
{ id: "1", content: "Task 1", status: "completed", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
{ id: "3", content: "Task 3", status: "pending", priority: "low" },
|
||||
]
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||
|
||||
// when
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
||||
|
||||
// then
|
||||
expect(progressUpdate.hasProgressed).toBe(true)
|
||||
expect(progressUpdate.stagnationCount).toBe(0)
|
||||
})
|
||||
|
||||
test("given todo status changes without count changes, treats it as progress", () => {
|
||||
// given
|
||||
const sessionID = "ses-status-change-progress"
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.lastInjectedAt = Date.now()
|
||||
const initialTodos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
const progressedTodos = [
|
||||
{ id: "1", content: "Task 1", status: "in_progress", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||
|
||||
// when
|
||||
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
||||
|
||||
// then
|
||||
expect(progressUpdate.hasProgressed).toBe(true)
|
||||
expect(progressUpdate.stagnationCount).toBe(0)
|
||||
})
|
||||
|
||||
test("given progress resumes after stagnation, restarts the stagnation count from zero", () => {
|
||||
// given
|
||||
const sessionID = "ses-progress-restarts-stagnation"
|
||||
const state = sessionStateStore.getState(sessionID)
|
||||
state.lastInjectedAt = Date.now()
|
||||
const initialTodos = [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
const progressedTodos = [
|
||||
{ id: "1", content: "Task 1", status: "in_progress", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
|
||||
]
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, initialTodos)
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
||||
|
||||
// when
|
||||
state.awaitingPostInjectionProgressCheck = true
|
||||
const stagnatedAgainUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, progressedTodos)
|
||||
|
||||
// then
|
||||
expect(stagnatedAgainUpdate.hasProgressed).toBe(false)
|
||||
expect(stagnatedAgainUpdate.stagnationCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { SessionState } from "./types"
|
||||
import type { SessionState, Todo } from "./types"
|
||||
|
||||
type TimerHandle = number | { unref?: () => void }
|
||||
|
||||
declare function setInterval(callback: () => void, delay?: number): TimerHandle
|
||||
declare function clearInterval(timeout: TimerHandle): void
|
||||
declare function clearTimeout(timeout: TimerHandle): void
|
||||
|
||||
// TTL for idle session state entries (10 minutes)
|
||||
const SESSION_STATE_TTL_MS = 10 * 60 * 1000
|
||||
@@ -8,22 +14,62 @@ const SESSION_STATE_PRUNE_INTERVAL_MS = 2 * 60 * 1000
|
||||
interface TrackedSessionState {
|
||||
state: SessionState
|
||||
lastAccessedAt: number
|
||||
lastCompletedCount?: number
|
||||
lastTodoSnapshot?: string
|
||||
activitySignalCount: number
|
||||
lastObservedActivitySignalCount?: number
|
||||
}
|
||||
|
||||
export interface ContinuationProgressUpdate {
|
||||
previousIncompleteCount?: number
|
||||
previousStagnationCount: number
|
||||
stagnationCount: number
|
||||
hasProgressed: boolean
|
||||
progressSource: "none" | "todo" | "activity"
|
||||
}
|
||||
|
||||
export interface SessionStateStore {
|
||||
getState: (sessionID: string) => SessionState
|
||||
getExistingState: (sessionID: string) => SessionState | undefined
|
||||
trackContinuationProgress: (sessionID: string, incompleteCount: number, todos?: Todo[]) => ContinuationProgressUpdate
|
||||
resetContinuationProgress: (sessionID: string) => void
|
||||
cancelCountdown: (sessionID: string) => void
|
||||
cleanup: (sessionID: string) => void
|
||||
cancelAllCountdowns: () => void
|
||||
shutdown: () => void
|
||||
}
|
||||
|
||||
function getTodoSnapshot(todos: Todo[]): string {
|
||||
const normalizedTodos = todos
|
||||
.map((todo) => ({
|
||||
id: todo.id ?? null,
|
||||
content: todo.content,
|
||||
priority: todo.priority,
|
||||
status: todo.status,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const leftKey = left.id ?? `${left.content}:${left.priority}:${left.status}`
|
||||
const rightKey = right.id ?? `${right.content}:${right.priority}:${right.status}`
|
||||
if (leftKey !== rightKey) {
|
||||
return leftKey.localeCompare(rightKey)
|
||||
}
|
||||
if (left.content !== right.content) {
|
||||
return left.content.localeCompare(right.content)
|
||||
}
|
||||
if (left.priority !== right.priority) {
|
||||
return left.priority.localeCompare(right.priority)
|
||||
}
|
||||
return left.status.localeCompare(right.status)
|
||||
})
|
||||
|
||||
return JSON.stringify(normalizedTodos)
|
||||
}
|
||||
|
||||
export function createSessionStateStore(): SessionStateStore {
|
||||
const sessions = new Map<string, TrackedSessionState>()
|
||||
|
||||
// Periodic pruning of stale session states to prevent unbounded Map growth
|
||||
let pruneInterval: ReturnType<typeof setInterval> | undefined
|
||||
let pruneInterval: TimerHandle | undefined
|
||||
pruneInterval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [sessionID, tracked] of sessions.entries()) {
|
||||
@@ -34,22 +80,41 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
}
|
||||
}, SESSION_STATE_PRUNE_INTERVAL_MS)
|
||||
// Allow process to exit naturally even if interval is running
|
||||
if (typeof pruneInterval === "object" && "unref" in pruneInterval) {
|
||||
if (typeof pruneInterval === "object" && typeof pruneInterval.unref === "function") {
|
||||
pruneInterval.unref()
|
||||
}
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
function getTrackedSession(sessionID: string): TrackedSessionState {
|
||||
const existing = sessions.get(sessionID)
|
||||
if (existing) {
|
||||
existing.lastAccessedAt = Date.now()
|
||||
return existing.state
|
||||
return existing
|
||||
}
|
||||
|
||||
const state: SessionState = {
|
||||
const rawState: SessionState = {
|
||||
stagnationCount: 0,
|
||||
consecutiveFailures: 0,
|
||||
}
|
||||
sessions.set(sessionID, { state, lastAccessedAt: Date.now() })
|
||||
return state
|
||||
const trackedSession: TrackedSessionState = {
|
||||
state: rawState,
|
||||
lastAccessedAt: Date.now(),
|
||||
activitySignalCount: 0,
|
||||
}
|
||||
trackedSession.state = new Proxy(rawState, {
|
||||
set(target, property, value, receiver) {
|
||||
if (property === "abortDetectedAt" && value === undefined) {
|
||||
trackedSession.activitySignalCount += 1
|
||||
}
|
||||
|
||||
return Reflect.set(target, property, value, receiver)
|
||||
},
|
||||
})
|
||||
sessions.set(sessionID, trackedSession)
|
||||
return trackedSession
|
||||
}
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
return getTrackedSession(sessionID).state
|
||||
}
|
||||
|
||||
function getExistingState(sessionID: string): SessionState | undefined {
|
||||
@@ -61,6 +126,107 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function trackContinuationProgress(
|
||||
sessionID: string,
|
||||
incompleteCount: number,
|
||||
todos?: Todo[]
|
||||
): ContinuationProgressUpdate {
|
||||
const trackedSession = getTrackedSession(sessionID)
|
||||
const state = trackedSession.state
|
||||
const previousIncompleteCount = state.lastIncompleteCount
|
||||
const previousStagnationCount = state.stagnationCount
|
||||
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
|
||||
const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined
|
||||
const currentActivitySignalCount = trackedSession.activitySignalCount
|
||||
const hasCompletedMoreTodos =
|
||||
currentCompletedCount !== undefined
|
||||
&& trackedSession.lastCompletedCount !== undefined
|
||||
&& currentCompletedCount > trackedSession.lastCompletedCount
|
||||
const hasTodoSnapshotChanged =
|
||||
currentTodoSnapshot !== undefined
|
||||
&& trackedSession.lastTodoSnapshot !== undefined
|
||||
&& currentTodoSnapshot !== trackedSession.lastTodoSnapshot
|
||||
const hasObservedExternalActivity =
|
||||
trackedSession.lastObservedActivitySignalCount !== undefined
|
||||
&& currentActivitySignalCount > trackedSession.lastObservedActivitySignalCount
|
||||
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
|
||||
|
||||
state.lastIncompleteCount = incompleteCount
|
||||
if (currentCompletedCount !== undefined) {
|
||||
trackedSession.lastCompletedCount = currentCompletedCount
|
||||
}
|
||||
if (currentTodoSnapshot !== undefined) {
|
||||
trackedSession.lastTodoSnapshot = currentTodoSnapshot
|
||||
}
|
||||
trackedSession.lastObservedActivitySignalCount = currentActivitySignalCount
|
||||
|
||||
if (previousIncompleteCount === undefined) {
|
||||
state.stagnationCount = 0
|
||||
return {
|
||||
previousIncompleteCount,
|
||||
previousStagnationCount,
|
||||
stagnationCount: state.stagnationCount,
|
||||
hasProgressed: false,
|
||||
progressSource: "none",
|
||||
}
|
||||
}
|
||||
|
||||
const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged
|
||||
? "todo"
|
||||
: hasObservedExternalActivity
|
||||
? "activity"
|
||||
: "none"
|
||||
|
||||
if (progressSource !== "none") {
|
||||
state.stagnationCount = 0
|
||||
state.awaitingPostInjectionProgressCheck = false
|
||||
return {
|
||||
previousIncompleteCount,
|
||||
previousStagnationCount,
|
||||
stagnationCount: state.stagnationCount,
|
||||
hasProgressed: true,
|
||||
progressSource,
|
||||
}
|
||||
}
|
||||
|
||||
if (!hadSuccessfulInjectionAwaitingProgressCheck) {
|
||||
return {
|
||||
previousIncompleteCount,
|
||||
previousStagnationCount,
|
||||
stagnationCount: state.stagnationCount,
|
||||
hasProgressed: false,
|
||||
progressSource: "none",
|
||||
}
|
||||
}
|
||||
|
||||
state.awaitingPostInjectionProgressCheck = false
|
||||
state.stagnationCount += 1
|
||||
return {
|
||||
previousIncompleteCount,
|
||||
previousStagnationCount,
|
||||
stagnationCount: state.stagnationCount,
|
||||
hasProgressed: false,
|
||||
progressSource: "none",
|
||||
}
|
||||
}
|
||||
|
||||
function resetContinuationProgress(sessionID: string): void {
|
||||
const trackedSession = sessions.get(sessionID)
|
||||
if (!trackedSession) return
|
||||
|
||||
trackedSession.lastAccessedAt = Date.now()
|
||||
|
||||
const { state } = trackedSession
|
||||
|
||||
state.lastIncompleteCount = undefined
|
||||
state.stagnationCount = 0
|
||||
state.awaitingPostInjectionProgressCheck = false
|
||||
trackedSession.lastCompletedCount = undefined
|
||||
trackedSession.lastTodoSnapshot = undefined
|
||||
trackedSession.activitySignalCount = 0
|
||||
trackedSession.lastObservedActivitySignalCount = undefined
|
||||
}
|
||||
|
||||
function cancelCountdown(sessionID: string): void {
|
||||
const tracked = sessions.get(sessionID)
|
||||
if (!tracked) return
|
||||
@@ -92,7 +258,9 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
}
|
||||
|
||||
function shutdown(): void {
|
||||
clearInterval(pruneInterval)
|
||||
if (pruneInterval !== undefined) {
|
||||
clearInterval(pruneInterval)
|
||||
}
|
||||
cancelAllCountdowns()
|
||||
sessions.clear()
|
||||
}
|
||||
@@ -100,6 +268,8 @@ export function createSessionStateStore(): SessionStateStore {
|
||||
return {
|
||||
getState,
|
||||
getExistingState,
|
||||
trackContinuationProgress,
|
||||
resetContinuationProgress,
|
||||
cancelCountdown,
|
||||
cleanup,
|
||||
cancelAllCountdowns,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/// <reference path="../../../bun-test.d.ts" />
|
||||
|
||||
import { describe, expect, it as test } from "bun:test"
|
||||
|
||||
import { MAX_STAGNATION_COUNT } from "./constants"
|
||||
import { shouldStopForStagnation } from "./stagnation-detection"
|
||||
|
||||
describe("shouldStopForStagnation", () => {
|
||||
describe("#given stagnation reaches the configured limit", () => {
|
||||
describe("#when no progress is detected", () => {
|
||||
test("#then it stops continuation", () => {
|
||||
const shouldStop = shouldStopForStagnation({
|
||||
sessionID: "ses-stagnated",
|
||||
incompleteCount: 2,
|
||||
progressUpdate: {
|
||||
previousIncompleteCount: 2,
|
||||
previousStagnationCount: MAX_STAGNATION_COUNT - 1,
|
||||
stagnationCount: MAX_STAGNATION_COUNT,
|
||||
hasProgressed: false,
|
||||
progressSource: "none",
|
||||
},
|
||||
})
|
||||
|
||||
expect(shouldStop).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when activity progress is detected after the halt", () => {
|
||||
test("#then it clears the stop condition", () => {
|
||||
const shouldStop = shouldStopForStagnation({
|
||||
sessionID: "ses-recovered",
|
||||
incompleteCount: 2,
|
||||
progressUpdate: {
|
||||
previousIncompleteCount: 2,
|
||||
previousStagnationCount: MAX_STAGNATION_COUNT,
|
||||
stagnationCount: 0,
|
||||
hasProgressed: true,
|
||||
progressSource: "activity",
|
||||
},
|
||||
})
|
||||
|
||||
expect(shouldStop).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
import { HOOK_NAME, MAX_STAGNATION_COUNT } from "./constants"
|
||||
import type { ContinuationProgressUpdate } from "./session-state"
|
||||
|
||||
export function shouldStopForStagnation(args: {
|
||||
sessionID: string
|
||||
incompleteCount: number
|
||||
progressUpdate: ContinuationProgressUpdate
|
||||
}): boolean {
|
||||
const { sessionID, incompleteCount, progressUpdate } = args
|
||||
|
||||
if (progressUpdate.hasProgressed) {
|
||||
log(`[${HOOK_NAME}] Progress detected: reset stagnation count`, {
|
||||
sessionID,
|
||||
previousIncompleteCount: progressUpdate.previousIncompleteCount,
|
||||
previousStagnationCount: progressUpdate.previousStagnationCount,
|
||||
incompleteCount,
|
||||
progressSource: progressUpdate.progressSource,
|
||||
recoveredFromStagnationStop: progressUpdate.previousStagnationCount >= MAX_STAGNATION_COUNT,
|
||||
})
|
||||
}
|
||||
|
||||
if (progressUpdate.stagnationCount < MAX_STAGNATION_COUNT) {
|
||||
return false
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Skipped: todo continuation stagnated`, {
|
||||
sessionID,
|
||||
incompleteCount,
|
||||
previousIncompleteCount: progressUpdate.previousIncompleteCount,
|
||||
stagnationCount: progressUpdate.stagnationCount,
|
||||
maxStagnationCount: MAX_STAGNATION_COUNT,
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CONTINUATION_COOLDOWN_MS,
|
||||
FAILURE_RESET_WINDOW_MS,
|
||||
MAX_CONSECUTIVE_FAILURES,
|
||||
MAX_STAGNATION_COUNT,
|
||||
} from "./constants"
|
||||
|
||||
type TimerCallback = (...args: any[]) => void
|
||||
@@ -626,6 +627,57 @@ describe("todo-continuation-enforcer", () => {
|
||||
const sessionID = "main-max-consecutive-failures"
|
||||
setMainSession(sessionID)
|
||||
const mockInput = createMockPluginInput()
|
||||
const incompleteCounts = [5, 4, 5, 4, 5, 4]
|
||||
let todoCallCount = 0
|
||||
mockInput.client.session.todo = async () => {
|
||||
const countIndex = Math.min(Math.floor(todoCallCount / 2), incompleteCounts.length - 1)
|
||||
const incompleteCount = incompleteCounts[countIndex] ?? incompleteCounts[incompleteCounts.length - 1] ?? 1
|
||||
todoCallCount += 1
|
||||
return {
|
||||
data: Array.from({ length: incompleteCount }, (_, index) => ({
|
||||
id: String(index + 1),
|
||||
content: `Task ${index + 1}`,
|
||||
status: "pending",
|
||||
priority: "high",
|
||||
})),
|
||||
}
|
||||
}
|
||||
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
throw new Error("simulated auth failure")
|
||||
}
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
//#when
|
||||
for (let index = 0; index < MAX_CONSECUTIVE_FAILURES; index++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
if (index < MAX_CONSECUTIVE_FAILURES - 1) {
|
||||
await fakeTimers.advanceClockBy(1_000_000)
|
||||
}
|
||||
}
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(MAX_CONSECUTIVE_FAILURES)
|
||||
}, { timeout: 30000 })
|
||||
|
||||
test("should not stop retries early for unchanged todos when injections keep failing", async () => {
|
||||
//#given
|
||||
const sessionID = "main-unchanged-todos-max-failures"
|
||||
setMainSession(sessionID)
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.todo = async () => ({
|
||||
data: [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
],
|
||||
})
|
||||
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
@@ -657,6 +709,21 @@ describe("todo-continuation-enforcer", () => {
|
||||
const sessionID = "main-recovery-after-max-failures"
|
||||
setMainSession(sessionID)
|
||||
const mockInput = createMockPluginInput()
|
||||
const incompleteCounts = [5, 4, 5, 4, 5, 4, 5]
|
||||
let todoCallCount = 0
|
||||
mockInput.client.session.todo = async () => {
|
||||
const countIndex = Math.min(Math.floor(todoCallCount / 2), incompleteCounts.length - 1)
|
||||
const incompleteCount = incompleteCounts[countIndex] ?? incompleteCounts[incompleteCounts.length - 1] ?? 1
|
||||
todoCallCount += 1
|
||||
return {
|
||||
data: Array.from({ length: incompleteCount }, (_, index) => ({
|
||||
id: String(index + 1),
|
||||
content: `Task ${index + 1}`,
|
||||
status: "pending",
|
||||
priority: "high",
|
||||
})),
|
||||
}
|
||||
}
|
||||
mockInput.client.session.promptAsync = async (opts: PromptRequestOptions) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
@@ -753,7 +820,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(3)
|
||||
}, { timeout: 30000 })
|
||||
|
||||
test("should keep injecting even when todos remain unchanged across cycles", async () => {
|
||||
test("should stop injecting after max stagnation cycles when todos remain unchanged across cycles", async () => {
|
||||
//#given
|
||||
const sessionID = "main-no-stagnation-cap"
|
||||
setMainSession(sessionID)
|
||||
@@ -784,8 +851,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await fakeTimers.advanceBy(2500, true)
|
||||
|
||||
//#then — all 5 injections should fire (no stagnation cap)
|
||||
expect(promptCalls).toHaveLength(5)
|
||||
// then
|
||||
expect(promptCalls).toHaveLength(MAX_STAGNATION_COUNT)
|
||||
}, { timeout: 60000 })
|
||||
|
||||
test("should skip idle handling while injection is in flight", async () => {
|
||||
@@ -1639,6 +1706,27 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject when shouldSkipContinuation returns true", async () => {
|
||||
// given - session already handled by another continuation hook
|
||||
const sessionID = "main-skip-other-continuation"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
shouldSkipContinuation: (id) => id === sessionID,
|
||||
})
|
||||
|
||||
// when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// then - no countdown toast or continuation injection
|
||||
expect(toastCalls).toHaveLength(0)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject when isContinuationStopped becomes true during countdown", async () => {
|
||||
// given - session where continuation is not stopped at idle time but stops during countdown
|
||||
const sessionID = "main-race-condition"
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface TodoContinuationEnforcerOptions {
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
isContinuationStopped?: (sessionID: string) => boolean
|
||||
shouldSkipContinuation?: (sessionID: string) => boolean
|
||||
}
|
||||
|
||||
export interface TodoContinuationEnforcer {
|
||||
@@ -27,8 +28,11 @@ export interface SessionState {
|
||||
isRecovering?: boolean
|
||||
countdownStartedAt?: number
|
||||
abortDetectedAt?: number
|
||||
lastIncompleteCount?: number
|
||||
lastInjectedAt?: number
|
||||
awaitingPostInjectionProgressCheck?: boolean
|
||||
inFlight?: boolean
|
||||
stagnationCount: number
|
||||
consecutiveFailures: number
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,20 @@ describe("createToolOutputTruncatorHook", () => {
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
})
|
||||
|
||||
it("passes modelContextLimitsCache through to createDynamicTruncator", () => {
|
||||
const ctx = {} as never
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
const modelCacheState = {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
}
|
||||
|
||||
truncateSpy.mockClear()
|
||||
createToolOutputTruncatorHook(ctx, { modelCacheState })
|
||||
|
||||
expect(truncateSpy).toHaveBeenLastCalledWith(ctx, modelCacheState)
|
||||
})
|
||||
|
||||
describe("tool.execute.after", () => {
|
||||
const createInput = (tool: string) => ({
|
||||
tool,
|
||||
|
||||
@@ -27,7 +27,10 @@ const TOOL_SPECIFIC_MAX_TOKENS: Record<string, number> = {
|
||||
}
|
||||
|
||||
interface ToolOutputTruncatorOptions {
|
||||
modelCacheState?: { anthropicContext1MEnabled: boolean }
|
||||
modelCacheState?: {
|
||||
anthropicContext1MEnabled: boolean
|
||||
modelContextLimitsCache?: Map<string, number>
|
||||
}
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user