Merge remote-tracking branch 'origin/dev' into fix/sync-package-json-to-opencode-intent

This commit is contained in:
acamq
2026-03-11 08:04:16 -06:00
185 changed files with 8236 additions and 1041 deletions
+6
View File
@@ -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`, {
+31
View File
@@ -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")
+1
View File
@@ -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
+1 -1
View File
@@ -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())
+34 -44
View File
@@ -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
+142 -50
View File
@@ -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")
})
})
})
})
+26 -29
View File
@@ -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([])
})
})
+1
View File
@@ -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";
+18 -3
View File
@@ -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,
},
+9 -9
View File
@@ -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()
})
})
+67 -36
View File
@@ -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
}
+14
View File
@@ -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,
+4 -1
View File
@@ -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
}