diff --git a/signatures/cla.json b/signatures/cla.json index 467ec11f5..fe3c257d7 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -3023,6 +3023,22 @@ "created_at": "2026-04-28T03:32:31Z", "repoId": 1108837393, "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 } ] } \ No newline at end of file diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 5cceda373..2df90e917 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -177,6 +177,7 @@ export class BackgroundManager { private tasks: Map + private tasksByParentSession: Map> private notifications: Map private pendingNotifications: Map private pendingByParent: Map> // Track pending tasks per parent for batching @@ -218,6 +219,7 @@ export class BackgroundManager { } ) { this.tasks = new Map() + this.tasksByParentSession = new Map() this.notifications = new Map() this.pendingNotifications = new Map() this.pendingByParent = new Map() @@ -322,6 +324,50 @@ export class BackgroundManager { 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() + 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() + 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 { log("[background-agent] launch() called with:", { agent: input.agent, @@ -368,7 +414,7 @@ export class BackgroundManager { } 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 }) // 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[] { - const result: BackgroundTask[] = [] - for (const task of this.tasks.values()) { - if (task.parentSessionID === sessionID) { - result.push(task) + const taskIDs = this.tasksByParentSession.get(sessionID) + if (!taskIDs) { + const result: BackgroundTask[] = [] + 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[] { @@ -830,7 +888,7 @@ The fallback retry session is now created and can be inspected directly. const parentChanged = input.parentSessionID !== existingTask.parentSessionID if (parentChanged) { this.cleanupPendingByParent(existingTask) // Clean from OLD parent - existingTask.parentSessionID = input.parentSessionID + this.updateTaskParent(existingTask, input.parentSessionID) } if (input.parentAgent !== undefined) { existingTask.parentAgent = input.parentAgent @@ -885,7 +943,7 @@ The fallback retry session is now created and can be inspected directly. concurrencyGroup, } - this.tasks.set(task.id, task) + this.addTask(task) subagentSessions.add(input.sessionID) 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 }) @@ -935,7 +993,7 @@ The fallback retry session is now created and can be inspected directly. existingTask.status = "running" existingTask.completedAt = undefined existingTask.error = undefined - existingTask.parentSessionID = input.parentSessionID + this.updateTaskParent(existingTask, input.parentSessionID) existingTask.parentMessageID = input.parentMessageID existingTask.parentModel = input.parentModel 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.tasks.delete(taskId) + this.removeTask(task) this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID) if (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.tasks.clear() + this.tasksByParentSession.clear() this.notifications.clear() this.pendingNotifications.clear() this.pendingByParent.clear() diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 652c000f5..093312d4a 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -141,6 +141,22 @@ function extractFilePath(metadata: unknown): string | undefined { return undefined } +function extractLineCount(metadata: unknown): number | undefined { + if (!metadata || typeof metadata !== "object") { + return undefined + } + + const objectMeta = metadata as Record + 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 { if (output.output.startsWith(WRITE_SUCCESS_MARKER)) { return @@ -151,6 +167,12 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk return } + const metadataLineCount = extractLineCount(output.metadata) + if (metadataLineCount !== undefined) { + output.output = `${WRITE_SUCCESS_MARKER} ${metadataLineCount} lines written.` + return + } + const filePath = extractFilePath(output.metadata) if (!filePath) { return diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index dcab65bc9..b46ffa88e 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -11,9 +11,9 @@ function mockCtx(): PluginInput { return { client: {} as PluginInput["client"], directory: "/test", - project: "/test" as unknown as PluginInput["project"], + project: "/test" as PluginInput["project"], worktree: "/test", - serverUrl: "http://localhost" as unknown as PluginInput["serverUrl"], + serverUrl: "http://localhost" as PluginInput["serverUrl"], $: {} as PluginInput["$"], } } @@ -238,6 +238,26 @@ describe("hashline-read-enhancer", () => { 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 () => { //#given — write tool failed, but stale file exists from previous write const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } }) diff --git a/src/hooks/preemptive-compaction-degradation-monitor.ts b/src/hooks/preemptive-compaction-degradation-monitor.ts index 6c93a0e4e..29605ac96 100644 --- a/src/hooks/preemptive-compaction-degradation-monitor.ts +++ b/src/hooks/preemptive-compaction-degradation-monitor.ts @@ -43,6 +43,7 @@ interface ClientLike { export interface AssistantCompactionMessageInfo { sessionID: string id?: string + parts?: unknown } async function withTimeout( @@ -185,6 +186,7 @@ export function createPostCompactionDegradationMonitor(args: { sessionID: info.sessionID, messageID: info.id, directory, + parts: info.parts, }) if (!isNoTextTail) { diff --git a/src/hooks/preemptive-compaction-no-text-tail.ts b/src/hooks/preemptive-compaction-no-text-tail.ts index 712ed1ff3..b3ca2dd66 100644 --- a/src/hooks/preemptive-compaction-no-text-tail.ts +++ b/src/hooks/preemptive-compaction-no-text-tail.ts @@ -46,8 +46,13 @@ export async function resolveNoTextTailFromSession(args: { sessionID: string messageID?: string directory: string + parts?: unknown }): Promise { - const { client, sessionID, messageID, directory } = args + const { client, sessionID, messageID, directory, parts } = args + + if (Array.isArray(parts)) { + return isStepOnlyNoTextParts(parts) + } try { const response = await client.session.messages({ diff --git a/src/hooks/preemptive-compaction.degradation-monitor.test.ts b/src/hooks/preemptive-compaction.degradation-monitor.test.ts index ae7f73a57..4399c81f7 100644 --- a/src/hooks/preemptive-compaction.degradation-monitor.test.ts +++ b/src/hooks/preemptive-compaction.degradation-monitor.test.ts @@ -192,4 +192,28 @@ describe("preemptive-compaction post-compaction degradation monitor", () => { // then 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() + }) }) diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index 7b4828dcb..a8da4b91e 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -76,6 +76,7 @@ export function createPreemptiveCompactionHook( modelID?: string finish?: boolean tokens?: TokenInfo + parts?: unknown } | undefined if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return @@ -92,6 +93,7 @@ export function createPreemptiveCompactionHook( await postCompactionMonitor.onAssistantMessageUpdated({ sessionID: info.sessionID, id: info.id, + parts: info.parts, }) } } diff --git a/src/hooks/todo-continuation-enforcer/idle-event.test.ts b/src/hooks/todo-continuation-enforcer/idle-event.test.ts new file mode 100644 index 000000000..3e9746fdc --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/idle-event.test.ts @@ -0,0 +1,99 @@ +/// + +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]) + }) +}) diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 162b60f6d..eebd83315 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -108,7 +108,6 @@ export async function handleSessionIdle(args: { } if (!todos || todos.length === 0) { - sessionStateStore.resetContinuationProgress(sessionID) sessionStateStore.resetContinuationProgress(sessionID) log(`[${HOOK_NAME}] No todos`, { sessionID }) return @@ -116,7 +115,6 @@ export async function handleSessionIdle(args: { const incompleteCount = getIncompleteCount(todos) if (incompleteCount === 0) { - sessionStateStore.resetContinuationProgress(sessionID) sessionStateStore.resetContinuationProgress(sessionID) log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length }) return diff --git a/src/plugin/event.ts b/src/plugin/event.ts index abfa84cac..686f55fae 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -1,4 +1,5 @@ import type { OhMyOpenCodeConfig } from "../config"; +import type { PluginInput } from "@opencode-ai/plugin"; import type { PluginContext } from "./types"; import { @@ -26,6 +27,7 @@ import { import { resetMessageCursor } from "../shared"; import { getAgentConfigKey } from "../shared/agent-display-names"; import { readConnectedProvidersCache } from "../shared/connected-providers-cache"; +import { invalidateContextWindowUsageCache } from "../shared/dynamic-truncator"; import { log } from "../shared/logger"; import { shouldRetryError } from "../shared/model-error-classifier"; import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models"; @@ -161,8 +163,7 @@ export function createEventHandler(args: { body: { parts: Array<{ type: "text"; text: string }> }; query: { directory: string }; }) => Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - summarize: (...args: any[]) => Promise; + summarize: (...args: unknown[]) => Promise; }; }; }; @@ -476,6 +477,9 @@ export function createEventHandler(args: { const sessionID = info?.sessionID as string | undefined; const agent = info?.agent as string | undefined; const role = info?.role as string | undefined; + if (sessionID && info?.finish === true) { + invalidateContextWindowUsageCache(pluginContext as PluginInput, sessionID); + } if (sessionID && role === "user") { const isCompactionMessage = agent ? isCompactionAgent(agent) : false; if (agent && !isCompactionMessage) { diff --git a/src/shared/dynamic-truncator.test.ts b/src/shared/dynamic-truncator.test.ts index 3e19512a7..d090dd260 100644 --- a/src/shared/dynamic-truncator.test.ts +++ b/src/shared/dynamic-truncator.test.ts @@ -2,7 +2,7 @@ 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 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", () => { afterEach(() => { resetContextLimitEnv() @@ -125,6 +158,39 @@ describe("getContextWindowUsage", () => { 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 () => { // given const ctx = createContextUsageMockContext(180000, { diff --git a/src/shared/dynamic-truncator.ts b/src/shared/dynamic-truncator.ts index 3b445759f..504f1a73b 100644 --- a/src/shared/dynamic-truncator.ts +++ b/src/shared/dynamic-truncator.ts @@ -24,6 +24,66 @@ interface MessageWrapper { info: { role: string } & Partial; } +type ContextWindowUsage = { + usedTokens: number; + remainingTokens: number; + usagePercentage: number; +} + +type ContextWindowUsageClient = Pick + +const usageCacheByClient = new WeakMap>>>() + +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> { + 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 { result: string; truncated: boolean; @@ -112,11 +172,23 @@ export async function getContextWindowUsage( ctx: PluginInput, sessionID: string, modelCacheState?: ContextLimitModelCacheState, -): Promise<{ - usedTokens: number; - remainingTokens: number; - usagePercentage: number; -} | null> { +): Promise { + const cache = getUsageCache(ctx.client, modelCacheState) + const cached = cache.get(sessionID) + if (cached) { + return cached + } + + const usagePromise = fetchContextWindowUsage(ctx, sessionID, modelCacheState) + cache.set(sessionID, usagePromise) + return usagePromise +} + +async function fetchContextWindowUsage( + ctx: PluginInput, + sessionID: string, + modelCacheState?: ContextLimitModelCacheState, +): Promise { try { const response = await ctx.client.session.messages({ path: { id: sessionID },