fix(compaction): validate recovered agent config state
Retry compaction recovery when model or tool state is still incomplete, and treat reasoning or tool-only assistant progress as valid output so no-text tail recovery does not misfire.
This commit is contained in:
@@ -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.
|
||||||
|
`
|
||||||
@@ -9,14 +9,21 @@ import { createInternalAgentTextPart } from "../../shared/internal-initiator-mar
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { setSessionModel } from "../../shared/session-model-state"
|
import { setSessionModel } from "../../shared/session-model-state"
|
||||||
import { setSessionTools } from "../../shared/session-tools-store"
|
import { setSessionTools } from "../../shared/session-tools-store"
|
||||||
|
import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt"
|
||||||
import {
|
import {
|
||||||
createSystemDirective,
|
createExpectedRecoveryPromptConfig,
|
||||||
SystemDirectiveTypes,
|
isPromptConfigRecovered,
|
||||||
} from "../../shared/system-directive"
|
} from "./recovery-prompt-config"
|
||||||
import {
|
import {
|
||||||
resolveLatestSessionPromptConfig,
|
resolveLatestSessionPromptConfig,
|
||||||
resolveSessionPromptConfig,
|
resolveSessionPromptConfig,
|
||||||
} from "./session-prompt-config-resolver"
|
} from "./session-prompt-config-resolver"
|
||||||
|
import {
|
||||||
|
finalizeTrackedAssistantMessage,
|
||||||
|
shouldTreatAssistantPartAsOutput,
|
||||||
|
trackAssistantOutput,
|
||||||
|
type TailMonitorState,
|
||||||
|
} from "./tail-monitor"
|
||||||
|
|
||||||
const HOOK_NAME = "compaction-context-injector"
|
const HOOK_NAME = "compaction-context-injector"
|
||||||
const AGENT_RECOVERY_PROMPT = "[restore checkpointed session agent configuration after compaction]"
|
const AGENT_RECOVERY_PROMPT = "[restore checkpointed session agent configuration after compaction]"
|
||||||
@@ -44,14 +51,6 @@ type CompactionContextClient = {
|
|||||||
directory: string
|
directory: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TailMonitorState = {
|
|
||||||
currentMessageID?: string
|
|
||||||
currentHasText: boolean
|
|
||||||
consecutiveNoTextMessages: number
|
|
||||||
lastCompactedAt?: number
|
|
||||||
lastRecoveryAt?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompactionContextInjector {
|
export interface CompactionContextInjector {
|
||||||
capture: (sessionID: string) => Promise<void>
|
capture: (sessionID: string) => Promise<void>
|
||||||
inject: (sessionID?: string) => string
|
inject: (sessionID?: string) => string
|
||||||
@@ -67,81 +66,6 @@ function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
|||||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeTrackedAssistantMessage(state: TailMonitorState): number {
|
|
||||||
if (!state.currentMessageID) {
|
|
||||||
return state.consecutiveNoTextMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
state.consecutiveNoTextMessages = state.currentHasText
|
|
||||||
? 0
|
|
||||||
: state.consecutiveNoTextMessages + 1
|
|
||||||
state.currentMessageID = undefined
|
|
||||||
state.currentHasText = false
|
|
||||||
|
|
||||||
return state.consecutiveNoTextMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
function trackAssistantText(state: TailMonitorState, messageID?: string): void {
|
|
||||||
if (messageID && !state.currentMessageID) {
|
|
||||||
state.currentMessageID = messageID
|
|
||||||
}
|
|
||||||
|
|
||||||
state.currentHasText = true
|
|
||||||
state.consecutiveNoTextMessages = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
`
|
|
||||||
|
|
||||||
export function createCompactionContextInjector(options?: {
|
export function createCompactionContextInjector(options?: {
|
||||||
ctx?: CompactionContextClient
|
ctx?: CompactionContextClient
|
||||||
backgroundManager?: BackgroundManager
|
backgroundManager?: BackgroundManager
|
||||||
@@ -157,7 +81,7 @@ export function createCompactionContextInjector(options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created: TailMonitorState = {
|
const created: TailMonitorState = {
|
||||||
currentHasText: false,
|
currentHasOutput: false,
|
||||||
consecutiveNoTextMessages: 0,
|
consecutiveNoTextMessages: 0,
|
||||||
}
|
}
|
||||||
tailStates.set(sessionID, created)
|
tailStates.set(sessionID, created)
|
||||||
@@ -176,6 +100,10 @@ export function createCompactionContextInjector(options?: {
|
|||||||
if (!checkpoint?.agent) {
|
if (!checkpoint?.agent) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
const checkpointWithAgent = {
|
||||||
|
...checkpoint,
|
||||||
|
agent: checkpoint.agent,
|
||||||
|
}
|
||||||
|
|
||||||
const tailState = getTailState(sessionID)
|
const tailState = getTailState(sessionID)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -183,28 +111,27 @@ export function createCompactionContextInjector(options?: {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentPromptConfig = await resolveSessionPromptConfig(ctx, sessionID)
|
||||||
|
const expectedPromptConfig = createExpectedRecoveryPromptConfig(
|
||||||
|
checkpointWithAgent,
|
||||||
|
currentPromptConfig,
|
||||||
|
)
|
||||||
|
const model = expectedPromptConfig.model
|
||||||
|
const tools = expectedPromptConfig.tools
|
||||||
|
|
||||||
if (reason === "session.compacted") {
|
if (reason === "session.compacted") {
|
||||||
const latestPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
const latestPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
||||||
const latestAgentMatchesCheckpoint =
|
if (isPromptConfigRecovered(latestPromptConfig, expectedPromptConfig)) {
|
||||||
typeof latestPromptConfig.agent === "string" &&
|
|
||||||
latestPromptConfig.agent.toLowerCase() === checkpoint.agent.toLowerCase() &&
|
|
||||||
!isCompactionAgent(latestPromptConfig.agent)
|
|
||||||
|
|
||||||
if (latestAgentMatchesCheckpoint && latestPromptConfig.model) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPromptConfig = await resolveSessionPromptConfig(ctx, sessionID)
|
|
||||||
const model = checkpoint.model ?? currentPromptConfig.model
|
|
||||||
const tools = checkpoint.tools ?? currentPromptConfig.tools
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ctx.client.session.promptAsync({
|
await ctx.client.session.promptAsync({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
noReply: true,
|
noReply: true,
|
||||||
agent: checkpoint.agent,
|
agent: expectedPromptConfig.agent,
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(tools ? { tools } : {}),
|
...(tools ? { tools } : {}),
|
||||||
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
|
parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)],
|
||||||
@@ -212,7 +139,20 @@ export function createCompactionContextInjector(options?: {
|
|||||||
query: { directory: ctx.directory },
|
query: { directory: ctx.directory },
|
||||||
})
|
})
|
||||||
|
|
||||||
updateSessionAgent(sessionID, checkpoint.agent)
|
const recoveredPromptConfig = await resolveLatestSessionPromptConfig(ctx, sessionID)
|
||||||
|
if (!isPromptConfigRecovered(recoveredPromptConfig, expectedPromptConfig)) {
|
||||||
|
log(`[${HOOK_NAME}] 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) {
|
if (model) {
|
||||||
setSessionModel(sessionID, model)
|
setSessionModel(sessionID, model)
|
||||||
}
|
}
|
||||||
@@ -226,7 +166,7 @@ export function createCompactionContextInjector(options?: {
|
|||||||
log(`[${HOOK_NAME}] Re-injected checkpointed agent config`, {
|
log(`[${HOOK_NAME}] Re-injected checkpointed agent config`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
reason,
|
reason,
|
||||||
agent: checkpoint.agent,
|
agent: expectedPromptConfig.agent,
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -352,7 +292,7 @@ export function createCompactionContextInjector(options?: {
|
|||||||
|
|
||||||
if (tailState.currentMessageID !== info.id) {
|
if (tailState.currentMessageID !== info.id) {
|
||||||
tailState.currentMessageID = info.id
|
tailState.currentMessageID = info.id
|
||||||
tailState.currentHasText = false
|
tailState.currentHasOutput = false
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -367,7 +307,7 @@ export function createCompactionContextInjector(options?: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trackAssistantText(getTailState(sessionID), messageID)
|
trackAssistantOutput(getTailState(sessionID), messageID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,11 +319,11 @@ export function createCompactionContextInjector(options?: {
|
|||||||
text?: string
|
text?: string
|
||||||
} | undefined
|
} | undefined
|
||||||
|
|
||||||
if (!part?.sessionID || part.type !== "text" || !part.text?.trim()) {
|
if (!part?.sessionID || !shouldTreatAssistantPartAsOutput(part)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trackAssistantText(getTailState(part.sessionID), part.messageID)
|
trackAssistantOutput(getTailState(part.sessionID), part.messageID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,294 @@
|
|||||||
|
/// <reference path="../../../bun-test.d.ts" />
|
||||||
|
|
||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user