Merge pull request #4625 from code-yeongyu/fix/deduplicate-opencode-responses
fix: dedupe OpenCode idle responses
This commit is contained in:
@@ -44,17 +44,17 @@
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "4.5.1",
|
||||
"oh-my-opencode-darwin-x64": "4.5.1",
|
||||
"oh-my-opencode-darwin-x64-baseline": "4.5.1",
|
||||
"oh-my-opencode-linux-arm64": "4.5.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "4.5.1",
|
||||
"oh-my-opencode-linux-x64": "4.5.1",
|
||||
"oh-my-opencode-linux-x64-baseline": "4.5.1",
|
||||
"oh-my-opencode-linux-x64-musl": "4.5.1",
|
||||
"oh-my-opencode-linux-x64-musl-baseline": "4.5.1",
|
||||
"oh-my-opencode-windows-x64": "4.5.1",
|
||||
"oh-my-opencode-windows-x64-baseline": "4.5.1",
|
||||
"oh-my-opencode-darwin-arm64": "4.5.12",
|
||||
"oh-my-opencode-darwin-x64": "4.5.12",
|
||||
"oh-my-opencode-darwin-x64-baseline": "4.5.12",
|
||||
"oh-my-opencode-linux-arm64": "4.5.12",
|
||||
"oh-my-opencode-linux-arm64-musl": "4.5.12",
|
||||
"oh-my-opencode-linux-x64": "4.5.12",
|
||||
"oh-my-opencode-linux-x64-baseline": "4.5.12",
|
||||
"oh-my-opencode-linux-x64-musl": "4.5.12",
|
||||
"oh-my-opencode-linux-x64-musl-baseline": "4.5.12",
|
||||
"oh-my-opencode-windows-x64": "4.5.12",
|
||||
"oh-my-opencode-windows-x64-baseline": "4.5.12",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0",
|
||||
@@ -431,27 +431,27 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@4.5.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-eBpVUGaj4f8CrpETV3j5Uw184QUfSzr8skhTeCemygH8THnbbEQC5lj3KfpeDFD+iWyvG6dKXFgfD80dbFn/qg=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@4.5.12", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ivvx5oy+5HFTVWpSKcACUaByWsH6lAf8ICZ55isUmtjARVNZbkEv5c0uXelJfvqBJ114wtjAv333LWc4X30brQ=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@4.5.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/78kDxNiK4UxyNqm0sUWUvBkjlqT1b/XQ7acsR76wWxvcT/rMHOcYhbJCC9tmlfGsgiRj9mrUQQ4GyZ4AUflGQ=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@4.5.12", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-2LuglSUqPfvGTYU3CgdpeNqBYxVvndpRFiLAFkiSMDWFZ17Yv6tcqEz/PsimwITv5zXug+i9RzgIS1NKVqaESw=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@4.5.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-H40//7oWAAE4/dos5bdaLvu1dZX22DIX8u8KfJnY7/bN/+bYO5WSXu7ANakEebkzVBBHqsMfK5G6GgdvEEcolg=="],
|
||||
"oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@4.5.12", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zk+ikM/kL3WqT5bTajyLlzlJr1glvwTlt1ETqDmgG6aZ623scdclq5g6ZdgzHl9Bz03BIFEvV00rfnRiWjQf6Q=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@4.5.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-kY2z28+FXEzanYbAJNp6FuxLpr7A1nHywZMU8mQBeGT7evySHojBeAUcrCKAj+nswZkecebwTI+4Q+EfWCKwvQ=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@4.5.12", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-u73EaDVwPgrbP86zXXs8MvHiP8Wlf/W+utq95uRAu5i1GNPT/TVWXQmhz9muwTYagLgHpsiKYkU77mTsbpIMIQ=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@4.5.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-xHjRCECGzE4ZxlalBGAmptOal8+zY26RBcY0KSK81tZRs0NElEq4Oj+3tsWZXLHrdMeZJ+s2Z04//VpaQrgePA=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@4.5.12", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-qSLIqmjhc1LnV/zmX1+UZvZZdPcX2aD6cKaoYeNvt0Z7mf5iRzdymnHsABiShO62dztacXniu6KFLm5HdvYECg=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@4.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-yNBVPT/v/QaAffmEOy8r4jyih0ebGC3E3pD3aZ7YSGJ6c4xbZCMCiSftCDE0XyDT7wSr/0HUzFOVvn+EfLkbzQ=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@4.5.12", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-K5wHbomCwOAvMKx6BCpwIt92AkujhpSeXXp871ZsBxExRu4PC00X8lTCaL7wVjM/2sq8apT9c1z8sSK0mrat3Q=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@4.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/zUu9Lwl3pj9LgP5v1AKsJeOio3ikSaI7WUCAGcnomuGmG0Lbn5RzaWVGxf6kVMOneBzAyQ3sKUde630TI4fzA=="],
|
||||
"oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@4.5.12", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-byCOE84Z/nztKl8XWJoP4kgIlEUwz0ZPAit00FBSUTart8u326HZduL1Z8mzG0QTKF5oMBYVvaZRWNoPvOd3Tg=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@4.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-MxyxOZENSouJLinvNFK06eSRNIG4dq5Nh3Zct+dI48aveS/kn7IIDvUTlx5mgCFvGhMygtq/UYgT7n29GKAmpw=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@4.5.12", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-mVNLDHcNoQ0gFIuD/MseVrWo2ADB9TmGrzN26bQoHhtqDLdak9s0ukvfcXGICPIMOov3R117UpQ14mpggdjGVQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@4.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-g0gZUb9RAil2LH6QddDvhhEJGBa8w3NzFDBnfEJKRWnZwIs5Z0T4nfDtxvEDCUHXZ5q25DX/jdwTzgggIXiqlw=="],
|
||||
"oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@4.5.12", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ODhkALqNEd66ndZARYZQRH1psbFWA8oIjskR+NSWWOpJAlcecArnLfJF78M7d0MrUvan8GnxHayHZ/XSrYodUQ=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@4.5.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-JSQduUCWqHac9Sh78pqEPA3K7NCJt0TX4klUOOQbUKlbw1RIjKCh2i9zy7iW5oUGyH7vxXWWvaACSEUIaDD8HA=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@4.5.12", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-mRsNfyyDqvMpc6jD1d2VfNJW4joaeIfBKOcpJ0FtDQkPf7pWc2HVCSi/w/m7y+1dwzOFHFYAQ1UeLpsONbnNgA=="],
|
||||
|
||||
"oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@4.5.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-0u6GeWC9wUeC90dhyHw5W4DSlhAey6P4GRmWcNjnR0nStGnXlca3TOgpJHKEBZdt8tS2tosk9OfGeTZ+la+vZQ=="],
|
||||
"oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@4.5.12", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-15z4hJpzSoPkdOajd3dftso+tpGaJch8v942kttisdZIlWhSp2P0YA1Grs1n9CjsqcCj6JV50KX2eketdguTwQ=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
|
||||
@@ -530,7 +530,7 @@ Disable built-in hooks via `disabled_hooks`:
|
||||
{ "disabled_hooks": ["comment-checker"] }
|
||||
```
|
||||
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `tool-output-truncator`, `question-label-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `model-fallback`, `anthropic-context-window-limit-recovery`, `preemptive-compaction`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `thinking-block-validator`, `tool-pair-validator`, `ralph-loop`, `category-skill-reminder`, `compaction-context-injector`, `compaction-todo-preserver`, `claude-code-hooks`, `auto-slash-command`, `edit-error-recovery`, `json-error-recovery`, `delegate-task-retry`, `prometheus-md-only`, `sisyphus-junior-notepad`, `team-tool-gating`, `no-sisyphus-gpt`, `no-hephaestus-non-gpt`, `start-work`, `atlas`, `unstable-agent-babysitter`, `task-resume-info`, `stop-continuation-guard`, `tasks-todowrite-disabler`, `runtime-fallback`, `write-existing-file-guard`, `bash-file-read-guard`, `anthropic-effort`, `hashline-read-enhancer`, `read-image-resizer`, `todo-description-override`, `webfetch-redirect-guard`, `fsync-skip-warning`, `legacy-plugin-toast`
|
||||
Available hooks: `todo-continuation-enforcer`, `session-recovery`, `session-notification`, `comment-checker`, `tool-output-truncator`, `question-label-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `model-fallback`, `anthropic-context-window-limit-recovery`, `preemptive-compaction`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `thinking-block-validator`, `tool-pair-validator`, `ralph-loop`, `category-skill-reminder`, `compaction-context-injector`, `compaction-todo-preserver`, `claude-code-hooks`, `auto-slash-command`, `edit-error-recovery`, `json-error-recovery`, `delegate-task-retry`, `prometheus-md-only`, `sisyphus-junior-notepad`, `team-tool-gating`, `no-sisyphus-gpt`, `no-hephaestus-non-gpt`, `start-work`, `atlas`, `unstable-agent-babysitter`, `task-resume-info`, `stop-continuation-guard`, `tasks-todowrite-disabler`, `runtime-fallback`, `write-existing-file-guard`, `bash-file-read-guard`, `anthropic-effort`, `hashline-read-enhancer`, `read-image-resizer`, `todo-description-override`, `webfetch-redirect-guard`, `fsync-skip-warning`, `legacy-plugin-toast`
|
||||
|
||||
Guard hooks such as `team-tool-gating`, `write-existing-file-guard`, `bash-file-read-guard`, `webfetch-redirect-guard`, `prometheus-md-only`, `rules-injector`, `tool-pair-validator`, and `thinking-block-validator` protect safety, permissions, or provider protocol correctness. Disable them only for audited local debugging in a trusted environment.
|
||||
|
||||
|
||||
@@ -785,7 +785,6 @@ Current composition counts:
|
||||
| **directory-readme-injector** | PreToolUse + PostToolUse | Auto-injects README.md for directory context. |
|
||||
| **rules-injector** | PreToolUse + PostToolUse | Injects rules from `.claude/rules/` when conditions match. Supports globs and alwaysApply. |
|
||||
| **compaction-context-injector** | Event | Preserves critical context during session compaction. |
|
||||
| **context-window-monitor** | Event | Monitors context window usage and tracks token consumption. |
|
||||
| **preemptive-compaction** | Event | Proactively compacts sessions before hitting token limits. |
|
||||
|
||||
#### Productivity & Control
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ Counts verified from each composer's return object. Numbers in brackets show cou
|
||||
```
|
||||
createHooks()
|
||||
├─→ createCoreHooks()
|
||||
│ ├─ createSessionHooks() # 24: contextWindowMonitor, preemptiveCompaction, sessionRecovery,
|
||||
│ ├─ createSessionHooks() # 23: preemptiveCompaction, sessionRecovery,
|
||||
│ │ sessionNotification, thinkMode, modelFallback,
|
||||
│ │ anthropicContextWindowLimitRecovery, autoUpdateChecker,
|
||||
│ │ agentUsageReminder, nonInteractiveEnv, interactiveBashSession,
|
||||
|
||||
@@ -15,7 +15,7 @@ config/schema/
|
||||
├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent)
|
||||
├── agent-definitions.ts # custom agent definition schema
|
||||
├── categories.ts # 8 built-in + custom categories
|
||||
├── hooks.ts # HookNameSchema (53 enum values; `team-tool-gating` is the only team-* one in schema — others are wired by direct config gates)
|
||||
├── hooks.ts # HookNameSchema (52 enum values; `team-tool-gating` is the only team-* one in schema — others are wired by direct config gates)
|
||||
├── skills.ts # SkillsConfigSchema (sources, paths, recursive)
|
||||
├── commands.ts # BuiltinCommandNameSchema
|
||||
├── experimental.ts # Feature flags incl plugin_load_timeout_ms (min 1000), task_system, max_tools
|
||||
|
||||
@@ -469,6 +469,17 @@ describe("HookNameSchema", () => {
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects removed context-window-monitor hook name", () => {
|
||||
//#given
|
||||
const input = "context-window-monitor"
|
||||
|
||||
//#when
|
||||
const result = HookNameSchema.safeParse(input)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Sisyphus-Junior agent override", () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from "zod"
|
||||
|
||||
export const HookNameSchema = z.enum([
|
||||
"todo-continuation-enforcer",
|
||||
"context-window-monitor",
|
||||
"session-recovery",
|
||||
"session-notification",
|
||||
"comment-checker",
|
||||
|
||||
+4
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
52 registered hooks. The 57 directories break down as: 48 registered hook dirs (with `index.ts`) + 6 standalone hook `.ts` files (bash-file-read-guard, context-window-monitor, empty-task-response-detector, preemptive-compaction, session-notification, tool-output-truncator) + support dirs (`shared/`, `team-session-events/`, 5 `zauc-mocks-*`/`zauc-sync-mocks`, `.sisyphus/` legacy state). 5-tier composition wired in `src/plugin/hooks/`. All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
|
||||
51 registered hooks. The 56 directories break down as: 48 registered hook dirs (with `index.ts`) + 5 standalone hook `.ts` files (bash-file-read-guard, empty-task-response-detector, preemptive-compaction, session-notification, tool-output-truncator) + support dirs (`shared/`, `team-session-events/`, 5 `zauc-mocks-*`/`zauc-sync-mocks`, `.sisyphus/` legacy state). 5-tier composition wired in `src/plugin/hooks/`. All hooks follow `createXXXHook(deps) -> HookFunction` factory pattern.
|
||||
|
||||
**Unwired WIP (do not modify casually):** `task-reminder/` (has `index.ts` + `createTaskReminderHook` but NOT exported from barrel, NOT imported by any composer) and `hashline-edit-diff-enhancer/` (has only `hook.ts`, NOT registered). Treat as orphaned until wired in.
|
||||
|
||||
@@ -12,22 +12,21 @@
|
||||
|
||||
| Tier | Composer | Base | With team-mode | Where |
|
||||
|------|----------|------|----------------|-------|
|
||||
| **Session** | `create-session-hooks.ts` | 24 | 24 | OpenCode session lifecycle + chat.params + chat.message |
|
||||
| **Session** | `create-session-hooks.ts` | 23 | 23 | OpenCode session lifecycle + chat.params + chat.message |
|
||||
| **Tool Guard** | `create-tool-guard-hooks.ts` | 16 | 17 | Pre/post tool execution (+1: `team-tool-gating`) |
|
||||
| **Transform** | `create-transform-hooks.ts` | 5 | 7 | `experimental.chat.messages.transform` (+2: `team-mode-status-injector`, `team-mailbox-injector`) |
|
||||
| **Continuation** | `create-continuation-hooks.ts` | 7 | 7 | Boulder/atlas/compaction/notification |
|
||||
| **Skill** | `create-skill-hooks.ts` | 2 | 2 | Skill awareness (categorySkillReminder, autoSlashCommand) |
|
||||
| **Direct event handlers** | `src/plugin/event.ts` | 0 | +4 | `team-session-events/` sub-files: `team-idle-wake-hint`, `team-lead-orphan-handler`, `team-member-error-handler`, `team-member-status-handler` |
|
||||
|
||||
Total exposed hooks: **54 base, 61 with team-mode** (counts the 4 team-session-events handlers individually).
|
||||
Total exposed hooks: **53 base, 60 with team-mode** (counts the 4 team-session-events handlers individually).
|
||||
|
||||
Hook name allowlist for `disabled_hooks`: all configurable hook names enumerated in [`src/config/schema/hooks.ts`](file:///Users/yeongyu/local-workspaces/omo/src/config/schema/hooks.ts) `HookNameSchema`. Team-session-event sub-hooks are not individually listed in the schema — they activate together with `team_mode.enabled`.
|
||||
|
||||
### Tier 1: Session Hooks (24)
|
||||
### Tier 1: Session Hooks (23)
|
||||
|
||||
| Hook | Event | Purpose |
|
||||
|------|-------|---------|
|
||||
| `contextWindowMonitor` | session.idle | Track context usage |
|
||||
| `preemptiveCompaction` | session.idle | Trigger compaction before limit |
|
||||
| `sessionRecovery` | session.error | Recover from structural errors (tool_result_missing, thinking_block_order) |
|
||||
| `sessionNotification` | session.idle | OS notifications on completion |
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
/// <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 4.6 provider with cached context limit and 1M mode disabled", () => {
|
||||
describe("#when cached usage is below threshold of cached limit", () => {
|
||||
it("#then should respect the cached limit and skip the reminder", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6", 500000)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_anthropic_cached_limit_respected"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
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 - 160K/500K = 32%, well below 70% threshold
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when cached usage exceeds threshold of cached limit", () => {
|
||||
it("#then should use the cached limit for the reminder", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6", 500000)
|
||||
|
||||
const hook = createContextWindowMonitorHook({} as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
const sessionID = "ses_anthropic_cached_limit_exceeded"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 350000,
|
||||
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)
|
||||
|
||||
expect(output.output).toContain("context remaining")
|
||||
expect(output.output).toContain("500,000-token context window")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given older Anthropic provider with cached context limit and 1M mode disabled", () => {
|
||||
describe("#when cached usage would only exceed the incorrect cached limit", () => {
|
||||
it("#then should ignore the cached limit and use the 200K default", 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_older_model_ignores_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")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,423 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"
|
||||
import { createContextWindowMonitorHook } from "./context-window-monitor"
|
||||
|
||||
const ANTHROPIC_CONTEXT_ENV_KEY = "ANTHROPIC_1M_CONTEXT"
|
||||
const VERTEX_CONTEXT_ENV_KEY = "VERTEX_ANTHROPIC_1M_CONTEXT"
|
||||
|
||||
const originalAnthropicContextEnv = process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
const originalVertexContextEnv = process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
|
||||
function resetContextLimitEnv(): void {
|
||||
if (originalAnthropicContextEnv === undefined) {
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
} else {
|
||||
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = originalAnthropicContextEnv
|
||||
}
|
||||
|
||||
if (originalVertexContextEnv === undefined) {
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
} else {
|
||||
process.env[VERTEX_CONTEXT_ENV_KEY] = originalVertexContextEnv
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCtx() {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
describe("context-window-monitor", () => {
|
||||
let ctx: ReturnType<typeof createMockCtx>
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createMockCtx()
|
||||
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
|
||||
delete process.env[VERTEX_CONTEXT_ENV_KEY]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetContextLimitEnv()
|
||||
})
|
||||
|
||||
// #given event caches token info from message.updated
|
||||
// #when tool.execute.after is called
|
||||
// #then session.messages() should NOT be called
|
||||
it("should use cached token info instead of fetching session.messages()", async () => {
|
||||
const hook = createContextWindowMonitorHook(ctx as never)
|
||||
const sessionID = "ses_test1"
|
||||
|
||||
// Simulate message.updated event with token info
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 50000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = { title: "", output: "test output", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
// session.messages() should NOT have been called
|
||||
expect(ctx.client.session.messages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// #given no cached token info exists
|
||||
// #when tool.execute.after is called
|
||||
// #then should skip gracefully without fetching
|
||||
it("should skip gracefully when no cached token info exists", async () => {
|
||||
const hook = createContextWindowMonitorHook(ctx as never)
|
||||
const sessionID = "ses_no_cache"
|
||||
|
||||
const output = { title: "", output: "test output", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
// No fetch, no crash
|
||||
expect(ctx.client.session.messages).not.toHaveBeenCalled()
|
||||
expect(output.output).toBe("test output")
|
||||
})
|
||||
|
||||
// #given token usage exceeds 70% threshold
|
||||
// #when tool.execute.after is called
|
||||
// #then context reminder should be appended to output
|
||||
it("should append context reminder with actual token counts when usage exceeds threshold", async () => {
|
||||
const hook = createContextWindowMonitorHook(ctx as never)
|
||||
const sessionID = "ses_high_usage"
|
||||
|
||||
// 150K input + 10K cache read = 160K, which is 80% of 200K limit
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 150000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = { title: "", output: "original", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.output).toContain("context remaining")
|
||||
expect(output.output).toContain("200,000-token context window")
|
||||
expect(output.output).toContain("[Context Status: 80.0% used (160,000/200,000 tokens), 20.0% remaining]")
|
||||
expect(ctx.client.session.messages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// #given total input tokens exceed the resolved actualLimit (e.g. 1M-context
|
||||
// Anthropic model where resolveActualContextLimit falls back to the
|
||||
// 200K default for the model family)
|
||||
// #when tool.execute.after appends the context status block
|
||||
// #then the displayed used% must be clamped to 100 and remaining% must not go
|
||||
// negative. Safety-tuned models flag the >100% / negative-remaining
|
||||
// block as prompt injection (issue #3655).
|
||||
it("should clamp displayed percentages when input exceeds actualLimit (regression #3655)", async () => {
|
||||
const hook = createContextWindowMonitorHook(ctx as never)
|
||||
const sessionID = "ses_overflow"
|
||||
|
||||
// 289,370 input + 0 cache against a 200K resolved limit -> 144.7% raw,
|
||||
// -44.7% remaining if not clamped.
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 289370,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = { title: "", output: "original", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
// The block must still be emitted (we are above the 70% threshold).
|
||||
expect(output.output).toContain("[Context Status:")
|
||||
|
||||
// Extract the displayed percentages and assert clamping.
|
||||
const match = output.output.match(
|
||||
/\[Context Status: ([\d.-]+)% used \([\d,]+\/[\d,]+ tokens\), ([\d.-]+)% remaining\]/,
|
||||
)
|
||||
expect(match).not.toBeNull()
|
||||
const usedPct = Number(match![1])
|
||||
const remainingPct = Number(match![2])
|
||||
|
||||
expect(usedPct).toBeLessThanOrEqual(100)
|
||||
expect(usedPct).toBeGreaterThanOrEqual(0)
|
||||
expect(remainingPct).toBeGreaterThanOrEqual(0)
|
||||
expect(remainingPct).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it("should append context reminder for google-vertex-anthropic provider", async () => {
|
||||
//#given cached usage for google-vertex-anthropic above threshold
|
||||
const hook = createContextWindowMonitorHook(ctx as never)
|
||||
const sessionID = "ses_vertex_anthropic_high_usage"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "google-vertex-anthropic",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 150000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when tool.execute.after runs
|
||||
const output = { title: "", output: "original", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
//#then context reminder should be appended
|
||||
expect(output.output).toContain("context remaining")
|
||||
})
|
||||
|
||||
// #given only a compaction agent summary message update is seen
|
||||
// #when tool.execute.after checks context usage
|
||||
// #then stale pre-compaction tokens should not create a context reminder
|
||||
it("should ignore compaction-agent message updates when caching context usage", async () => {
|
||||
const hook = createContextWindowMonitorHook(ctx as never)
|
||||
const sessionID = "ses_compaction_agent_context"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
agent: "compaction",
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 150000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 10000, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = { title: "", output: "original", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.output).toBe("original")
|
||||
expect(ctx.client.session.messages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// #given session is deleted
|
||||
// #when session.deleted event fires
|
||||
// #then cached data should be cleaned up
|
||||
it("should clean up cache on session.deleted", async () => {
|
||||
const hook = createContextWindowMonitorHook(ctx as never)
|
||||
const sessionID = "ses_deleted"
|
||||
|
||||
// Cache some data
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
finish: true,
|
||||
tokens: { input: 150000, output: 0, reasoning: 0, cache: { read: 10000, write: 0 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Delete session
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: sessionID } },
|
||||
},
|
||||
})
|
||||
|
||||
// After deletion, no reminder should fire (cache gone, reminded set gone)
|
||||
const output = { title: "", output: "test", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
expect(output.output).toBe("test")
|
||||
})
|
||||
|
||||
// #given non-anthropic provider
|
||||
// #when message.updated fires
|
||||
// #then should not trigger reminder
|
||||
it("should ignore non-anthropic providers", async () => {
|
||||
const hook = createContextWindowMonitorHook(ctx as never)
|
||||
const sessionID = "ses_openai"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "openai",
|
||||
finish: true,
|
||||
tokens: { input: 200000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = { title: "", output: "test", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
expect(output.output).toBe("test")
|
||||
})
|
||||
|
||||
it("should use 1M limit when model cache flag is enabled", async () => {
|
||||
//#given
|
||||
const hook = createContextWindowMonitorHook(ctx as never, {
|
||||
anthropicContext1MEnabled: true,
|
||||
})
|
||||
const sessionID = "ses_1m_flag"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 300000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const output = { title: "", output: "original", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
|
||||
it("should keep env var fallback when model cache flag is disabled", async () => {
|
||||
//#given
|
||||
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true"
|
||||
const hook = createContextWindowMonitorHook(ctx as never, {
|
||||
anthropicContext1MEnabled: false,
|
||||
})
|
||||
const sessionID = "ses_env_fallback"
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
role: "assistant",
|
||||
sessionID,
|
||||
providerID: "anthropic",
|
||||
finish: true,
|
||||
tokens: {
|
||||
input: 300000,
|
||||
output: 1000,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const output = { title: "", output: "original", metadata: null }
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "bash", sessionID, callID: "call_1" },
|
||||
output
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original")
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
resolveActualContextLimit,
|
||||
type ContextLimitModelCacheState,
|
||||
} from "../shared/context-limit-resolver"
|
||||
import { isCompactionAgent } from "../shared/compaction-marker"
|
||||
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
|
||||
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
|
||||
function createContextReminder(actualLimit: number): string {
|
||||
const limitTokens = actualLimit.toLocaleString()
|
||||
|
||||
return `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
|
||||
|
||||
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
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
|
||||
interface CachedTokenState {
|
||||
providerID: string
|
||||
modelID: string
|
||||
tokens: TokenInfo
|
||||
}
|
||||
|
||||
export function createContextWindowMonitorHook(
|
||||
_ctx: PluginInput,
|
||||
modelCacheState?: ContextLimitModelCacheState,
|
||||
) {
|
||||
const remindedSessions = new Set<string>()
|
||||
const tokenCache = new Map<string, CachedTokenState>()
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
const { sessionID } = input
|
||||
|
||||
if (remindedSessions.has(sessionID)) return
|
||||
|
||||
const cached = tokenCache.get(sessionID)
|
||||
if (!cached) 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 / actualLimit
|
||||
|
||||
if (actualUsagePercentage < CONTEXT_WARNING_THRESHOLD) return
|
||||
|
||||
remindedSessions.add(sessionID)
|
||||
|
||||
// Clamp the displayed percentages so the block stays trustworthy when the
|
||||
// resolved actualLimit underestimates the model's real context window
|
||||
// (e.g. a 1M-context Anthropic model that falls back to the 200K default).
|
||||
// Without clamping, the block would advertise >100% used and a negative
|
||||
// "remaining" - safety-tuned models flag exactly that pattern as a prompt
|
||||
// injection and refuse to follow the directive (issue #3655).
|
||||
const clampedPercentage = Math.min(Math.max(actualUsagePercentage, 0), 1)
|
||||
const usedPct = (clampedPercentage * 100).toFixed(1)
|
||||
const remainingPct = ((1 - clampedPercentage) * 100).toFixed(1)
|
||||
const usedTokens = totalInputTokens.toLocaleString()
|
||||
const limitTokens = actualLimit.toLocaleString()
|
||||
|
||||
output.output += `\n\n${createContextReminder(actualLimit)}
|
||||
[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]`
|
||||
}
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = resolveSessionEventID(props)
|
||||
if (sessionID) {
|
||||
remindedSessions.delete(sessionID)
|
||||
tokenCache.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as {
|
||||
agent?: unknown
|
||||
role?: string
|
||||
sessionID?: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
finish?: unknown
|
||||
tokens?: TokenInfo
|
||||
} | undefined
|
||||
|
||||
const finish = info?.finish
|
||||
if (!info || info.role !== "assistant" || !finish) return
|
||||
if (isCompactionAgent(info.agent)) return
|
||||
const sessionID = resolveMessageEventSessionID(props)
|
||||
if (!sessionID || !info.providerID || !info.tokens) return
|
||||
|
||||
tokenCache.set(sessionID, {
|
||||
providerID: info.providerID,
|
||||
modelID: info.modelID ?? "",
|
||||
tokens: info.tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
||||
export { createContextWindowMonitorHook } from "./context-window-monitor";
|
||||
export { createSessionNotification } from "./session-notification";
|
||||
export { sendSessionNotification, playSessionNotificationSound, detectPlatform, getDefaultSoundPath } from "./session-notification-sender";
|
||||
export { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting";
|
||||
|
||||
@@ -29,7 +29,6 @@ function createMinimalEventHandler() {
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
|
||||
@@ -391,7 +391,7 @@ describe("createEventHandler - idle deduplication", () => {
|
||||
expect(spawnTmuxPane).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("does NOT dedup real-idle-after-synthetic-idle within 500ms", async () => {
|
||||
it("#given session.status already emitted a synthetic idle #when real session.idle follows immediately #then hooks run once", async () => {
|
||||
//#given
|
||||
const dispatchCalls: EventInput[] = []
|
||||
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
|
||||
@@ -415,11 +415,9 @@ describe("createEventHandler - idle deduplication", () => {
|
||||
}))
|
||||
|
||||
//#then
|
||||
expect(dispatchCalls).toHaveLength(2)
|
||||
expect(dispatchCalls).toHaveLength(1)
|
||||
expect(dispatchCalls[0]?.event.type).toBe("session.idle")
|
||||
expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
|
||||
expect(dispatchCalls[1]?.event.type).toBe("session.idle")
|
||||
expect((dispatchCalls[1]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
|
||||
})
|
||||
|
||||
it("#given idle recovery handles an interrupted tool turn #when session.idle arrives #then later idle hooks are skipped for that event", async () => {
|
||||
@@ -619,7 +617,7 @@ describe("createEventHandler - idle deduplication", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps other session dedup state untouched when bypassing synthetic-idle for current session", async () => {
|
||||
it("keeps other session dedup state untouched when suppressing real-idle-after-synthetic-idle", async () => {
|
||||
//#given
|
||||
const originalDateNow = Date.now
|
||||
let currentNow = 30_000
|
||||
@@ -679,7 +677,7 @@ describe("createEventHandler - idle deduplication", () => {
|
||||
}))
|
||||
|
||||
//#then
|
||||
expect(dispatchedSessionIds).toEqual(["ses_a", "ses_b", "ses_a"])
|
||||
expect(dispatchedSessionIds).toEqual(["ses_a", "ses_b"])
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
@@ -828,7 +826,6 @@ describe("createEventHandler - idle deduplication", () => {
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
@@ -913,7 +910,6 @@ describe("createEventHandler - idle deduplication", () => {
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
@@ -970,7 +966,6 @@ describe("createEventHandler - idle deduplication", () => {
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
|
||||
@@ -310,7 +310,6 @@ export function createEventHandler(args: {
|
||||
await runEventHookSafely("sessionNotification", hooks.sessionNotification, input);
|
||||
await runEventHookSafely("todoContinuationEnforcer", hooks.todoContinuationEnforcer?.handler, input);
|
||||
await runEventHookSafely("unstableAgentBabysitter", hooks.unstableAgentBabysitter?.event, input);
|
||||
await runEventHookSafely("contextWindowMonitor", hooks.contextWindowMonitor?.event, input);
|
||||
await runEventHookSafely("preemptiveCompaction", hooks.preemptiveCompaction?.event, input);
|
||||
await runEventHookSafely("directoryAgentsInjector", hooks.directoryAgentsInjector?.event, input);
|
||||
await runEventHookSafely("directoryReadmeInjector", hooks.directoryReadmeInjector?.event, input);
|
||||
@@ -594,12 +593,6 @@ export function createEventHandler(args: {
|
||||
const emittedAt = recentSyntheticIdles.get(sessionID);
|
||||
if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) {
|
||||
recentSyntheticIdles.delete(sessionID);
|
||||
// Let real idle events through even when a synthetic idle fired moments earlier.
|
||||
// OpenCode diagnostics expect a concrete session.idle event signal.
|
||||
const lastAnyIdleAt = recentAnyIdles.get(sessionID);
|
||||
if (lastAnyIdleAt === emittedAt) {
|
||||
recentAnyIdles.delete(sessionID);
|
||||
}
|
||||
}
|
||||
}
|
||||
const recovered = await recoverInterruptedToolResultsOnIdleEvent(input);
|
||||
|
||||
@@ -55,6 +55,23 @@ describe("createSessionHooks", () => {
|
||||
expect(result.modelFallback).not.toBeNull()
|
||||
})
|
||||
|
||||
it("does not create removed context window monitor hook", () => {
|
||||
// given
|
||||
const pluginConfig = {} as OhMyOpenCodeConfig
|
||||
|
||||
// when
|
||||
const result = createSessionHooks({
|
||||
ctx: mockContext,
|
||||
pluginConfig,
|
||||
modelCacheState: mockModelCacheState,
|
||||
isHookEnabled: (hookName) => hookName === "context-window-monitor",
|
||||
safeHookEnabled: true,
|
||||
})
|
||||
|
||||
// then
|
||||
expect("contextWindowMonitor" in result).toBe(false)
|
||||
})
|
||||
|
||||
it("skips interactive bash session hook when tmux integration is disabled", () => {
|
||||
// given
|
||||
const pluginConfig = {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { ModelCacheState } from "../../plugin-state"
|
||||
import type { PluginContext } from "../types"
|
||||
|
||||
import {
|
||||
createContextWindowMonitorHook,
|
||||
createSessionRecoveryHook,
|
||||
createSessionNotification,
|
||||
createThinkModeHook,
|
||||
@@ -41,7 +40,6 @@ import { sessionExists } from "../../tools"
|
||||
import { isTmuxIntegrationEnabled } from "../../create-runtime-tmux-config"
|
||||
|
||||
export type SessionHooks = {
|
||||
contextWindowMonitor: ReturnType<typeof createContextWindowMonitorHook> | null
|
||||
preemptiveCompaction: ReturnType<typeof createPreemptiveCompactionHook> | null
|
||||
sessionRecovery: ReturnType<typeof createSessionRecoveryHook> | null
|
||||
sessionNotification: ReturnType<typeof createSessionNotification> | null
|
||||
@@ -80,11 +78,6 @@ export function createSessionHooks(args: {
|
||||
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
|
||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||
|
||||
const contextWindowMonitor = isHookEnabled("context-window-monitor")
|
||||
? safeHook("context-window-monitor", () =>
|
||||
createContextWindowMonitorHook(ctx, modelCacheState))
|
||||
: null
|
||||
|
||||
const preemptiveCompaction =
|
||||
isHookEnabled("preemptive-compaction") &&
|
||||
pluginConfig.experimental?.preemptive_compaction
|
||||
@@ -277,7 +270,6 @@ export function createSessionHooks(args: {
|
||||
: null
|
||||
|
||||
return {
|
||||
contextWindowMonitor,
|
||||
preemptiveCompaction,
|
||||
sessionRecovery,
|
||||
sessionNotification,
|
||||
|
||||
@@ -162,7 +162,6 @@ export function createToolExecuteAfterHandler(args: {
|
||||
await hooks.toolOutputTruncator?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.claudeCodeHooks?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.preemptiveCompaction?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.contextWindowMonitor?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.commentChecker?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.directoryAgentsInjector?.["tool.execute.after"]?.(hookInput, output)
|
||||
await hooks.directoryReadmeInjector?.["tool.execute.after"]?.(hookInput, output)
|
||||
|
||||
@@ -31,7 +31,6 @@ function createMinimalEventHandler() {
|
||||
sessionNotification: async () => {},
|
||||
todoContinuationEnforcer: { handler: async () => {} },
|
||||
unstableAgentBabysitter: { event: async () => {} },
|
||||
contextWindowMonitor: { event: async () => {} },
|
||||
directoryAgentsInjector: { event: async () => {} },
|
||||
directoryReadmeInjector: { event: async () => {} },
|
||||
rulesInjector: { event: async () => {} },
|
||||
|
||||
Reference in New Issue
Block a user