Merge remote-tracking branch 'origin/dev' into fix/retry-recovery-and-observability
This commit is contained in:
@@ -3023,6 +3023,22 @@
|
|||||||
"created_at": "2026-04-28T03:32:31Z",
|
"created_at": "2026-04-28T03:32:31Z",
|
||||||
"repoId": 1108837393,
|
"repoId": 1108837393,
|
||||||
"pullRequestNo": 3695
|
"pullRequestNo": 3695
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unclok",
|
||||||
|
"id": 5087124,
|
||||||
|
"comment_id": 4335472715,
|
||||||
|
"created_at": "2026-04-28T13:00:37Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 3706
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deopa0402",
|
||||||
|
"id": 107998765,
|
||||||
|
"comment_id": 4336992103,
|
||||||
|
"created_at": "2026-04-28T16:03:18Z",
|
||||||
|
"repoId": 1108837393,
|
||||||
|
"pullRequestNo": 3713
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -177,6 +177,7 @@ export class BackgroundManager {
|
|||||||
|
|
||||||
|
|
||||||
private tasks: Map<string, BackgroundTask>
|
private tasks: Map<string, BackgroundTask>
|
||||||
|
private tasksByParentSession: Map<string, Set<string>>
|
||||||
private notifications: Map<string, BackgroundTask[]>
|
private notifications: Map<string, BackgroundTask[]>
|
||||||
private pendingNotifications: Map<string, string[]>
|
private pendingNotifications: Map<string, string[]>
|
||||||
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
|
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
|
||||||
@@ -218,6 +219,7 @@ export class BackgroundManager {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
this.tasks = new Map()
|
this.tasks = new Map()
|
||||||
|
this.tasksByParentSession = new Map()
|
||||||
this.notifications = new Map()
|
this.notifications = new Map()
|
||||||
this.pendingNotifications = new Map()
|
this.pendingNotifications = new Map()
|
||||||
this.pendingByParent = new Map()
|
this.pendingByParent = new Map()
|
||||||
@@ -322,6 +324,50 @@ export class BackgroundManager {
|
|||||||
this.unregisterRootDescendant(task.rootSessionID)
|
this.unregisterRootDescendant(task.rootSessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addTask(task: BackgroundTask): void {
|
||||||
|
this.tasks.set(task.id, task)
|
||||||
|
if (!task.parentSessionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskIDs = this.tasksByParentSession.get(task.parentSessionID) ?? new Set<string>()
|
||||||
|
taskIDs.add(task.id)
|
||||||
|
this.tasksByParentSession.set(task.parentSessionID, taskIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeTask(task: BackgroundTask): void {
|
||||||
|
this.tasks.delete(task.id)
|
||||||
|
this.removeTaskFromParentIndex(task.id, task.parentSessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTaskParent(task: BackgroundTask, parentSessionID: string): void {
|
||||||
|
if (task.parentSessionID === parentSessionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeTaskFromParentIndex(task.id, task.parentSessionID)
|
||||||
|
task.parentSessionID = parentSessionID
|
||||||
|
const taskIDs = this.tasksByParentSession.get(parentSessionID) ?? new Set<string>()
|
||||||
|
taskIDs.add(task.id)
|
||||||
|
this.tasksByParentSession.set(parentSessionID, taskIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeTaskFromParentIndex(taskID: string, parentSessionID: string | undefined): void {
|
||||||
|
if (!parentSessionID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskIDs = this.tasksByParentSession.get(parentSessionID)
|
||||||
|
if (!taskIDs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
taskIDs.delete(taskID)
|
||||||
|
if (taskIDs.size === 0) {
|
||||||
|
this.tasksByParentSession.delete(parentSessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||||
log("[background-agent] launch() called with:", {
|
log("[background-agent] launch() called with:", {
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
@@ -368,7 +414,7 @@ export class BackgroundManager {
|
|||||||
}
|
}
|
||||||
const firstAttempt = startAttempt(task, input.model)
|
const firstAttempt = startAttempt(task, input.model)
|
||||||
|
|
||||||
this.tasks.set(task.id, task)
|
this.addTask(task)
|
||||||
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
|
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
|
||||||
|
|
||||||
// Track for batched notifications immediately (pending state)
|
// Track for batched notifications immediately (pending state)
|
||||||
@@ -745,13 +791,25 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
}
|
}
|
||||||
|
|
||||||
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
||||||
const result: BackgroundTask[] = []
|
const taskIDs = this.tasksByParentSession.get(sessionID)
|
||||||
for (const task of this.tasks.values()) {
|
if (!taskIDs) {
|
||||||
if (task.parentSessionID === sessionID) {
|
const result: BackgroundTask[] = []
|
||||||
result.push(task)
|
for (const task of this.tasks.values()) {
|
||||||
|
if (task.parentSessionID === sessionID) {
|
||||||
|
result.push(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: BackgroundTask[] = []
|
||||||
|
for (const taskID of taskIDs) {
|
||||||
|
const task = this.tasks.get(taskID)
|
||||||
|
if (task) {
|
||||||
|
tasks.push(task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
|
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
|
||||||
@@ -830,7 +888,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
const parentChanged = input.parentSessionID !== existingTask.parentSessionID
|
const parentChanged = input.parentSessionID !== existingTask.parentSessionID
|
||||||
if (parentChanged) {
|
if (parentChanged) {
|
||||||
this.cleanupPendingByParent(existingTask) // Clean from OLD parent
|
this.cleanupPendingByParent(existingTask) // Clean from OLD parent
|
||||||
existingTask.parentSessionID = input.parentSessionID
|
this.updateTaskParent(existingTask, input.parentSessionID)
|
||||||
}
|
}
|
||||||
if (input.parentAgent !== undefined) {
|
if (input.parentAgent !== undefined) {
|
||||||
existingTask.parentAgent = input.parentAgent
|
existingTask.parentAgent = input.parentAgent
|
||||||
@@ -885,7 +943,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
concurrencyGroup,
|
concurrencyGroup,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tasks.set(task.id, task)
|
this.addTask(task)
|
||||||
subagentSessions.add(input.sessionID)
|
subagentSessions.add(input.sessionID)
|
||||||
this.startPolling()
|
this.startPolling()
|
||||||
this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID: input.sessionID, agent: input.agent || "task", description: input.description, status: "running", startedAt: task.startedAt })
|
this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID: input.sessionID, agent: input.agent || "task", description: input.description, status: "running", startedAt: task.startedAt })
|
||||||
@@ -935,7 +993,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
existingTask.status = "running"
|
existingTask.status = "running"
|
||||||
existingTask.completedAt = undefined
|
existingTask.completedAt = undefined
|
||||||
existingTask.error = undefined
|
existingTask.error = undefined
|
||||||
existingTask.parentSessionID = input.parentSessionID
|
this.updateTaskParent(existingTask, input.parentSessionID)
|
||||||
existingTask.parentMessageID = input.parentMessageID
|
existingTask.parentMessageID = input.parentMessageID
|
||||||
existingTask.parentModel = input.parentModel
|
existingTask.parentModel = input.parentModel
|
||||||
existingTask.parentAgent = input.parentAgent
|
existingTask.parentAgent = input.parentAgent
|
||||||
@@ -1680,7 +1738,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.clearNotificationsForTask(taskId)
|
this.clearNotificationsForTask(taskId)
|
||||||
this.tasks.delete(taskId)
|
this.removeTask(task)
|
||||||
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
|
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
|
||||||
if (task.sessionID) {
|
if (task.sessionID) {
|
||||||
subagentSessions.delete(task.sessionID)
|
subagentSessions.delete(task.sessionID)
|
||||||
@@ -2360,6 +2418,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
|||||||
|
|
||||||
this.concurrencyManager.clear()
|
this.concurrencyManager.clear()
|
||||||
this.tasks.clear()
|
this.tasks.clear()
|
||||||
|
this.tasksByParentSession.clear()
|
||||||
this.notifications.clear()
|
this.notifications.clear()
|
||||||
this.pendingNotifications.clear()
|
this.pendingNotifications.clear()
|
||||||
this.pendingByParent.clear()
|
this.pendingByParent.clear()
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ function extractFilePath(metadata: unknown): string | undefined {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractLineCount(metadata: unknown): number | undefined {
|
||||||
|
if (!metadata || typeof metadata !== "object") {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectMeta = metadata as Record<string, unknown>
|
||||||
|
const candidates = [objectMeta.lineCount, objectMeta.linesWritten, objectMeta.lines]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
|
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
|
||||||
if (output.output.startsWith(WRITE_SUCCESS_MARKER)) {
|
if (output.output.startsWith(WRITE_SUCCESS_MARKER)) {
|
||||||
return
|
return
|
||||||
@@ -151,6 +167,12 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metadataLineCount = extractLineCount(output.metadata)
|
||||||
|
if (metadataLineCount !== undefined) {
|
||||||
|
output.output = `${WRITE_SUCCESS_MARKER} ${metadataLineCount} lines written.`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = extractFilePath(output.metadata)
|
const filePath = extractFilePath(output.metadata)
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ function mockCtx(): PluginInput {
|
|||||||
return {
|
return {
|
||||||
client: {} as PluginInput["client"],
|
client: {} as PluginInput["client"],
|
||||||
directory: "/test",
|
directory: "/test",
|
||||||
project: "/test" as unknown as PluginInput["project"],
|
project: "/test" as PluginInput["project"],
|
||||||
worktree: "/test",
|
worktree: "/test",
|
||||||
serverUrl: "http://localhost" as unknown as PluginInput["serverUrl"],
|
serverUrl: "http://localhost" as PluginInput["serverUrl"],
|
||||||
$: {} as PluginInput["$"],
|
$: {} as PluginInput["$"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +238,26 @@ describe("hashline-read-enhancer", () => {
|
|||||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("uses write metadata line count without reading the file", async () => {
|
||||||
|
//#given
|
||||||
|
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||||
|
const input = { tool: "write", sessionID: "s", callID: "c" }
|
||||||
|
const output = {
|
||||||
|
title: "write",
|
||||||
|
output: "Wrote file successfully.",
|
||||||
|
metadata: {
|
||||||
|
filepath: "/tmp/hashline-metadata-fast-path-missing-file.ts",
|
||||||
|
lineCount: 7,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await hook["tool.execute.after"](input, output)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(output.output).toBe("File written successfully. 7 lines written.")
|
||||||
|
})
|
||||||
|
|
||||||
it("does not overwrite write tool error output with success message", async () => {
|
it("does not overwrite write tool error output with success message", async () => {
|
||||||
//#given — write tool failed, but stale file exists from previous write
|
//#given — write tool failed, but stale file exists from previous write
|
||||||
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface ClientLike {
|
|||||||
export interface AssistantCompactionMessageInfo {
|
export interface AssistantCompactionMessageInfo {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
id?: string
|
id?: string
|
||||||
|
parts?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withTimeout<TValue>(
|
async function withTimeout<TValue>(
|
||||||
@@ -185,6 +186,7 @@ export function createPostCompactionDegradationMonitor(args: {
|
|||||||
sessionID: info.sessionID,
|
sessionID: info.sessionID,
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
directory,
|
directory,
|
||||||
|
parts: info.parts,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isNoTextTail) {
|
if (!isNoTextTail) {
|
||||||
|
|||||||
@@ -46,8 +46,13 @@ export async function resolveNoTextTailFromSession(args: {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
messageID?: string
|
messageID?: string
|
||||||
directory: string
|
directory: string
|
||||||
|
parts?: unknown
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const { client, sessionID, messageID, directory } = args
|
const { client, sessionID, messageID, directory, parts } = args
|
||||||
|
|
||||||
|
if (Array.isArray(parts)) {
|
||||||
|
return isStepOnlyNoTextParts(parts)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await client.session.messages({
|
const response = await client.session.messages({
|
||||||
|
|||||||
@@ -192,4 +192,28 @@ describe("preemptive-compaction post-compaction degradation monitor", () => {
|
|||||||
// then
|
// then
|
||||||
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("uses message update parts without refetching session messages", async () => {
|
||||||
|
// given
|
||||||
|
const sessionHistory: AssistantHistoryMessage[] = []
|
||||||
|
const ctx = createMockCtx(sessionHistory)
|
||||||
|
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
|
||||||
|
const sessionID = "ses_tail_update_parts"
|
||||||
|
const stepOnlyParts = [{ type: "step-start" }, { type: "step-finish" }]
|
||||||
|
|
||||||
|
await hook.event({
|
||||||
|
event: {
|
||||||
|
type: "session.compacted",
|
||||||
|
properties: { sessionID },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_1", parts: stepOnlyParts }))
|
||||||
|
await hook.event(buildAssistantUpdate({ sessionID, id: "msg_2", parts: stepOnlyParts }))
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(ctx.client.session.messages).not.toHaveBeenCalled()
|
||||||
|
expect(ctx.client.session.summarize).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function createPreemptiveCompactionHook(
|
|||||||
modelID?: string
|
modelID?: string
|
||||||
finish?: boolean
|
finish?: boolean
|
||||||
tokens?: TokenInfo
|
tokens?: TokenInfo
|
||||||
|
parts?: unknown
|
||||||
} | undefined
|
} | undefined
|
||||||
|
|
||||||
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
|
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
|
||||||
@@ -92,6 +93,7 @@ export function createPreemptiveCompactionHook(
|
|||||||
await postCompactionMonitor.onAssistantMessageUpdated({
|
await postCompactionMonitor.onAssistantMessageUpdated({
|
||||||
sessionID: info.sessionID,
|
sessionID: info.sessionID,
|
||||||
id: info.id,
|
id: info.id,
|
||||||
|
parts: info.parts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
|
||||||
|
import { handleSessionIdle } from "./idle-event"
|
||||||
|
import type { SessionStateStore } from "./session-state"
|
||||||
|
import type { ContinuationProgressUpdate, SessionState } from "./types"
|
||||||
|
|
||||||
|
function createStateStore(): {
|
||||||
|
store: SessionStateStore
|
||||||
|
resetCalls: string[]
|
||||||
|
} {
|
||||||
|
const state: SessionState = {
|
||||||
|
stagnationCount: 0,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
}
|
||||||
|
const resetCalls: string[] = []
|
||||||
|
const progressUpdate: ContinuationProgressUpdate = {
|
||||||
|
previousStagnationCount: 0,
|
||||||
|
stagnationCount: 0,
|
||||||
|
hasProgressed: false,
|
||||||
|
progressSource: "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resetCalls,
|
||||||
|
store: {
|
||||||
|
getState: () => state,
|
||||||
|
getExistingState: () => state,
|
||||||
|
startPruneInterval: () => {},
|
||||||
|
recordActivity: () => {},
|
||||||
|
trackContinuationProgress: () => progressUpdate,
|
||||||
|
resetContinuationProgress: (sessionID: string) => {
|
||||||
|
resetCalls.push(sessionID)
|
||||||
|
},
|
||||||
|
cancelCountdown: () => {},
|
||||||
|
cleanup: () => {},
|
||||||
|
cancelAllCountdowns: () => {},
|
||||||
|
shutdown: () => {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("handleSessionIdle", () => {
|
||||||
|
it("resets continuation progress once when todos are empty", async () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "ses_empty_todos"
|
||||||
|
const { store, resetCalls } = createStateStore()
|
||||||
|
const ctx = {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
todo: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: "/tmp/test",
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handleSessionIdle({
|
||||||
|
ctx: ctx as never,
|
||||||
|
sessionID,
|
||||||
|
sessionStateStore: store,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(resetCalls).toEqual([sessionID])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resets continuation progress once when every todo is complete", async () => {
|
||||||
|
// given
|
||||||
|
const sessionID = "ses_completed_todos"
|
||||||
|
const { store, resetCalls } = createStateStore()
|
||||||
|
const ctx = {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
todo: async () => ({
|
||||||
|
data: [
|
||||||
|
{ id: "todo-1", content: "Ship", status: "completed", priority: "high" },
|
||||||
|
{ id: "todo-2", content: "Verify", status: "completed", priority: "medium" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: "/tmp/test",
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handleSessionIdle({
|
||||||
|
ctx: ctx as never,
|
||||||
|
sessionID,
|
||||||
|
sessionStateStore: store,
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(resetCalls).toEqual([sessionID])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -108,7 +108,6 @@ export async function handleSessionIdle(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!todos || todos.length === 0) {
|
if (!todos || todos.length === 0) {
|
||||||
sessionStateStore.resetContinuationProgress(sessionID)
|
|
||||||
sessionStateStore.resetContinuationProgress(sessionID)
|
sessionStateStore.resetContinuationProgress(sessionID)
|
||||||
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
||||||
return
|
return
|
||||||
@@ -116,7 +115,6 @@ export async function handleSessionIdle(args: {
|
|||||||
|
|
||||||
const incompleteCount = getIncompleteCount(todos)
|
const incompleteCount = getIncompleteCount(todos)
|
||||||
if (incompleteCount === 0) {
|
if (incompleteCount === 0) {
|
||||||
sessionStateStore.resetContinuationProgress(sessionID)
|
|
||||||
sessionStateStore.resetContinuationProgress(sessionID)
|
sessionStateStore.resetContinuationProgress(sessionID)
|
||||||
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
||||||
return
|
return
|
||||||
|
|||||||
+6
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { OhMyOpenCodeConfig } from "../config";
|
import type { OhMyOpenCodeConfig } from "../config";
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin";
|
||||||
import type { PluginContext } from "./types";
|
import type { PluginContext } from "./types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
import { resetMessageCursor } from "../shared";
|
import { resetMessageCursor } from "../shared";
|
||||||
import { getAgentConfigKey } from "../shared/agent-display-names";
|
import { getAgentConfigKey } from "../shared/agent-display-names";
|
||||||
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
|
import { readConnectedProvidersCache } from "../shared/connected-providers-cache";
|
||||||
|
import { invalidateContextWindowUsageCache } from "../shared/dynamic-truncator";
|
||||||
import { log } from "../shared/logger";
|
import { log } from "../shared/logger";
|
||||||
import { shouldRetryError } from "../shared/model-error-classifier";
|
import { shouldRetryError } from "../shared/model-error-classifier";
|
||||||
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
|
import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models";
|
||||||
@@ -161,8 +163,7 @@ export function createEventHandler(args: {
|
|||||||
body: { parts: Array<{ type: "text"; text: string }> };
|
body: { parts: Array<{ type: "text"; text: string }> };
|
||||||
query: { directory: string };
|
query: { directory: string };
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
summarize: (...args: unknown[]) => Promise<unknown>;
|
||||||
summarize: (...args: any[]) => Promise<unknown>;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -476,6 +477,9 @@ export function createEventHandler(args: {
|
|||||||
const sessionID = info?.sessionID as string | undefined;
|
const sessionID = info?.sessionID as string | undefined;
|
||||||
const agent = info?.agent as string | undefined;
|
const agent = info?.agent as string | undefined;
|
||||||
const role = info?.role as string | undefined;
|
const role = info?.role as string | undefined;
|
||||||
|
if (sessionID && info?.finish === true) {
|
||||||
|
invalidateContextWindowUsageCache(pluginContext as PluginInput, sessionID);
|
||||||
|
}
|
||||||
if (sessionID && role === "user") {
|
if (sessionID && role === "user") {
|
||||||
const isCompactionMessage = agent ? isCompactionAgent(agent) : false;
|
const isCompactionMessage = agent ? isCompactionAgent(agent) : false;
|
||||||
if (agent && !isCompactionMessage) {
|
if (agent && !isCompactionMessage) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { describe, expect, it, afterEach } from "bun:test"
|
import { describe, expect, it, afterEach } from "bun:test"
|
||||||
|
|
||||||
import { getContextWindowUsage } from "./dynamic-truncator"
|
import { getContextWindowUsage, invalidateContextWindowUsageCache } from "./dynamic-truncator"
|
||||||
|
|
||||||
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
|
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
|
||||||
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
|
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
|
||||||
@@ -53,6 +53,39 @@ function createContextUsageMockContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCountingContextUsageMockContext(inputTokens: number) {
|
||||||
|
let messagesCalls = 0
|
||||||
|
return {
|
||||||
|
ctx: {
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
messages: async () => {
|
||||||
|
messagesCalls += 1
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-4-5",
|
||||||
|
tokens: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: { read: 0, write: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getMessagesCalls: () => messagesCalls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("getContextWindowUsage", () => {
|
describe("getContextWindowUsage", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetContextLimitEnv()
|
resetContextLimitEnv()
|
||||||
@@ -125,6 +158,39 @@ describe("getContextWindowUsage", () => {
|
|||||||
expect(usage?.remainingTokens).toBe(82144)
|
expect(usage?.remainingTokens).toBe(82144)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("reuses context usage for repeated calls in the same session", async () => {
|
||||||
|
// given
|
||||||
|
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||||
|
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||||
|
const { ctx, getMessagesCalls } = createCountingContextUsageMockContext(100000)
|
||||||
|
const modelCacheState = { anthropicContext1MEnabled: false }
|
||||||
|
|
||||||
|
// when
|
||||||
|
const firstUsage = await getContextWindowUsage(ctx as never, "ses_cached_usage", modelCacheState)
|
||||||
|
const secondUsage = await getContextWindowUsage(ctx as never, "ses_cached_usage", modelCacheState)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(firstUsage?.remainingTokens).toBe(100000)
|
||||||
|
expect(secondUsage?.remainingTokens).toBe(100000)
|
||||||
|
expect(getMessagesCalls()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refetches context usage after cache invalidation", async () => {
|
||||||
|
// given
|
||||||
|
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||||
|
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||||
|
const { ctx, getMessagesCalls } = createCountingContextUsageMockContext(100000)
|
||||||
|
const modelCacheState = { anthropicContext1MEnabled: false }
|
||||||
|
|
||||||
|
// when
|
||||||
|
await getContextWindowUsage(ctx as never, "ses_invalidated_usage", modelCacheState)
|
||||||
|
invalidateContextWindowUsageCache(ctx as never, "ses_invalidated_usage")
|
||||||
|
await getContextWindowUsage(ctx as never, "ses_invalidated_usage", modelCacheState)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(getMessagesCalls()).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
it("returns null for non-anthropic providers without a cached limit", async () => {
|
it("returns null for non-anthropic providers without a cached limit", async () => {
|
||||||
// given
|
// given
|
||||||
const ctx = createContextUsageMockContext(180000, {
|
const ctx = createContextUsageMockContext(180000, {
|
||||||
|
|||||||
@@ -24,6 +24,66 @@ interface MessageWrapper {
|
|||||||
info: { role: string } & Partial<AssistantMessageInfo>;
|
info: { role: string } & Partial<AssistantMessageInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContextWindowUsage = {
|
||||||
|
usedTokens: number;
|
||||||
|
remainingTokens: number;
|
||||||
|
usagePercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextWindowUsageClient = Pick<PluginInput["client"], "session">
|
||||||
|
|
||||||
|
const usageCacheByClient = new WeakMap<object, Map<string, Map<string, Promise<ContextWindowUsage | null>>>>()
|
||||||
|
|
||||||
|
function createModelCacheKey(modelCacheState?: ContextLimitModelCacheState): string {
|
||||||
|
if (!modelCacheState) {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedLimits = modelCacheState.modelContextLimitsCache
|
||||||
|
? [...modelCacheState.modelContextLimitsCache.entries()]
|
||||||
|
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||||
|
.map(([modelKey, limit]) => `${modelKey}:${limit}`)
|
||||||
|
.join(",")
|
||||||
|
: ""
|
||||||
|
|
||||||
|
return `${modelCacheState.anthropicContext1MEnabled ? "1m" : "200k"}|${cachedLimits}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageCache(
|
||||||
|
client: ContextWindowUsageClient,
|
||||||
|
modelCacheState?: ContextLimitModelCacheState,
|
||||||
|
): Map<string, Promise<ContextWindowUsage | null>> {
|
||||||
|
let cacheByModelState = usageCacheByClient.get(client)
|
||||||
|
if (!cacheByModelState) {
|
||||||
|
cacheByModelState = new Map()
|
||||||
|
usageCacheByClient.set(client, cacheByModelState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelCacheKey = createModelCacheKey(modelCacheState)
|
||||||
|
let cache = cacheByModelState.get(modelCacheKey)
|
||||||
|
if (!cache) {
|
||||||
|
cache = new Map()
|
||||||
|
cacheByModelState.set(modelCacheKey, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateContextWindowUsageCache(ctx: PluginInput, sessionID?: string): void {
|
||||||
|
const cacheByModelState = usageCacheByClient.get(ctx.client)
|
||||||
|
if (!cacheByModelState) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cache of cacheByModelState.values()) {
|
||||||
|
if (sessionID) {
|
||||||
|
cache.delete(sessionID)
|
||||||
|
} else {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface TruncationResult {
|
export interface TruncationResult {
|
||||||
result: string;
|
result: string;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
@@ -112,11 +172,23 @@ export async function getContextWindowUsage(
|
|||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
modelCacheState?: ContextLimitModelCacheState,
|
modelCacheState?: ContextLimitModelCacheState,
|
||||||
): Promise<{
|
): Promise<ContextWindowUsage | null> {
|
||||||
usedTokens: number;
|
const cache = getUsageCache(ctx.client, modelCacheState)
|
||||||
remainingTokens: number;
|
const cached = cache.get(sessionID)
|
||||||
usagePercentage: number;
|
if (cached) {
|
||||||
} | null> {
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const usagePromise = fetchContextWindowUsage(ctx, sessionID, modelCacheState)
|
||||||
|
cache.set(sessionID, usagePromise)
|
||||||
|
return usagePromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchContextWindowUsage(
|
||||||
|
ctx: PluginInput,
|
||||||
|
sessionID: string,
|
||||||
|
modelCacheState?: ContextLimitModelCacheState,
|
||||||
|
): Promise<ContextWindowUsage | null> {
|
||||||
try {
|
try {
|
||||||
const response = await ctx.client.session.messages({
|
const response = await ctx.client.session.messages({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
|
|||||||
Reference in New Issue
Block a user