Compare commits

..

41 Commits

Author SHA1 Message Date
github-actions[bot]
ebd26b7421 release: v3.8.4 2026-02-23 17:11:38 +00:00
YeonGyu-Kim
9f804c2a6a fix(test): sync AGENTS_WITH_TODO_DENY with tool-config-handler implementation 2026-02-24 02:08:30 +09:00
YeonGyu-Kim
05c04838f4 test(hashline-edit): cover concise responses and anchor alias normalization
Update expectations to the new pi-style response contract and add cases for one-anchor replace_lines fallback plus after_line alias handling.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 18:51:37 +09:00
YeonGyu-Kim
86671ad25c refactor(hashline-edit): adopt normalized single-shape edit input
Keep current field names but accept a pi-style flexible edit payload that is normalized to concrete operations at execution time.

Response now follows concise update/move status with diff metadata retained, removing full-file hashline echo to reduce model feedback loops.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 18:51:32 +09:00
YeonGyu-Kim
ab768029fa refactor(hashline-edit): stabilize hashes and tighten prefix stripping
Switch line hashing to significance-aware seeding so meaningful lines stay stable across reflows while punctuation-only lines still disambiguate by line index.

Also narrow prefix stripping to hashline/diff patterns that reduce accidental content corruption during edit normalization.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 18:51:25 +09:00
github-actions[bot]
afec1f2928 @DMax1314 has signed the CLA in code-yeongyu/oh-my-opencode#2068 2026-02-23 07:06:25 +00:00
YeonGyu-Kim
41fe6ad2e4 fix(tools/call-omo-agent): replace as any with Record type cast in session-creator
Cast session body to Record<string, unknown> instead of as any

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:48 +09:00
YeonGyu-Kim
b47b034209 chore(assets): regenerate JSON schema
Regenerate oh-my-opencode.schema.json after config export changes

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:19 +09:00
YeonGyu-Kim
a37a6044dc refactor(config): remove unused barrel exports
Clean up unused re-exports from config barrel file

Remove 14 unused schema exports identified by knip analysis

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:17 +09:00
YeonGyu-Kim
7a01035736 refactor(agents/prometheus): remove unused barrel exports
Clean up unused re-exports from prometheus agents barrel file

Remove 9 unused exports identified by knip analysis

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:16 +09:00
YeonGyu-Kim
f1076d978e refactor(agents/atlas): remove unused barrel exports
Clean up unused re-exports from atlas agents barrel file

Remove 12 unused exports identified by knip analysis

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:14 +09:00
YeonGyu-Kim
3a5aaf6488 refactor(agents): remove unused barrel exports
Clean up unused re-exports from agents barrel file

Remove 24 unused exports identified by knip analysis

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:12 +09:00
YeonGyu-Kim
830dcf8d2f refactor(features): remove empty barrel files
Delete 2 empty barrel index.ts files:

- claude-tasks/index.ts

- mcp-oauth/index.ts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:11 +09:00
YeonGyu-Kim
96d51418d6 refactor(hooks): remove dead hook files
Delete 3 unused hook files:

- hashline-edit-diff-enhancer/index.ts (and test file)

- session-recovery/recover-empty-content-message.ts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:08 +09:00
YeonGyu-Kim
b3a6aaa843 refactor(shared): remove dead utility files
Delete 4 unused utility files:

- models-json-cache-reader.ts

- open-code-client-accessors.ts

- open-code-client-shapes.ts

- provider-models-cache-model-reader.ts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:06 +09:00
YeonGyu-Kim
1f62fa5b2a refactor(tools/call-omo-agent): remove dead code submodules
Delete 3 unused files in call-omo-agent module:

- session-completion-poller.ts

- session-message-output-extractor.ts

- subagent-session-prompter.ts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:04 +09:00
YeonGyu-Kim
2428a46e6d refactor(features/background-agent): remove dead code submodules
Delete 15 unused files in background-agent module:

- background-task-completer.ts

- format-duration.ts

- message-dir.ts

- parent-session-context-resolver.ts

- parent-session-notifier.ts (and its test file)

- result-handler-context.ts

- result-handler.ts

- session-output-validator.ts

- session-task-cleanup.ts

- session-todo-checker.ts

- spawner/background-session-creator.ts

- spawner/concurrency-key-from-launch-input.ts

- spawner/spawner-context.ts

- spawner/tmux-callback-invoker.ts

Update index.ts barrel and manager.ts/spawner.ts imports

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:43:01 +09:00
YeonGyu-Kim
b709fa8e83 fix(plugin/hooks): remove unnecessary as any cast
Remove as any from modelCacheState parameter

Structural typing works without explicit cast

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:45 +09:00
YeonGyu-Kim
0dc5f56af4 fix(shared): fix optional chaining on modelItem
Change modelItem.id to modelItem?.id to handle null values

Prevents TypeError when modelItem is null in provider-models cache

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:43 +09:00
YeonGyu-Kim
cd6c9cb5dc fix(cli/run): replace as any with Record type cast
Cast session body to Record<string, unknown> instead of as any

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:40 +09:00
YeonGyu-Kim
e5aa08b865 fix(tools/delegate-task): replace as any with Record type cast
Cast session body to Record<string, unknown> instead of as any

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:38 +09:00
YeonGyu-Kim
db15f96cd8 fix(tools/call-omo-agent): replace as any with SessionWithPromptAsync type
Add SessionWithPromptAsync local type for promptAsync access

Remove as any cast from session.promptAsync call

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:37 +09:00
YeonGyu-Kim
ff0e9ac557 fix(tools/call-omo-agent): replace as any with SDKMessage interface
Add SDKMessage local interface for message type safety

Replace any lambda params and message casts with SDKMessage

Remove eslint-disable comments for no-explicit-any

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:34 +09:00
YeonGyu-Kim
07113ebe94 fix(features/task-toast-manager): replace as any with ClientWithTui type
Add ClientWithTui local type for tui.showToast access

Remove 2 as any casts and eslint-disable comments

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:32 +09:00
YeonGyu-Kim
2d3d993eb6 fix(hooks/shared): replace as any with proper Record type cast
Cast pluginConfig.agents to Record type with proper structure

Remove eslint-disable comment for no-explicit-any

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:30 +09:00
YeonGyu-Kim
a82f4ee86a fix(hooks/thinking-block-validator): replace as any with typed interfaces
Add ThinkingPart and MessageInfoExtended local interfaces

Replace 3 as any casts with proper unknown-to-typed casts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:28 +09:00
YeonGyu-Kim
0cbc6b5410 fix(hooks/session-recovery): replace @ts-expect-error with proper type cast
Add ClientWithPromptAsync local type to avoid @ts-expect-error

Cast client to proper type before calling session.promptAsync

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:26 +09:00
YeonGyu-Kim
ac3a9fd272 fix(hooks/anthropic-context-window-limit-recovery): remove @ts-ignore comments and fix parameter types
Remove @ts-ignore and eslint-disable comments from executor.ts and recovery-hook.ts

- Change client: any to client: Client with proper import

- Rename experimental to _experimental for unused parameter

- Remove @ts-ignore for ctx.client casts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-23 02:42:24 +09:00
github-actions[bot]
41880f8ffb @imadal1n has signed the CLA in code-yeongyu/oh-my-opencode#2045 2026-02-22 10:57:45 +00:00
YeonGyu-Kim
35ab9b19c8 fix: deny todo tools for prometheus and sisyphus-junior when task_system enabled
Amp-Thread-ID: https://ampcode.com/threads/T-019c848f-b2a8-7037-9eb5-a258df14b683
Co-authored-by: Amp <amp@ampcode.com>
2026-02-22 17:58:42 +09:00
YeonGyu-Kim
6245e46885 feat(hooks): add Gemini-optimized ultrawork message with intent gate
Create dedicated Gemini ultrawork variant that enforces intent
classification as mandatory Step 0 before any action. Routes Gemini
models to the new variant via source-detector priority chain
(planner > GPT > Gemini > default). Includes anti-optimism checkpoint
and tool-call mandate sections tuned for Gemini's eager behavior.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-22 17:40:38 +09:00
YeonGyu-Kim
76da95116e feat(agents): add Gemini intent gate enforcement overlay for Sisyphus
Counter Gemini's tendency to skip Phase 0 intent classification by
injecting a mandatory self-check gate before tool calls. Includes
intent type classification, anti-skip mechanism, and common mistake
table showing wrong vs correct behavior per intent type.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-02-22 17:40:20 +09:00
YeonGyu-Kim
9933c6654f feat(model-fallback): disable model fallback retry by default
Model fallback is now opt-in via `model_fallback: true` in plugin config,
matching the runtime-fallback pattern. Prevents unexpected automatic model
switching on API errors unless explicitly enabled.
2026-02-22 17:25:04 +09:00
YeonGyu-Kim
2e845c8d99 feat(hooks): wire pluginConfig to preemptive-compaction hook factory 2026-02-22 17:19:46 +09:00
YeonGyu-Kim
bcf7fff9b9 feat(recovery-strategy): apply compaction model override in context window recovery 2026-02-22 17:19:43 +09:00
YeonGyu-Kim
2d069ce4cc feat(preemptive-compaction): apply compaction model override from agent config 2026-02-22 17:19:39 +09:00
YeonGyu-Kim
09314dba1a feat(schema): add compaction model and variant override configuration 2026-02-22 17:19:35 +09:00
YeonGyu-Kim
32a838ad3c feat(hooks): add compaction-model-resolver utility for session agent model lookup 2026-02-22 17:19:31 +09:00
YeonGyu-Kim
edf4d522d1 Merge pull request #2041 from code-yeongyu/fix/rewrite-overmocked-tests
refactor(tests): rewrite 5 over-mocked test files to test real behavior
2026-02-22 16:54:13 +09:00
YeonGyu-Kim
0bae7ec4fc chore(tests): remove duplicate test in background-update-check (cubic feedback) 2026-02-22 16:51:04 +09:00
YeonGyu-Kim
7e05bd2b8e refactor(tests): rewrite 5 over-mocked test files to test real behavior
- formatter.test.ts: use dynamic imports with cache-busting to avoid mock pollution from runner.test.ts; test real format output instead of dispatch mocking
- hook.test.ts: rewrite with proper branch coverage (7 tests), add success/guard/subagent paths
- background-update-check.test.ts: rewrite with 10 tests covering all branches (early returns, pinned versions, auto-update success/failure)
- directory-agents-injector/injector.test.ts: replace finder/storage mocks with real filesystem + temp directories, verify actual AGENTS.md injection content
- directory-readme-injector/injector.test.ts: same pattern as agents-injector but for README.md, verifies root inclusion behavior
2026-02-22 16:43:56 +09:00
84 changed files with 1853 additions and 1947 deletions

View File

@@ -82,6 +82,9 @@
"hashline_edit": {
"type": "boolean"
},
"model_fallback": {
"type": "boolean"
},
"agents": {
"type": "object",
"properties": {
@@ -288,6 +291,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -495,6 +510,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -702,6 +729,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -909,6 +948,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1116,6 +1167,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1323,6 +1386,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1530,6 +1605,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1737,6 +1824,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -1944,6 +2043,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2151,6 +2262,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2358,6 +2481,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2565,6 +2700,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2772,6 +2919,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -2979,6 +3138,18 @@
}
},
"additionalProperties": false
},
"compaction": {
"type": "object",
"properties": {
"model": {
"type": "string"
},
"variant": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.8.3",
"version": "3.8.4",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.3",
"oh-my-opencode-darwin-x64": "3.8.3",
"oh-my-opencode-linux-arm64": "3.8.3",
"oh-my-opencode-linux-arm64-musl": "3.8.3",
"oh-my-opencode-linux-x64": "3.8.3",
"oh-my-opencode-linux-x64-musl": "3.8.3",
"oh-my-opencode-windows-x64": "3.8.3"
"oh-my-opencode-darwin-arm64": "3.8.4",
"oh-my-opencode-darwin-x64": "3.8.4",
"oh-my-opencode-linux-arm64": "3.8.4",
"oh-my-opencode-linux-arm64-musl": "3.8.4",
"oh-my-opencode-linux-x64": "3.8.4",
"oh-my-opencode-linux-x64-musl": "3.8.4",
"oh-my-opencode-windows-x64": "3.8.4"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.8.3",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.8.3",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.8.3",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.8.3",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.8.3",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.8.3",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.8.3",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1679,6 +1679,22 @@
"created_at": "2026-02-21T22:44:45Z",
"repoId": 1108837393,
"pullRequestNo": 2029
},
{
"name": "imadal1n",
"id": 97968636,
"comment_id": 3940704780,
"created_at": "2026-02-22T10:57:33Z",
"repoId": 1108837393,
"pullRequestNo": 2045
},
{
"name": "DMax1314",
"id": 54206290,
"comment_id": 3943046087,
"created_at": "2026-02-23T07:06:14Z",
"repoId": 1108837393,
"pullRequestNo": 2068
}
]
}

View File

@@ -1,15 +1,2 @@
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
export { ATLAS_GEMINI_SYSTEM_PROMPT, getGeminiAtlasPrompt } from "./gemini"
export {
getCategoryDescription,
buildAgentSelectionSection,
buildCategorySection,
buildSkillsSection,
buildDecisionMatrix,
} from "./prompt-section-builder"
export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
export { createAtlasAgent, atlasPromptMetadata } from "./agent"
export type { AtlasPromptSource, OrchestratorContext } from "./agent"
export { isGptModel } from "../types"

View File

@@ -1,28 +1,4 @@
export * from "./types"
export { createBuiltinAgents } from "./builtin-agents"
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
export { createSisyphusAgent } from "./sisyphus"
export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
export { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
export { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
export { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
export { createMetisAgent, METIS_SYSTEM_PROMPT, metisPromptMetadata } from "./metis"
export { createMomusAgent, MOMUS_SYSTEM_PROMPT, momusPromptMetadata } from "./momus"
export { createAtlasAgent, atlasPromptMetadata } from "./atlas"
export {
PROMETHEUS_SYSTEM_PROMPT,
PROMETHEUS_PERMISSION,
PROMETHEUS_GPT_SYSTEM_PROMPT,
getPrometheusPrompt,
getPrometheusPromptSource,
getGptPrometheusPrompt,
PROMETHEUS_IDENTITY_CONSTRAINTS,
PROMETHEUS_INTERVIEW_MODE,
PROMETHEUS_PLAN_GENERATION,
PROMETHEUS_HIGH_ACCURACY_MODE,
PROMETHEUS_PLAN_TEMPLATE,
PROMETHEUS_BEHAVIORAL_SUMMARY,
} from "./prometheus"
export type { PrometheusPromptSource } from "./prometheus"

View File

@@ -2,16 +2,5 @@ export {
PROMETHEUS_SYSTEM_PROMPT,
PROMETHEUS_PERMISSION,
getPrometheusPrompt,
getPrometheusPromptSource,
} from "./system-prompt"
export type { PrometheusPromptSource } from "./system-prompt"
export { PROMETHEUS_GPT_SYSTEM_PROMPT, getGptPrometheusPrompt } from "./gpt"
export { PROMETHEUS_GEMINI_SYSTEM_PROMPT, getGeminiPrometheusPrompt } from "./gemini"
// Re-export individual sections for granular access
export { PROMETHEUS_IDENTITY_CONSTRAINTS } from "./identity-constraints"
export { PROMETHEUS_INTERVIEW_MODE } from "./interview-mode"
export { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
export { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
export { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
export { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"

View File

@@ -6,6 +6,8 @@
* - Avoid delegation, preferring to do work themselves
* - Claim completion without verification
* - Interpret constraints as suggestions
* - Skip intent classification gates (jump straight to action)
* - Conflate investigation with implementation ("look into X" → starts coding)
*
* These overlays inject corrective sections at strategic points
* in the dynamic Sisyphus prompt to counter these tendencies.
@@ -77,3 +79,39 @@ Your internal confidence estimator is miscalibrated toward optimism. What feels
4. If you delegated, read EVERY file the subagent touched — not trust their claims
</GEMINI_VERIFICATION_OVERRIDE>`;
}
export function buildGeminiIntentGateEnforcement(): string {
return `<GEMINI_INTENT_GATE_ENFORCEMENT>
## YOU MUST CLASSIFY INTENT BEFORE ACTING. NO EXCEPTIONS.
**Your failure mode: You skip intent classification and jump straight to implementation.**
You see a user message and your instinct is to immediately start working. WRONG. You MUST first determine WHAT KIND of work the user wants. Getting this wrong wastes everything that follows.
**MANDATORY FIRST OUTPUT — before ANY tool call or action:**
\`\`\`
I detect [TYPE] intent — [REASON].
My approach: [ROUTING DECISION].
\`\`\`
Where TYPE is one of: research | implementation | investigation | evaluation | fix | open-ended
**SELF-CHECK (answer honestly before proceeding):**
1. Did the user EXPLICITLY ask me to implement/build/create something? → If NO, do NOT implement.
2. Did the user say "look into", "check", "investigate", "explain"? → That means RESEARCH, not implementation.
3. Did the user ask "what do you think?" → That means EVALUATION — propose and WAIT, do not execute.
4. Did the user report an error? → That means MINIMAL FIX, not refactoring.
**COMMON MISTAKES YOU MAKE (AND MUST NOT):**
| User Says | You Want To Do | You MUST Do |
| "explain how X works" | Start modifying X | Research X, explain it, STOP |
| "look into this bug" | Fix the bug immediately | Investigate, report findings, WAIT for go-ahead |
| "what do you think about approach X?" | Implement approach X | Evaluate X, propose alternatives, WAIT |
| "improve the tests" | Rewrite all tests | Assess current tests FIRST, propose approach, THEN implement |
**IF YOU SKIPPED THE INTENT CLASSIFICATION ABOVE:** STOP. Go back. Do it now. Your next tool call is INVALID without it.
</GEMINI_INTENT_GATE_ENFORCEMENT>`;
}

View File

@@ -5,6 +5,7 @@ import {
buildGeminiToolMandate,
buildGeminiDelegationOverride,
buildGeminiVerificationOverride,
buildGeminiIntentGateEnforcement,
} from "./sisyphus-gemini-overlays";
const MODE: AgentMode = "primary";
@@ -567,7 +568,7 @@ export function createSisyphusAgent(
if (isGeminiModel(model)) {
prompt = prompt.replace(
"</intent_verbalization>",
`</intent_verbalization>\n\n${buildGeminiToolMandate()}`
`</intent_verbalization>\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}`
);
prompt += "\n" + buildGeminiDelegationOverride();
prompt += "\n" + buildGeminiVerificationOverride();

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import { describe, expect, it } from "bun:test"
import { stripAnsi } from "./format-shared"
import type { DoctorResult } from "./types"
function createDoctorResult(): DoctorResult {
@@ -39,78 +40,122 @@ function createDoctorResult(): DoctorResult {
}
}
describe("formatter", () => {
afterEach(() => {
mock.restore()
function createDoctorResultWithIssues(): DoctorResult {
const base = createDoctorResult()
base.results[1].issues = [
{ title: "Config issue", description: "Bad config", severity: "error" as const, fix: "Fix it" },
{ title: "Tool warning", description: "Missing tool", severity: "warning" as const },
]
base.summary.failed = 1
base.summary.warnings = 1
return base
}
describe("formatDoctorOutput", () => {
describe("#given default mode", () => {
it("shows System OK when no issues", async () => {
//#given
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?default-ok-${Date.now()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "default"))
//#then
expect(output).toContain("System OK (opencode 1.0.200 · oh-my-opencode 3.4.0)")
})
it("shows issue count and details when issues exist", async () => {
//#given
const result = createDoctorResultWithIssues()
const { formatDoctorOutput } = await import(`./formatter?default-issues-${Date.now()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "default"))
//#then
expect(output).toContain("issues found:")
expect(output).toContain("1. Config issue")
expect(output).toContain("2. Tool warning")
})
})
describe("formatDoctorOutput", () => {
it("dispatches to default formatter for default mode", async () => {
describe("#given status mode", () => {
it("renders system version line", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?default=${Date.now()}`)
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?status-ver-${Date.now()}`)
//#when
const output = formatDoctorOutput(createDoctorResult(), "default")
const output = stripAnsi(formatDoctorOutput(result, "status"))
//#then
expect(output).toBe("default-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
expect(output).toContain("1.0.200 · 3.4.0 · Bun 1.2.0")
})
it("dispatches to status formatter for status mode", async () => {
it("renders tool and MCP info", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?status=${Date.now()}`)
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?status-tools-${Date.now()}`)
//#when
const output = formatDoctorOutput(createDoctorResult(), "status")
const output = stripAnsi(formatDoctorOutput(result, "status"))
//#then
expect(output).toBe("status-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(1)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
expect(output).toContain("LSP 2/4")
expect(output).toContain("context7")
})
})
describe("#given verbose mode", () => {
it("includes all section headers", async () => {
//#given
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?verbose-headers-${Date.now()}`)
//#when
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
//#then
expect(output).toContain("System Information")
expect(output).toContain("Configuration")
expect(output).toContain("Tools")
expect(output).toContain("MCPs")
expect(output).toContain("Summary")
})
it("dispatches to verbose formatter for verbose mode", async () => {
it("shows check summary counts", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?verbose=${Date.now()}`)
const result = createDoctorResult()
const { formatDoctorOutput } = await import(`./formatter?verbose-summary-${Date.now()}`)
//#when
const output = formatDoctorOutput(createDoctorResult(), "verbose")
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
//#then
expect(output).toBe("verbose-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
expect(output).toContain("1 passed")
expect(output).toContain("0 failed")
expect(output).toContain("1 warnings")
})
})
describe("formatJsonOutput", () => {
it("returns valid JSON payload", async () => {
it("returns valid JSON", async () => {
//#given
const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`)
const result = createDoctorResult()
const { formatJsonOutput } = await import(`./formatter?json-valid-${Date.now()}`)
//#when
const output = formatJsonOutput(result)
//#then
expect(() => JSON.parse(output)).not.toThrow()
})
it("preserves all result fields", async () => {
//#given
const result = createDoctorResult()
const { formatJsonOutput } = await import(`./formatter?json-fields-${Date.now()}`)
//#when
const output = formatJsonOutput(result)
@@ -119,7 +164,6 @@ describe("formatter", () => {
//#then
expect(parsed.summary.total).toBe(2)
expect(parsed.systemInfo.pluginVersion).toBe("3.4.0")
expect(parsed.tools.ghCli.username).toBe("yeongyu")
expect(parsed.exitCode).toBe(0)
})
})

View File

@@ -31,7 +31,7 @@ export async function resolveSession(options: {
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
} as Record<string, unknown>,
query: { directory },
})

View File

@@ -1,18 +1,5 @@
export {
OhMyOpenCodeConfigSchema,
AgentOverrideConfigSchema,
AgentOverridesSchema,
McpNameSchema,
AgentNameSchema,
HookNameSchema,
BuiltinCommandNameSchema,
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
RalphLoopConfigSchema,
TmuxConfigSchema,
TmuxLayoutSchema,
RuntimeFallbackConfigSchema,
FallbackModelsSchema,
} from "./schema"
export type {

View File

@@ -47,6 +47,12 @@ export const AgentOverrideConfigSchema = z.object({
variant: z.string().optional(),
})
.optional(),
compaction: z
.object({
model: z.string().optional(),
variant: z.string().optional(),
})
.optional(),
})
export const AgentOverridesSchema = z.object({

View File

@@ -35,6 +35,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_tools: z.array(z.string()).optional(),
/** Enable hashline_edit tool/hook integrations (default: true at call site) */
hashline_edit: z.boolean().optional(),
/** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */
model_fallback: z.boolean().optional(),
agents: AgentOverridesSchema.optional(),
categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),

View File

@@ -1,40 +0,0 @@
import type { BackgroundTask } from "./types"
import type { ResultHandlerContext } from "./result-handler-context"
import { log } from "../../shared"
import { notifyParentSession } from "./parent-session-notifier"
export async function tryCompleteTask(
task: BackgroundTask,
source: string,
ctx: ResultHandlerContext
): Promise<boolean> {
const { concurrencyManager, state } = ctx
if (task.status !== "running") {
log("[background-agent] Task already completed, skipping:", {
taskId: task.id,
status: task.status,
source,
})
return false
}
task.status = "completed"
task.completedAt = new Date()
if (task.concurrencyKey) {
concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
state.markForNotification(task)
try {
await notifyParentSession(task, ctx)
log(`[background-agent] Task completed via ${source}:`, task.id)
} catch (error) {
log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error })
}
return true
}

View File

@@ -1,14 +0,0 @@
export function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`
}
return `${seconds}s`
}

View File

@@ -1,5 +1,2 @@
export * from "./types"
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
export { TaskHistory, type TaskHistoryEntry } from "./task-history"
export { ConcurrencyManager } from "./concurrency"
export { TaskStateManager } from "./state"

View File

@@ -268,7 +268,7 @@ export class BackgroundManager {
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`,
} as any,
} as Record<string, unknown>,
query: {
directory: parentDirectory,
},

View File

@@ -1 +0,0 @@
export { getMessageDir } from "../../shared"

View File

@@ -1,81 +0,0 @@
import type { OpencodeClient } from "./constants"
import type { BackgroundTask } from "./types"
import { findNearestMessageWithFields } from "../hook-message-injector"
import { getMessageDir } from "../../shared"
import { normalizePromptTools, resolveInheritedPromptTools } from "../../shared"
type AgentModel = { providerID: string; modelID: string }
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function extractAgentAndModelFromMessage(message: unknown): {
agent?: string
model?: AgentModel
tools?: Record<string, boolean>
} {
if (!isObject(message)) return {}
const info = message["info"]
if (!isObject(info)) return {}
const agent = typeof info["agent"] === "string" ? info["agent"] : undefined
const modelObj = info["model"]
const tools = normalizePromptTools(isObject(info["tools"]) ? info["tools"] as Record<string, unknown> as Record<string, boolean | "allow" | "deny" | "ask"> : undefined)
if (isObject(modelObj)) {
const providerID = modelObj["providerID"]
const modelID = modelObj["modelID"]
if (typeof providerID === "string" && typeof modelID === "string") {
return { agent, model: { providerID, modelID }, tools }
}
}
const providerID = info["providerID"]
const modelID = info["modelID"]
if (typeof providerID === "string" && typeof modelID === "string") {
return { agent, model: { providerID, modelID }, tools }
}
return { agent, tools }
}
export async function resolveParentSessionAgentAndModel(input: {
client: OpencodeClient
task: BackgroundTask
}): Promise<{ agent?: string; model?: AgentModel; tools?: Record<string, boolean> }> {
const { client, task } = input
let agent: string | undefined = task.parentAgent
let model: AgentModel | undefined
let tools: Record<string, boolean> | undefined = task.parentTools
try {
const messagesResp = await client.session.messages({
path: { id: task.parentSessionID },
})
const messagesRaw = "data" in messagesResp ? messagesResp.data : []
const messages = Array.isArray(messagesRaw) ? messagesRaw : []
for (let i = messages.length - 1; i >= 0; i--) {
const extracted = extractAgentAndModelFromMessage(messages[i])
if (extracted.agent || extracted.model || extracted.tools) {
agent = extracted.agent ?? task.parentAgent
model = extracted.model
tools = extracted.tools ?? tools
break
}
}
} catch {
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agent = currentMessage?.agent ?? task.parentAgent
model =
currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
tools = normalizePromptTools(currentMessage?.tools) ?? tools
}
return { agent, model, tools: resolveInheritedPromptTools(task.parentSessionID, tools) }
}

View File

@@ -1,39 +0,0 @@
declare const require: (name: string) => any
const { describe, test, expect } = require("bun:test")
import type { BackgroundTask } from "./types"
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
describe("notifyParentSession", () => {
test("displays INTERRUPTED for interrupted tasks", () => {
// given
const task: BackgroundTask = {
id: "test-task",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
description: "Test task",
prompt: "Test prompt",
agent: "test-agent",
status: "interrupt",
startedAt: new Date(),
completedAt: new Date(),
}
const duration = "1s"
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const allComplete = false
const remainingCount = 1
const completedTasks: BackgroundTask[] = []
// when
const notification = buildBackgroundTaskNotificationText({
task,
duration,
statusText,
allComplete,
remainingCount,
completedTasks,
})
// then
expect(notification).toContain("INTERRUPTED")
})
})

View File

@@ -1,103 +0,0 @@
import type { BackgroundTask } from "./types"
import type { ResultHandlerContext } from "./result-handler-context"
import { TASK_CLEANUP_DELAY_MS } from "./constants"
import { createInternalAgentTextPart, log } from "../../shared"
import { getTaskToastManager } from "../task-toast-manager"
import { formatDuration } from "./duration-formatter"
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
import { resolveParentSessionAgentAndModel } from "./parent-session-context-resolver"
export async function notifyParentSession(
task: BackgroundTask,
ctx: ResultHandlerContext
): Promise<void> {
const { client, state } = ctx
const duration = formatDuration(task.startedAt ?? task.completedAt ?? new Date(), task.completedAt)
log("[background-agent] notifyParentSession called for task:", task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.showCompletionToast({
id: task.id,
description: task.description,
duration,
})
}
const pendingSet = state.pendingByParent.get(task.parentSessionID)
if (pendingSet) {
pendingSet.delete(task.id)
if (pendingSet.size === 0) {
state.pendingByParent.delete(task.parentSessionID)
}
}
const allComplete = !pendingSet || pendingSet.size === 0
const remainingCount = pendingSet?.size ?? 0
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const completedTasks = allComplete
? Array.from(state.tasks.values()).filter(
(t) =>
t.parentSessionID === task.parentSessionID &&
t.status !== "running" &&
t.status !== "pending"
)
: []
const notification = buildBackgroundTaskNotificationText({
task,
duration,
statusText,
allComplete,
remainingCount,
completedTasks,
})
const { agent, model, tools } = await resolveParentSessionAgentAndModel({ client, task })
log("[background-agent] notifyParentSession context:", {
taskId: task.id,
resolvedAgent: agent,
resolvedModel: model,
})
try {
await client.session.promptAsync({
path: { id: task.parentSessionID },
body: {
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(tools ? { tools } : {}),
parts: [createInternalAgentTextPart(notification)],
},
})
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
noReply: !allComplete,
})
} catch (error) {
log("[background-agent] Failed to send notification:", error)
}
if (!allComplete) return
for (const completedTask of completedTasks) {
const taskId = completedTask.id
state.clearCompletionTimer(taskId)
const timer = setTimeout(() => {
state.completionTimers.delete(taskId)
if (state.tasks.has(taskId)) {
state.clearNotificationsForTask(taskId)
state.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
}, TASK_CLEANUP_DELAY_MS)
state.setCompletionTimer(taskId, timer)
}
}

View File

@@ -1,9 +0,0 @@
import type { OpencodeClient } from "./constants"
import type { ConcurrencyManager } from "./concurrency"
import type { TaskStateManager } from "./state"
export interface ResultHandlerContext {
client: OpencodeClient
concurrencyManager: ConcurrencyManager
state: TaskStateManager
}

View File

@@ -1,7 +0,0 @@
export type { ResultHandlerContext } from "./result-handler-context"
export { formatDuration } from "./duration-formatter"
export { getMessageDir } from "../../shared"
export { checkSessionTodos } from "./session-todo-checker"
export { validateSessionHasOutput } from "./session-output-validator"
export { tryCompleteTask } from "./background-task-completer"
export { notifyParentSession } from "./parent-session-notifier"

View File

@@ -1,89 +0,0 @@
import type { OpencodeClient } from "./constants"
import { log } from "../../shared"
type SessionMessagePart = {
type?: string
text?: string
content?: unknown
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getMessageRole(message: unknown): string | undefined {
if (!isObject(message)) return undefined
const info = message["info"]
if (!isObject(info)) return undefined
const role = info["role"]
return typeof role === "string" ? role : undefined
}
function getMessageParts(message: unknown): SessionMessagePart[] {
if (!isObject(message)) return []
const parts = message["parts"]
if (!Array.isArray(parts)) return []
return parts
.filter((part): part is SessionMessagePart => isObject(part))
.map((part) => ({
type: typeof part["type"] === "string" ? part["type"] : undefined,
text: typeof part["text"] === "string" ? part["text"] : undefined,
content: part["content"],
}))
}
function partHasContent(part: SessionMessagePart): boolean {
if (part.type === "text" || part.type === "reasoning") {
return Boolean(part.text && part.text.trim().length > 0)
}
if (part.type === "tool") return true
if (part.type === "tool_result") {
if (typeof part.content === "string") return part.content.trim().length > 0
if (Array.isArray(part.content)) return part.content.length > 0
return Boolean(part.content)
}
return false
}
export async function validateSessionHasOutput(
client: OpencodeClient,
sessionID: string
): Promise<boolean> {
try {
const response = await client.session.messages({
path: { id: sessionID },
})
const messagesRaw =
isObject(response) && "data" in response ? (response as { data?: unknown }).data : response
const messages = Array.isArray(messagesRaw) ? messagesRaw : []
const hasAssistantOrToolMessage = messages.some((message) => {
const role = getMessageRole(message)
return role === "assistant" || role === "tool"
})
if (!hasAssistantOrToolMessage) {
log("[background-agent] No assistant/tool messages found in session:", sessionID)
return false
}
const hasContent = messages.some((message) => {
const role = getMessageRole(message)
if (role !== "assistant" && role !== "tool") return false
const parts = getMessageParts(message)
return parts.some(partHasContent)
})
if (!hasContent) {
log("[background-agent] Messages exist but no content found in session:", sessionID)
return false
}
return true
} catch (error) {
log("[background-agent] Error validating session output:", error)
return true
}
}

View File

@@ -1,46 +0,0 @@
import { subagentSessions } from "../claude-code-session-state"
import type { BackgroundTask } from "./types"
export function cleanupTaskAfterSessionEnds(args: {
task: BackgroundTask
tasks: Map<string, BackgroundTask>
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
completionTimers: Map<string, ReturnType<typeof setTimeout>>
cleanupPendingByParent: (task: BackgroundTask) => void
clearNotificationsForTask: (taskId: string) => void
releaseConcurrencyKey?: (key: string) => void
}): void {
const {
task,
tasks,
idleDeferralTimers,
completionTimers,
cleanupPendingByParent,
clearNotificationsForTask,
releaseConcurrencyKey,
} = args
const completionTimer = completionTimers.get(task.id)
if (completionTimer) {
clearTimeout(completionTimer)
completionTimers.delete(task.id)
}
const idleTimer = idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
idleDeferralTimers.delete(task.id)
}
if (task.concurrencyKey && releaseConcurrencyKey) {
releaseConcurrencyKey(task.concurrencyKey)
task.concurrencyKey = undefined
}
cleanupPendingByParent(task)
clearNotificationsForTask(task.id)
tasks.delete(task.id)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}

View File

@@ -1,33 +0,0 @@
import type { OpencodeClient, Todo } from "./constants"
function isTodo(value: unknown): value is Todo {
if (typeof value !== "object" || value === null) return false
const todo = value as Record<string, unknown>
return (
(typeof todo["id"] === "string" || todo["id"] === undefined) &&
typeof todo["content"] === "string" &&
typeof todo["status"] === "string" &&
typeof todo["priority"] === "string"
)
}
export async function checkSessionTodos(
client: OpencodeClient,
sessionID: string
): Promise<boolean> {
try {
const response = await client.session.todo({
path: { id: sessionID },
})
const todosRaw = "data" in response ? response.data : response
if (!Array.isArray(todosRaw) || todosRaw.length === 0) return false
const incomplete = todosRaw
.filter(isTodo)
.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled")
return incomplete.length > 0
} catch {
return false
}
}

View File

@@ -61,9 +61,7 @@ export async function startTask(
const createResult = await client.session.create({
body: {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
} as Record<string, unknown>,
query: {
directory: parentDirectory,
},

View File

@@ -1,45 +0,0 @@
import type { OpencodeClient } from "../constants"
import type { ConcurrencyManager } from "../concurrency"
import type { LaunchInput } from "../types"
import { log } from "../../../shared"
export async function createBackgroundSession(options: {
client: OpencodeClient
input: LaunchInput
parentDirectory: string
concurrencyManager: ConcurrencyManager
concurrencyKey: string
}): Promise<string> {
const { client, input, parentDirectory, concurrencyManager, concurrencyKey } = options
const body = {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
}
const createResult = await client.session
.create({
body,
query: {
directory: parentDirectory,
},
})
.catch((error: unknown) => {
concurrencyManager.release(concurrencyKey)
throw error
})
if (createResult.error) {
concurrencyManager.release(concurrencyKey)
throw new Error(`Failed to create background session: ${createResult.error}`)
}
if (!createResult.data?.id) {
concurrencyManager.release(concurrencyKey)
throw new Error("Failed to create background session: API returned no session ID")
}
const sessionID = createResult.data.id
log("[background-agent] Background session created", { sessionID })
return sessionID
}

View File

@@ -1,7 +0,0 @@
import type { LaunchInput } from "../types"
export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string {
return input.model
? `${input.model.providerID}/${input.model.modelID}`
: input.agent
}

View File

@@ -1,12 +0,0 @@
import type { BackgroundTask } from "../types"
import type { ConcurrencyManager } from "../concurrency"
import type { OpencodeClient, OnSubagentSessionCreated } from "../constants"
export interface SpawnerContext {
client: OpencodeClient
directory: string
concurrencyManager: ConcurrencyManager
tmuxEnabled: boolean
onSubagentSessionCreated?: OnSubagentSessionCreated
onTaskError: (task: BackgroundTask, error: Error) => void
}

View File

@@ -1,40 +0,0 @@
import { setTimeout } from "timers/promises"
import type { OnSubagentSessionCreated } from "../constants"
import { TMUX_CALLBACK_DELAY_MS } from "../constants"
import { log } from "../../../shared"
import { isInsideTmux } from "../../../shared/tmux"
export async function maybeInvokeTmuxCallback(options: {
onSubagentSessionCreated?: OnSubagentSessionCreated
tmuxEnabled: boolean
sessionID: string
parentID: string
title: string
}): Promise<void> {
const { onSubagentSessionCreated, tmuxEnabled, sessionID, parentID, title } = options
log("[background-agent] tmux callback check", {
hasCallback: !!onSubagentSessionCreated,
tmuxEnabled,
isInsideTmux: isInsideTmux(),
sessionID,
parentID,
})
if (!onSubagentSessionCreated || !tmuxEnabled || !isInsideTmux()) {
log("[background-agent] SKIP tmux callback - conditions not met")
return
}
log("[background-agent] Invoking tmux callback NOW", { sessionID })
await onSubagentSessionCreated({
sessionID,
parentID,
title,
}).catch((error: unknown) => {
log("[background-agent] Failed to spawn tmux pane:", error)
})
log("[background-agent] tmux callback completed, waiting")
await setTimeout(TMUX_CALLBACK_DELAY_MS)
}

View File

@@ -1,3 +0,0 @@
export * from "./types"
export * from "./storage"
export * from "./session-storage"

View File

@@ -1,3 +0,0 @@
export * from "./schema"
export * from "./oauth-authorization-flow"
export * from "./provider"

View File

@@ -4,6 +4,12 @@ import type { ConcurrencyManager } from "../background-agent/concurrency"
type OpencodeClient = PluginInput["client"]
type ClientWithTui = {
tui?: {
showToast: (opts: { body: { title: string; message: string; variant: string; duration: number } }) => Promise<unknown>
}
}
export class TaskToastManager {
private tasks: Map<string, TrackedTask> = new Map()
private client: OpencodeClient
@@ -170,8 +176,7 @@ export class TaskToastManager {
* Show consolidated toast with all running/queued tasks
*/
private showTaskListToast(newTask: TrackedTask): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
const tuiClient = this.client as ClientWithTui
if (!tuiClient.tui?.showToast) return
const message = this.buildTaskListMessage(newTask)
@@ -196,8 +201,7 @@ export class TaskToastManager {
* Show task completion toast
*/
showCompletionToast(task: { id: string; description: string; duration: string }): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
const tuiClient = this.client as ClientWithTui
if (!tuiClient.tui?.showToast) return
this.removeTask(task.id)

View File

@@ -1,4 +1,5 @@
import type { AutoCompactState } from "./types";
import type { OhMyOpenCodeConfig } from "../../config";
import type { ExperimentalConfig } from "../../config";
import { TRUNCATE_CONFIG } from "./types";
@@ -15,15 +16,15 @@ export async function executeCompact(
sessionID: string,
msg: Record<string, unknown>,
autoCompactState: AutoCompactState,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
client: Client,
directory: string,
experimental?: ExperimentalConfig,
pluginConfig: OhMyOpenCodeConfig,
_experimental?: ExperimentalConfig
): Promise<void> {
void experimental
void _experimental
if (autoCompactState.compactionInProgress.has(sessionID)) {
await (client as Client).tui
await client.tui
.showToast({
body: {
title: "Compact In Progress",
@@ -55,7 +56,7 @@ export async function executeCompact(
const result = await runAggressiveTruncationStrategy({
sessionID,
autoCompactState,
client: client as Client,
client: client,
directory,
truncateAttempt: truncateState.truncateAttempt,
currentTokens: errorData.currentTokens,
@@ -70,8 +71,9 @@ export async function executeCompact(
sessionID,
msg,
autoCompactState,
client: client as Client,
client: client,
directory,
pluginConfig,
errorType: errorData?.errorType,
messageIndex: errorData?.messageIndex,
})

View File

@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { Client } from "./client"
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
import type { ExperimentalConfig } from "../../config"
import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config"
import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor"
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
@@ -8,6 +9,7 @@ import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions {
experimental?: ExperimentalConfig
pluginConfig: OhMyOpenCodeConfig
}
function createRecoveryState(): AutoCompactState {
@@ -28,6 +30,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(
) {
const autoCompactState = createRecoveryState()
const experimental = options?.experimental
const pluginConfig = options?.pluginConfig!
const pendingCompactionTimeoutBySession = new Map<string, ReturnType<typeof setTimeout>>()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
@@ -89,8 +92,9 @@ export function createAnthropicContextWindowLimitRecoveryHook(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client,
ctx.client as Client,
ctx.directory,
pluginConfig,
experimental,
)
}, 300)
@@ -156,8 +160,9 @@ export function createAnthropicContextWindowLimitRecoveryHook(
sessionID,
{ providerID, modelID },
autoCompactState,
ctx.client,
ctx.client as Client,
ctx.directory,
pluginConfig,
experimental,
)
}

View File

@@ -1,16 +1,19 @@
import type { AutoCompactState } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"
import { RETRY_CONFIG } from "./types"
import type { Client } from "./client"
import { clearSessionState, getEmptyContentAttempt, getOrCreateRetryState } from "./state"
import { sanitizeEmptyMessagesBeforeSummarize } from "./message-builder"
import { fixEmptyMessages } from "./empty-content-recovery"
import { resolveCompactionModel } from "../shared/compaction-model-resolver"
export async function runSummarizeRetryStrategy(params: {
sessionID: string
msg: Record<string, unknown>
autoCompactState: AutoCompactState
client: Client
directory: string
pluginConfig: OhMyOpenCodeConfig
errorType?: string
messageIndex?: number
}): Promise<void> {
@@ -74,7 +77,14 @@ export async function runSummarizeRetryStrategy(params: {
})
.catch(() => {})
const summarizeBody = { providerID, modelID, auto: true }
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
params.pluginConfig,
params.sessionID,
providerID,
modelID
)
const summarizeBody = { providerID: targetProviderID, modelID: targetModelID, auto: true }
await params.client.session.summarize({
path: { id: params.sessionID },
body: summarizeBody as never,

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, it, expect, mock } from "bun:test"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
const mockShowConfigErrorsIfAny = mock(async () => {})
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
@@ -7,7 +7,7 @@ const mockShowLocalDevToast = mock(async () => {})
const mockShowVersionToast = mock(async () => {})
const mockRunBackgroundUpdateCheck = mock(async () => {})
const mockGetCachedVersion = mock(() => "3.6.0")
const mockGetLocalDevVersion = mock(() => "3.6.0")
const mockGetLocalDevVersion = mock<(directory: string) => string | null>(() => null)
mock.module("./hook/config-errors-toast", () => ({
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
@@ -40,31 +40,49 @@ mock.module("../../shared/logger", () => ({
log: () => {},
}))
const { createAutoUpdateCheckerHook } = await import("./hook")
type HookFactory = typeof import("./hook").createAutoUpdateCheckerHook
async function importFreshHookFactory(): Promise<HookFactory> {
const hookModule = await import(`./hook?test-${Date.now()}-${Math.random()}`)
return hookModule.createAutoUpdateCheckerHook
}
function createPluginInput() {
return {
directory: "/test",
client: {} as never,
} as never
}
beforeEach(() => {
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
mockShowLocalDevToast.mockClear()
mockShowVersionToast.mockClear()
mockRunBackgroundUpdateCheck.mockClear()
mockGetCachedVersion.mockClear()
mockGetLocalDevVersion.mockClear()
mockGetCachedVersion.mockReturnValue("3.6.0")
mockGetLocalDevVersion.mockReturnValue(null)
})
afterEach(() => {
delete process.env.OPENCODE_CLI_RUN_MODE
mock.restore()
})
describe("createAutoUpdateCheckerHook", () => {
it("skips startup toasts and checks in CLI run mode", async () => {
//#given - CLI run mode enabled
process.env.OPENCODE_CLI_RUN_MODE = "true"
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
mockShowLocalDevToast.mockClear()
mockShowVersionToast.mockClear()
mockRunBackgroundUpdateCheck.mockClear()
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(
{
directory: "/test",
client: {} as never,
} as never,
{ showStartupToast: true, isSisyphusEnabled: true, autoUpdate: true }
)
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
showStartupToast: true,
isSisyphusEnabled: true,
autoUpdate: true,
})
//#when - session.created event arrives
hook.event({
@@ -73,7 +91,7 @@ describe("createAutoUpdateCheckerHook", () => {
properties: { info: { parentID: undefined } },
},
})
await new Promise((resolve) => setTimeout(resolve, 25))
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - no update checker side effects run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -82,6 +100,144 @@ describe("createAutoUpdateCheckerHook", () => {
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
expect(mockShowVersionToast).not.toHaveBeenCalled()
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
})
it("runs all startup checks on normal session.created", async () => {
//#given - normal mode and no local dev version
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives on primary session
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - startup checks, toast, and background check run
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
it("ignores subagent sessions (parentID present)", async () => {
//#given - a subagent session with parentID
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event contains parentID
hook.event({
event: {
type: "session.created",
properties: { info: { parentID: "parent-123" } },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
expect(mockShowVersionToast).not.toHaveBeenCalled()
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
})
it("runs only once (hasChecked guard)", async () => {
//#given - one hook instance in normal mode
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event is fired twice
hook.event({
event: {
type: "session.created",
},
})
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - side effects execute only once
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
})
it("shows localDevToast when local dev version exists", async () => {
//#given - local dev version is present
mockGetLocalDevVersion.mockReturnValue("3.6.0-dev")
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - local dev toast is shown and background check is skipped
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mockShowLocalDevToast).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).not.toHaveBeenCalled()
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
})
it("ignores non-session.created events", async () => {
//#given - a hook instance in normal mode
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - a non-session.created event arrives
hook.event({
event: {
type: "session.deleted",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
expect(mockShowVersionToast).not.toHaveBeenCalled()
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
})
it("passes correct toast message with sisyphus enabled", async () => {
//#given - sisyphus mode enabled
const createAutoUpdateCheckerHook = await importFreshHookFactory()
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
isSisyphusEnabled: true,
})
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
//#then - startup toast includes sisyphus wording
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).toHaveBeenCalledWith(
expect.anything(),
"3.6.0",
expect.stringContaining("Sisyphus")
)
})
})

View File

@@ -1,177 +1,208 @@
import { describe, it, expect, mock, beforeEach } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { beforeEach, describe, expect, it, mock } from "bun:test"
// Mock modules before importing
const mockFindPluginEntry = mock(() => null as any)
const mockGetCachedVersion = mock(() => null as string | null)
const mockGetLatestVersion = mock(async () => null as string | null)
const mockUpdatePinnedVersion = mock(() => false)
type PluginEntry = {
entry: string
isPinned: boolean
pinnedVersion: string | null
configPath: string
}
type ToastMessageGetter = (isUpdate: boolean, version?: string) => string
function createPluginEntry(overrides?: Partial<PluginEntry>): PluginEntry {
return {
entry: "oh-my-opencode@3.4.0",
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
...overrides,
}
}
const mockFindPluginEntry = mock((_directory: string): PluginEntry | null => createPluginEntry())
const mockGetCachedVersion = mock((): string | null => "3.4.0")
const mockGetLatestVersion = mock(async (): Promise<string | null> => "3.5.0")
const mockExtractChannel = mock(() => "latest")
const mockInvalidatePackage = mock(() => {})
const mockRunBunInstall = mock(async () => true)
const mockShowUpdateAvailableToast = mock(async () => {})
const mockShowAutoUpdatedToast = mock(async () => {})
const mockShowUpdateAvailableToast = mock(
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
)
const mockShowAutoUpdatedToast = mock(
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
)
mock.module("../checker", () => ({
findPluginEntry: mockFindPluginEntry,
getCachedVersion: mockGetCachedVersion,
getLatestVersion: mockGetLatestVersion,
updatePinnedVersion: mockUpdatePinnedVersion,
revertPinnedVersion: mock(() => false),
}))
mock.module("../version-channel", () => ({
extractChannel: mockExtractChannel,
}))
mock.module("../cache", () => ({
invalidatePackage: mockInvalidatePackage,
}))
mock.module("../../../cli/config-manager", () => ({
runBunInstall: mockRunBunInstall,
}))
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
mock.module("../../../cli/config-manager", () => ({ runBunInstall: mockRunBunInstall }))
mock.module("./update-toasts", () => ({
showUpdateAvailableToast: mockShowUpdateAvailableToast,
showAutoUpdatedToast: mockShowAutoUpdatedToast,
}))
mock.module("../../../shared/logger", () => ({ log: () => {} }))
mock.module("../../../shared/logger", () => ({
log: () => {},
}))
const { runBackgroundUpdateCheck } = await import("./background-update-check?test")
const modulePath = "./background-update-check?test"
const { runBackgroundUpdateCheck } = await import(modulePath)
describe("runBackgroundUpdateCheck", () => {
const mockCtx = { directory: "/test" } as any
const mockGetToastMessage = (isUpdate: boolean, version?: string) =>
const mockCtx = { directory: "/test" } as PluginInput
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
isUpdate ? `Update to ${version}` : "Up to date"
beforeEach(() => {
mockFindPluginEntry.mockReset()
mockGetCachedVersion.mockReset()
mockGetLatestVersion.mockReset()
mockUpdatePinnedVersion.mockReset()
mockExtractChannel.mockReset()
mockInvalidatePackage.mockReset()
mockRunBunInstall.mockReset()
mockShowUpdateAvailableToast.mockReset()
mockShowAutoUpdatedToast.mockReset()
mockFindPluginEntry.mockReturnValue(createPluginEntry())
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
mockExtractChannel.mockReturnValue("latest")
mockRunBunInstall.mockResolvedValue(true)
})
describe("#given user has pinned a specific version", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode@3.4.0",
isPinned: true,
pinnedVersion: "3.4.0",
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should NOT call updatePinnedVersion", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
})
it("#then should show manual-update toast message", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
const [toastContext, latestVersion, getToastMessage] = mockShowUpdateAvailableToast.mock.calls[0] ?? []
expect(toastContext).toBe(mockCtx)
expect(latestVersion).toBe("3.5.0")
expect(typeof getToastMessage).toBe("function")
expect(getToastMessage(true, "3.5.0")).toBe("Update available: 3.5.0 (version pinned, update manually)")
})
it("#then should NOT run bun install", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
describe("#given no plugin entry found", () => {
it("returns early without showing any toast", async () => {
//#given
mockFindPluginEntry.mockReturnValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockFindPluginEntry).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
})
it("#then should NOT invalidate package cache", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockInvalidatePackage).not.toHaveBeenCalled()
})
})
describe("#given user has NOT pinned a version (unpinned)", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should proceed with auto-update", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockInvalidatePackage).toHaveBeenCalled()
expect(mockRunBunInstall).toHaveBeenCalled()
})
it("#then should show auto-updated toast on success", async () => {
mockRunBunInstall.mockResolvedValue(true)
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockShowAutoUpdatedToast).toHaveBeenCalled()
})
})
describe("#given autoUpdate is false", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should only show notification toast", async () => {
await runBackgroundUpdateCheck(mockCtx, false, mockGetToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
})
})
describe("#given already on latest version", () => {
beforeEach(() => {
mockFindPluginEntry.mockReturnValue({
entry: "oh-my-opencode@3.5.0",
isPinned: true,
pinnedVersion: "3.5.0",
configPath: "/test/opencode.json",
})
mockGetCachedVersion.mockReturnValue("3.5.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
})
it("#then should not update or show toast", async () => {
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
expect(mockUpdatePinnedVersion).not.toHaveBeenCalled()
describe("#given no version available", () => {
it("returns early when neither cached nor pinned version exists", async () => {
//#given
mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" }))
mockGetCachedVersion.mockReturnValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetCachedVersion).toHaveBeenCalledTimes(1)
expect(mockGetLatestVersion).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given latest version fetch fails", () => {
it("returns early without toasts", async () => {
//#given
mockGetLatestVersion.mockResolvedValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledWith("latest")
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given already on latest version", () => {
it("returns early without any action", async () => {
//#given
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.4.0")
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given update available with autoUpdate disabled", () => {
it("shows update notification but does not install", async () => {
//#given
const autoUpdate = false
//#when
await runBackgroundUpdateCheck(mockCtx, autoUpdate, getToastMessage)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given user has pinned a specific version", () => {
it("shows pinned-version toast without auto-updating", async () => {
//#given
mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" }))
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
it("toast message mentions version pinned", async () => {
//#given
let capturedToastMessage: ToastMessageGetter | undefined
mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" }))
mockShowUpdateAvailableToast.mockImplementation(
async (_ctx: PluginInput, _latestVersion: string, toastMessage: ToastMessageGetter) => {
capturedToastMessage = toastMessage
}
)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
expect(capturedToastMessage).toBeDefined()
if (!capturedToastMessage) {
throw new Error("toast message callback missing")
}
const message = capturedToastMessage(true, "3.5.0")
expect(message).toContain("version pinned")
expect(message).not.toBe("Update to 3.5.0")
})
})
describe("#given unpinned with auto-update and install succeeds", () => {
it("invalidates cache, installs, and shows auto-updated toast", async () => {
//#given
mockRunBunInstall.mockResolvedValue(true)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0")
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
})
})
describe("#given unpinned with auto-update and install fails", () => {
it("falls back to notification-only toast", async () => {
//#given
mockRunBunInstall.mockResolvedValue(false)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,161 +1,204 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
const findAgentsMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
const resolveFilePathMock = mock((_: string, path: string) => path)
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
const storageMaps = new Map<string, Set<string>>()
mock.module("./constants", () => ({
AGENTS_INJECTOR_STORAGE: "/tmp/directory-agents-injector-tests",
AGENTS_FILENAME: "AGENTS.md",
}))
mock.module("./storage", () => ({
loadInjectedPaths: (sessionID: string) => storageMaps.get(sessionID) ?? new Set<string>(),
saveInjectedPaths: (sessionID: string, paths: Set<string>) => {
storageMaps.set(sessionID, paths)
},
clearInjectedPaths: (sessionID: string) => {
storageMaps.delete(sessionID)
},
}))
const truncator = {
truncate: async (_sessionID: string, content: string) => ({ result: content, truncated: false }),
getUsage: async (_sessionID: string) => null,
truncateSync: (output: string, _maxTokens: number, _preserveHeaderLines?: number) => ({
result: output,
truncated: false,
}),
}
describe("processFilePathForAgentsInjection", () => {
let testRoot = ""
let srcDirectory = ""
let componentsDirectory = ""
const rootAgentsContent = "# ROOT AGENTS\nroot-level directives"
const srcAgentsContent = "# SRC AGENTS\nsrc-level directives"
const componentsAgentsContent = "# COMPONENT AGENTS\ncomponents-level directives"
beforeEach(() => {
findAgentsMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
storageMaps.clear()
testRoot = join(
tmpdir(),
`directory-agents-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
)
mkdirSync(testRoot, { recursive: true })
testRoot = join(tmpdir(), `directory-agents-injector-${randomUUID()}`)
srcDirectory = join(testRoot, "src")
componentsDirectory = join(srcDirectory, "components")
mkdirSync(componentsDirectory, { recursive: true })
writeFileSync(join(testRoot, "AGENTS.md"), rootAgentsContent)
writeFileSync(join(srcDirectory, "AGENTS.md"), srcAgentsContent)
writeFileSync(join(componentsDirectory, "AGENTS.md"), componentsAgentsContent)
writeFileSync(join(componentsDirectory, "button.ts"), "export const button = true\n")
writeFileSync(join(srcDirectory, "file.ts"), "export const sourceFile = true\n")
writeFileSync(join(testRoot, "file.ts"), "export const rootFile = true\n")
})
afterEach(() => {
mock.restore()
rmSync(testRoot, { recursive: true, force: true })
})
it("does not save when all discovered paths are already cached", async () => {
//#given
const sessionID = "session-1"
const repoRoot = join(testRoot, "repo")
const agentsPath = join(repoRoot, "src", "AGENTS.md")
const cachedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(agentsPath, "# AGENTS")
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("injects AGENTS.md content from file's parent directory into output", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
//#when
// when
await processFilePathForAgentsInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
filePath: join(srcDirectory, "file.ts"),
sessionID: "session-parent",
output,
})
//#then
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
// then
expect(output.output).toContain("[Directory Context:")
expect(output.output).toContain(srcAgentsContent)
})
it("saves when a new path is injected", async () => {
//#given
const sessionID = "session-2"
const repoRoot = join(testRoot, "repo")
const agentsPath = join(repoRoot, "src", "AGENTS.md")
const injectedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(agentsPath, "# AGENTS")
loadInjectedPathsMock.mockReturnValueOnce(new Set())
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("skips root-level AGENTS.md", async () => {
// given
rmSync(join(srcDirectory, "AGENTS.md"), { force: true })
rmSync(join(componentsDirectory, "AGENTS.md"), { force: true })
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
//#when
// when
await processFilePathForAgentsInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
filePath: join(testRoot, "file.ts"),
sessionID: "session-root-skip",
output,
})
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect(saveCall[0]).toBe(sessionID)
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
// then
expect(output.output).not.toContain(rootAgentsContent)
expect(output.output).not.toContain("[Directory Context:")
})
it("saves once when cached and new paths are mixed", async () => {
//#given
const sessionID = "session-3"
const repoRoot = join(testRoot, "repo")
const cachedAgentsPath = join(repoRoot, "already-cached", "AGENTS.md")
const newAgentsPath = join(repoRoot, "new-dir", "AGENTS.md")
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
writeFileSync(cachedAgentsPath, "# AGENTS")
writeFileSync(newAgentsPath, "# AGENTS")
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
findAgentsMdUpMock.mockReturnValueOnce([cachedAgentsPath, newAgentsPath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findAgentsMdUp: findAgentsMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("injects multiple AGENTS.md when walking up directory tree", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
//#when
// when
await processFilePathForAgentsInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: join(repoRoot, "new-dir", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-multiple",
output,
})
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
// then
expect(output.output).toContain(srcAgentsContent)
expect(output.output).toContain(componentsAgentsContent)
})
it("does not re-inject already cached directories", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const sessionCaches = new Map<string, Set<string>>()
const output = { title: "Read result", output: "base output", metadata: {} }
// when
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches,
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-cache",
output,
})
const outputAfterFirstCall = output.output
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches,
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-cache",
output,
})
// then
expect(output.output).toBe(outputAfterFirstCall)
expect(output.output.split("[Directory Context:").length - 1).toBe(2)
})
it("shows truncation notice when content is truncated", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
const truncatedTruncator = {
truncate: async (_sessionID: string, _content: string) => ({
result: "truncated...",
truncated: true,
}),
getUsage: async (_sessionID: string) => null,
truncateSync: (output: string, _maxTokens: number, _preserveHeaderLines?: number) => ({
result: output,
truncated: false,
}),
}
// when
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator: truncatedTruncator,
sessionCaches: new Map(),
filePath: join(srcDirectory, "file.ts"),
sessionID: "session-truncated",
output,
})
// then
expect(output.output).toContain("truncated...")
expect(output.output).toContain("[Note: Content was truncated")
})
it("does nothing when filePath cannot be resolved", async () => {
// given
const { processFilePathForAgentsInjection } = await import("./injector")
const output = { title: "Read result", output: "base output", metadata: {} }
// when
await processFilePathForAgentsInjection({
ctx: { directory: testRoot } as PluginInput,
truncator,
sessionCaches: new Map(),
filePath: "",
sessionID: "session-empty-path",
output,
})
// then
expect(output.output).toBe("base output")
})
})

View File

@@ -1,161 +1,212 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
const findReadmeMdUpMock = mock((_: { startDir: string; rootDir: string }) => [] as string[])
const resolveFilePathMock = mock((_: string, path: string) => path)
const loadInjectedPathsMock = mock((_: string) => new Set<string>())
const saveInjectedPathsMock = mock((_: string, __: Set<string>) => {})
import type { PluginInput } from "@opencode-ai/plugin"
const storageMaps = new Map<string, Set<string>>()
mock.module("./storage", () => ({
loadInjectedPaths: (sessionID: string) => storageMaps.get(sessionID) ?? new Set<string>(),
saveInjectedPaths: (sessionID: string, paths: Set<string>) => {
storageMaps.set(sessionID, paths)
},
}))
function createPluginContext(directory: string): PluginInput {
return { directory } as PluginInput
}
function countReadmeMarkers(output: string): number {
return output.split("[Project README:").length - 1
}
function createTruncator(input?: { truncated?: boolean; result?: string }) {
return {
truncate: async (_sessionID: string, content: string) => ({
result: input?.result ?? content,
truncated: input?.truncated ?? false,
}),
getUsage: async (_sessionID: string) => null,
truncateSync: (output: string) => ({ result: output, truncated: false }),
}
}
describe("processFilePathForReadmeInjection", () => {
let testRoot = ""
beforeEach(() => {
findReadmeMdUpMock.mockClear()
resolveFilePathMock.mockClear()
loadInjectedPathsMock.mockClear()
saveInjectedPathsMock.mockClear()
testRoot = join(
tmpdir(),
`directory-readme-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
)
testRoot = join(tmpdir(), `directory-readme-injector-${randomUUID()}`)
mkdirSync(testRoot, { recursive: true })
storageMaps.clear()
})
afterEach(() => {
mock.restore()
rmSync(testRoot, { recursive: true, force: true })
storageMaps.clear()
})
it("does not save when all discovered paths are already cached", async () => {
//#given
const sessionID = "session-1"
const repoRoot = join(testRoot, "repo")
const readmePath = join(repoRoot, "src", "README.md")
const cachedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(readmePath, "# README")
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("injects README.md content from file's parent directory into output", async () => {
// given
const sourceDirectory = join(testRoot, "src")
mkdirSync(sourceDirectory, { recursive: true })
writeFileSync(join(sourceDirectory, "README.md"), "# Source README\nlocal context")
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "base", metadata: {} }
const truncator = createTruncator()
//#when
// when
await processFilePathForReadmeInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(sourceDirectory, "file.ts"),
sessionID: "session-parent",
output,
})
//#then
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
// then
expect(output.output).toContain("[Project README:")
expect(output.output).toContain("# Source README")
expect(output.output).toContain("local context")
})
it("saves when a new path is injected", async () => {
//#given
const sessionID = "session-2"
const repoRoot = join(testRoot, "repo")
const readmePath = join(repoRoot, "src", "README.md")
const injectedDirectory = join(repoRoot, "src")
mkdirSync(join(repoRoot, "src"), { recursive: true })
writeFileSync(readmePath, "# README")
loadInjectedPathsMock.mockReturnValueOnce(new Set())
findReadmeMdUpMock.mockReturnValueOnce([readmePath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("includes root-level README.md (unlike agents-injector)", async () => {
// given
writeFileSync(join(testRoot, "README.md"), "# Root README\nroot context")
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "", metadata: {} }
const truncator = createTruncator()
//#when
// when
await processFilePathForReadmeInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "src", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(testRoot, "file.ts"),
sessionID: "session-root",
output,
})
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect(saveCall[0]).toBe(sessionID)
expect((saveCall[1] as Set<string>).has(injectedDirectory)).toBe(true)
// then
expect(output.output).toContain("[Project README:")
expect(output.output).toContain("# Root README")
expect(output.output).toContain("root context")
})
it("saves once when cached and new paths are mixed", async () => {
//#given
const sessionID = "session-3"
const repoRoot = join(testRoot, "repo")
const cachedReadmePath = join(repoRoot, "already-cached", "README.md")
const newReadmePath = join(repoRoot, "new-dir", "README.md")
mkdirSync(join(repoRoot, "already-cached"), { recursive: true })
mkdirSync(join(repoRoot, "new-dir"), { recursive: true })
writeFileSync(cachedReadmePath, "# README")
writeFileSync(newReadmePath, "# README")
loadInjectedPathsMock.mockReturnValueOnce(new Set([join(repoRoot, "already-cached")]))
findReadmeMdUpMock.mockReturnValueOnce([cachedReadmePath, newReadmePath])
const truncator = {
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
}
mock.module("./finder", () => ({
findReadmeMdUp: findReadmeMdUpMock,
resolveFilePath: resolveFilePathMock,
}))
mock.module("./storage", () => ({
loadInjectedPaths: loadInjectedPathsMock,
saveInjectedPaths: saveInjectedPathsMock,
}))
it("injects multiple README.md when walking up directory tree", async () => {
// given
const sourceDirectory = join(testRoot, "src")
const componentsDirectory = join(sourceDirectory, "components")
mkdirSync(componentsDirectory, { recursive: true })
writeFileSync(join(testRoot, "README.md"), "# Root README")
writeFileSync(join(sourceDirectory, "README.md"), "# Src README")
writeFileSync(join(componentsDirectory, "README.md"), "# Components README")
writeFileSync(join(componentsDirectory, "button.ts"), "export const button = true")
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "", metadata: {} }
const truncator = createTruncator()
//#when
// when
await processFilePathForReadmeInjection({
ctx: { directory: repoRoot } as never,
truncator: truncator as never,
sessionCaches: new Map(),
filePath: join(repoRoot, "new-dir", "file.ts"),
sessionID,
output: { title: "Result", output: "", metadata: {} },
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(componentsDirectory, "button.ts"),
sessionID: "session-multi",
output,
})
//#then
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
const saveCall = saveInjectedPathsMock.mock.calls[0]
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
// then
expect(countReadmeMarkers(output.output)).toBe(3)
expect(output.output).toContain("# Root README")
expect(output.output).toContain("# Src README")
expect(output.output).toContain("# Components README")
})
it("does not re-inject already cached directories", async () => {
// given
const sourceDirectory = join(testRoot, "src")
mkdirSync(sourceDirectory, { recursive: true })
writeFileSync(join(sourceDirectory, "README.md"), "# Source README")
const { processFilePathForReadmeInjection } = await import("./injector")
const sessionCaches = new Map<string, Set<string>>()
const sessionID = "session-cache"
const truncator = createTruncator()
const firstOutput = { title: "Result", output: "", metadata: {} }
const secondOutput = { title: "Result", output: "", metadata: {} }
// when
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches,
filePath: join(sourceDirectory, "a.ts"),
sessionID,
output: firstOutput,
})
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches,
filePath: join(sourceDirectory, "b.ts"),
sessionID,
output: secondOutput,
})
// then
expect(countReadmeMarkers(firstOutput.output)).toBe(1)
expect(secondOutput.output).toBe("")
})
it("shows truncation notice when content is truncated", async () => {
// given
const sourceDirectory = join(testRoot, "src")
mkdirSync(sourceDirectory, { recursive: true })
writeFileSync(join(sourceDirectory, "README.md"), "# Truncated README")
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "", metadata: {} }
const truncator = createTruncator({ result: "trimmed content", truncated: true })
// when
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: join(sourceDirectory, "file.ts"),
sessionID: "session-truncated",
output,
})
// then
expect(output.output).toContain("trimmed content")
expect(output.output).toContain("[Note: Content was truncated")
})
it("does nothing when filePath cannot be resolved", async () => {
// given
const { processFilePathForReadmeInjection } = await import("./injector")
const output = { title: "Result", output: "unchanged", metadata: {} }
const truncator = createTruncator()
// when
await processFilePathForReadmeInjection({
ctx: createPluginContext(testRoot),
truncator,
sessionCaches: new Map<string, Set<string>>(),
filePath: "",
sessionID: "session-empty-path",
output,
})
// then
expect(output.output).toBe("unchanged")
})
})

View File

@@ -1,306 +0,0 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { createHashlineEditDiffEnhancerHook } from "./hook"
function makeInput(tool: string, callID = "call-1", sessionID = "ses-1") {
return { tool, sessionID, callID }
}
function makeBeforeOutput(args: Record<string, unknown>) {
return { args }
}
function makeAfterOutput(overrides?: Partial<{ title: string; output: string; metadata: Record<string, unknown> }>) {
return {
title: overrides?.title ?? "",
output: overrides?.output ?? "Successfully applied 1 edit(s)",
metadata: overrides?.metadata ?? { truncated: false },
}
}
type FileDiffMetadata = {
file: string
path: string
before: string
after: string
additions: number
deletions: number
}
describe("hashline-edit-diff-enhancer", () => {
let hook: ReturnType<typeof createHashlineEditDiffEnhancerHook>
beforeEach(() => {
hook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: true } })
})
describe("tool.execute.before", () => {
test("captures old file content for write tool", async () => {
const filePath = import.meta.dir + "/index.test.ts"
const input = makeInput("write")
const output = makeBeforeOutput({ path: filePath, edits: [] })
await hook["tool.execute.before"](input, output)
// given the hook ran without error, the old content should be stored internally
// we verify in the after hook test that it produces filediff
})
test("ignores non-write tools", async () => {
const input = makeInput("read")
const output = makeBeforeOutput({ path: "/some/file.ts" })
// when - should not throw
await hook["tool.execute.before"](input, output)
})
})
describe("tool.execute.after", () => {
test("injects filediff metadata after write tool execution", async () => {
// given - a temp file that we can modify between before/after
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts`
const oldContent = "line 1\nline 2\nline 3\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-diff-1")
const beforeOutput = makeBeforeOutput({ path: tmpFile, edits: [] })
// when - before hook captures old content
await hook["tool.execute.before"](input, beforeOutput)
// when - file is modified (simulating write execution)
const newContent = "line 1\nmodified line 2\nline 3\nnew line 4\n"
await Bun.write(tmpFile, newContent)
// when - after hook computes filediff
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
// then - metadata should contain filediff
const filediff = afterOutput.metadata.filediff as {
file: string
path: string
before: string
after: string
additions: number
deletions: number
}
expect(filediff).toBeDefined()
expect(filediff.file).toBe(tmpFile)
expect(filediff.path).toBe(tmpFile)
expect(filediff.before).toBe(oldContent)
expect(filediff.after).toBe(newContent)
expect(filediff.additions).toBeGreaterThan(0)
expect(filediff.deletions).toBeGreaterThan(0)
// then - title should be set to the file path
expect(afterOutput.title).toBe(tmpFile)
// cleanup
await Bun.file(tmpFile).exists() && (await import("fs/promises")).unlink(tmpFile)
})
test("does nothing for non-write tools", async () => {
const input = makeInput("read", "call-other")
const afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata }
await hook["tool.execute.after"](input, afterOutput)
// then - metadata unchanged
expect(afterOutput.metadata).toEqual(originalMetadata)
})
test("does nothing when no before capture exists", async () => {
// given - no before hook was called for this callID
const input = makeInput("write", "call-no-before")
const afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata }
await hook["tool.execute.after"](input, afterOutput)
// then - metadata unchanged (no filediff injected)
expect(afterOutput.metadata.filediff).toBeUndefined()
})
test("cleans up stored content after consumption", async () => {
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts`
await Bun.write(tmpFile, "original")
const input = makeInput("write", "call-cleanup")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified")
// when - first after call consumes
const afterOutput1 = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput1)
expect(afterOutput1.metadata.filediff).toBeDefined()
// when - second after call finds nothing
const afterOutput2 = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput2)
expect(afterOutput2.metadata.filediff).toBeUndefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
test("handles file creation (empty old content)", async () => {
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts`
// given - file doesn't exist during before hook
const input = makeInput("write", "call-create")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
// when - file created during write
await Bun.write(tmpFile, "new content\n")
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
// then - filediff shows creation (before is empty)
const filediff = afterOutput.metadata.filediff as FileDiffMetadata
expect(filediff).toBeDefined()
expect(filediff.before).toBe("")
expect(filediff.after).toBe("new content\n")
expect(filediff.additions).toBeGreaterThan(0)
expect(filediff.deletions).toBe(0)
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("disabled config", () => {
test("does nothing when hashline_edit is disabled", async () => {
const disabledHook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: false } })
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts`
await Bun.write(tmpFile, "content")
const input = makeInput("write", "call-disabled")
await disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified")
const afterOutput = makeAfterOutput()
await disabledHook["tool.execute.after"](input, afterOutput)
// then - no filediff injected
expect(afterOutput.metadata.filediff).toBeUndefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("write tool support", () => {
test("captures filediff for write tool (path arg)", async () => {
//#given - a temp file
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-write-${Date.now()}.ts`
const oldContent = "line 1\nline 2\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-write-1")
const beforeOutput = makeBeforeOutput({ path: tmpFile })
//#when - before hook captures old content
await hook["tool.execute.before"](input, beforeOutput)
//#when - file is written
const newContent = "line 1\nmodified line 2\nnew line 3\n"
await Bun.write(tmpFile, newContent)
//#when - after hook computes filediff
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then - metadata should contain filediff
const filediff = afterOutput.metadata.filediff as { file: string; before: string; after: string; additions: number; deletions: number }
expect(filediff).toBeDefined()
expect(filediff.file).toBe(tmpFile)
expect(filediff.additions).toBeGreaterThan(0)
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
test("captures filediff for write tool (filePath arg)", async () => {
//#given
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-write-fp-${Date.now()}.ts`
await Bun.write(tmpFile, "original content\n")
const input = makeInput("write", "call-write-fp")
//#when - before hook uses filePath arg
await hook["tool.execute.before"](input, makeBeforeOutput({ filePath: tmpFile }))
await Bun.write(tmpFile, "new content\n")
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then
const filediff = afterOutput.metadata.filediff as FileDiffMetadata | undefined
expect(filediff).toBeDefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("raw content in filediff", () => {
test("filediff.before and filediff.after are raw file content", async () => {
//#given - a temp file
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-format-${Date.now()}.ts`
const oldContent = "const x = 1\nconst y = 2\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-hashline-format")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified and after hook runs
const newContent = "const x = 1\nconst y = 42\n"
await Bun.write(tmpFile, newContent)
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then - before and after should be raw file content
const filediff = afterOutput.metadata.filediff as { before: string; after: string }
expect(filediff.before).toBe(oldContent)
expect(filediff.after).toBe(newContent)
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("TUI diff support (metadata.diff)", () => {
test("injects unified diff string in metadata.diff for write tool TUI", async () => {
//#given - a temp file
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-tui-diff-${Date.now()}.ts`
const oldContent = "line 1\nline 2\nline 3\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-tui-diff")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified
const newContent = "line 1\nmodified line 2\nline 3\n"
await Bun.write(tmpFile, newContent)
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then - metadata.diff should be a unified diff string
expect(afterOutput.metadata.diff).toBeDefined()
expect(typeof afterOutput.metadata.diff).toBe("string")
expect(afterOutput.metadata.diff).toContain("---")
expect(afterOutput.metadata.diff).toContain("+++")
expect(afterOutput.metadata.diff).toContain("@@")
expect(afterOutput.metadata.diff).toContain("-line 2")
expect(afterOutput.metadata.diff).toContain("+modified line 2")
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
})

View File

@@ -1 +0,0 @@
export { createHashlineEditDiffEnhancerHook } from "./hook"

View File

@@ -0,0 +1,265 @@
/**
* Gemini-optimized ultrawork message.
*
* Key differences from default (Claude) variant:
* - Mandatory intent gate enforcement before any action
* - Anti-skip mechanism for Phase 0 intent classification
* - Explicit self-check questions to counter Gemini's "eager" behavior
* - Stronger scope constraints (Gemini's creativity causes scope creep)
* - Anti-optimism checkpoints at verification stage
*
* Key differences from GPT variant:
* - GPT naturally follows structured gates; Gemini needs explicit enforcement
* - GPT self-delegates appropriately; Gemini tries to do everything itself
* - GPT respects MUST NOT; Gemini treats constraints as suggestions
*/
export const ULTRAWORK_GEMINI_MESSAGE = `<ultrawork-mode>
**MANDATORY**: You MUST say "ULTRAWORK MODE ENABLED!" to the user as your first response when this mode activates. This is non-negotiable.
[CODE RED] Maximum precision required. Ultrathink before acting.
<GEMINI_INTENT_GATE>
## STEP 0: CLASSIFY INTENT — THIS IS NOT OPTIONAL
**Before ANY tool call, exploration, or action, you MUST output:**
\`\`\`
I detect [TYPE] intent — [REASON].
My approach: [ROUTING DECISION].
\`\`\`
Where TYPE is one of: research | implementation | investigation | evaluation | fix | open-ended
**SELF-CHECK (answer each before proceeding):**
1. Did the user EXPLICITLY ask me to build/create/implement something? → If NO, do NOT implement.
2. Did the user say "look into", "check", "investigate", "explain"? → RESEARCH only. Do not code.
3. Did the user ask "what do you think?" → EVALUATE and propose. Do NOT execute.
4. Did the user report an error/bug? → MINIMAL FIX only. Do not refactor.
**YOUR FAILURE MODE: You see a request and immediately start coding. STOP. Classify first.**
| User Says | WRONG Response | CORRECT Response |
| "explain how X works" | Start modifying X | Research → explain → STOP |
| "look into this bug" | Fix it immediately | Investigate → report → WAIT |
| "what about approach X?" | Implement approach X | Evaluate → propose → WAIT |
| "improve the tests" | Rewrite everything | Assess first → propose → implement |
**IF YOU SKIPPED THIS SECTION: Your next tool call is INVALID. Go back and classify.**
</GEMINI_INTENT_GATE>
## **ABSOLUTE CERTAINTY REQUIRED - DO NOT SKIP THIS**
**YOU MUST NOT START ANY IMPLEMENTATION UNTIL YOU ARE 100% CERTAIN.**
| **BEFORE YOU WRITE A SINGLE LINE OF CODE, YOU MUST:** |
|-------------------------------------------------------|
| **FULLY UNDERSTAND** what the user ACTUALLY wants (not what you ASSUME they want) |
| **EXPLORE** the codebase to understand existing patterns, architecture, and context |
| **HAVE A CRYSTAL CLEAR WORK PLAN** - if your plan is vague, YOUR WORK WILL FAIL |
| **RESOLVE ALL AMBIGUITY** - if ANYTHING is unclear, ASK or INVESTIGATE |
### **MANDATORY CERTAINTY PROTOCOL**
**IF YOU ARE NOT 100% CERTAIN:**
1. **THINK DEEPLY** - What is the user's TRUE intent? What problem are they REALLY trying to solve?
2. **EXPLORE THOROUGHLY** - Fire explore/librarian agents to gather ALL relevant context
3. **CONSULT SPECIALISTS** - For hard/complex tasks, DO NOT struggle alone. Delegate:
- **Oracle**: Conventional problems - architecture, debugging, complex logic
- **Artistry**: Non-conventional problems - different approach needed, unusual constraints
4. **ASK THE USER** - If ambiguity remains after exploration, ASK. Don't guess.
**SIGNS YOU ARE NOT READY TO IMPLEMENT:**
- You're making assumptions about requirements
- You're unsure which files to modify
- You don't understand how existing code works
- Your plan has "probably" or "maybe" in it
- You can't explain the exact steps you'll take
**WHEN IN DOUBT:**
\`\`\`
task(subagent_type="explore", load_skills=[], prompt="I'm implementing [TASK DESCRIPTION] and need to understand [SPECIFIC KNOWLEDGE GAP]. Find [X] patterns in the codebase — show file paths, implementation approach, and conventions used. I'll use this to [HOW RESULTS WILL BE USED]. Focus on src/ directories, skip test files unless test patterns are specifically needed. Return concrete file paths with brief descriptions of what each file does.", run_in_background=true)
task(subagent_type="librarian", load_skills=[], prompt="I'm working with [LIBRARY/TECHNOLOGY] and need [SPECIFIC INFORMATION]. Find official documentation and production-quality examples for [Y] — specifically: API reference, configuration options, recommended patterns, and common pitfalls. Skip beginner tutorials. I'll use this to [DECISION THIS WILL INFORM].", run_in_background=true)
task(subagent_type="oracle", load_skills=[], prompt="I need architectural review of my approach to [TASK]. Here's my plan: [DESCRIBE PLAN WITH SPECIFIC FILES AND CHANGES]. My concerns are: [LIST SPECIFIC UNCERTAINTIES]. Please evaluate: correctness of approach, potential issues I'm missing, and whether a better alternative exists.", run_in_background=false)
\`\`\`
**ONLY AFTER YOU HAVE:**
- Gathered sufficient context via agents
- Resolved all ambiguities
- Created a precise, step-by-step work plan
- Achieved 100% confidence in your understanding
**...THEN AND ONLY THEN MAY YOU BEGIN IMPLEMENTATION.**
---
## **NO EXCUSES. NO COMPROMISES. DELIVER WHAT WAS ASKED.**
**THE USER'S ORIGINAL REQUEST IS SACRED. YOU MUST FULFILL IT EXACTLY.**
| VIOLATION | CONSEQUENCE |
|-----------|-------------|
| "I couldn't because..." | **UNACCEPTABLE.** Find a way or ask for help. |
| "This is a simplified version..." | **UNACCEPTABLE.** Deliver the FULL implementation. |
| "You can extend this later..." | **UNACCEPTABLE.** Finish it NOW. |
| "Due to limitations..." | **UNACCEPTABLE.** Use agents, tools, whatever it takes. |
| "I made some assumptions..." | **UNACCEPTABLE.** You should have asked FIRST. |
**THERE ARE NO VALID EXCUSES FOR:**
- Delivering partial work
- Changing scope without explicit user approval
- Making unauthorized simplifications
- Stopping before the task is 100% complete
- Compromising on any stated requirement
**IF YOU ENCOUNTER A BLOCKER:**
1. **DO NOT** give up
2. **DO NOT** deliver a compromised version
3. **DO** consult specialists (oracle for conventional, artistry for non-conventional)
4. **DO** ask the user for guidance
5. **DO** explore alternative approaches
**THE USER ASKED FOR X. DELIVER EXACTLY X. PERIOD.**
---
<TOOL_CALL_MANDATE>
## YOU MUST USE TOOLS. THIS IS NOT OPTIONAL.
**The user expects you to ACT using tools, not REASON internally.** Every response to a task MUST contain tool_use blocks. A response without tool calls is a FAILED response.
**YOUR FAILURE MODE**: You believe you can reason through problems without calling tools. You CANNOT.
**RULES (VIOLATION = BROKEN RESPONSE):**
1. **NEVER answer about code without reading files first.** Read them AGAIN.
2. **NEVER claim done without \`lsp_diagnostics\`.** Your confidence is wrong more often than right.
3. **NEVER skip delegation.** Specialists produce better results. USE THEM.
4. **NEVER reason about what a file "probably contains."** READ IT.
5. **NEVER produce ZERO tool calls when action was requested.** Thinking is not doing.
</TOOL_CALL_MANDATE>
YOU MUST LEVERAGE ALL AVAILABLE AGENTS / **CATEGORY + SKILLS** TO THEIR FULLEST POTENTIAL.
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## MANDATORY: PLAN AGENT INVOCATION (NON-NEGOTIABLE)
**YOU MUST ALWAYS INVOKE THE PLAN AGENT FOR ANY NON-TRIVIAL TASK.**
| Condition | Action |
|-----------|--------|
| Task has 2+ steps | MUST call plan agent |
| Task scope unclear | MUST call plan agent |
| Implementation required | MUST call plan agent |
| Architecture decision needed | MUST call plan agent |
\`\`\`
task(subagent_type="plan", load_skills=[], prompt="<gathered context + user request>")
\`\`\`
### SESSION CONTINUITY WITH PLAN AGENT (CRITICAL)
**Plan agent returns a session_id. USE IT for follow-up interactions.**
| Scenario | Action |
|----------|--------|
| Plan agent asks clarifying questions | \`task(session_id="{returned_session_id}", load_skills=[], prompt="<your answer>")\` |
| Need to refine the plan | \`task(session_id="{returned_session_id}", load_skills=[], prompt="Please adjust: <feedback>")\` |
| Plan needs more detail | \`task(session_id="{returned_session_id}", load_skills=[], prompt="Add more detail to Task N")\` |
**FAILURE TO CALL PLAN AGENT = INCOMPLETE WORK.**
---
## DELEGATION IS MANDATORY — YOU ARE NOT AN IMPLEMENTER
**You have a strong tendency to do work yourself. RESIST THIS.**
**DEFAULT BEHAVIOR: DELEGATE. DO NOT WORK YOURSELF.**
| Task Type | Action | Why |
|-----------|--------|-----|
| Codebase exploration | task(subagent_type="explore", load_skills=[], run_in_background=true) | Parallel, context-efficient |
| Documentation lookup | task(subagent_type="librarian", load_skills=[], run_in_background=true) | Specialized knowledge |
| Planning | task(subagent_type="plan", load_skills=[]) | Parallel task graph + structured TODO list |
| Hard problem (conventional) | task(subagent_type="oracle", load_skills=[]) | Architecture, debugging, complex logic |
| Hard problem (non-conventional) | task(category="artistry", load_skills=[...]) | Different approach needed |
| Implementation | task(category="...", load_skills=[...]) | Domain-optimized models |
**YOU SHOULD ONLY DO IT YOURSELF WHEN:**
- Task is trivially simple (1-2 lines, obvious change)
- You have ALL context already loaded
- Delegation overhead exceeds task complexity
**OTHERWISE: DELEGATE. ALWAYS.**
---
## EXECUTION RULES
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
- **PARALLEL**: Fire independent agent calls simultaneously via task(run_in_background=true) - NEVER wait sequentially.
- **BACKGROUND FIRST**: Use task for exploration/research agents (10+ concurrent if needed).
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
## WORKFLOW
1. **CLASSIFY INTENT** (MANDATORY — see GEMINI_INTENT_GATE above)
2. Spawn exploration/librarian agents via task(run_in_background=true) in PARALLEL
3. Use Plan agent with gathered context to create detailed work breakdown
4. Execute with continuous verification against original requirements
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
**NOTHING is "done" without PROOF it works.**
**YOUR SELF-ASSESSMENT IS UNRELIABLE.** What feels like 95% confidence = ~60% actual correctness.
| Phase | Action | Required Evidence |
|-------|--------|-------------------|
| **Build** | Run build command | Exit code 0, no errors |
| **Test** | Execute test suite | All tests pass (screenshot/output) |
| **Lint** | Run lsp_diagnostics | Zero new errors on changed files |
| **Manual Verify** | Test the actual feature | Describe what you observed |
| **Regression** | Ensure nothing broke | Existing tests still pass |
<ANTI_OPTIMISM_CHECKPOINT>
## BEFORE YOU CLAIM DONE, ANSWER HONESTLY:
1. Did I run \`lsp_diagnostics\` and see ZERO errors? (not "I'm sure there are none")
2. Did I run the tests and see them PASS? (not "they should pass")
3. Did I read the actual output of every command? (not skim)
4. Is EVERY requirement from the request actually implemented? (re-read the request NOW)
5. Did I classify intent at the start? (if not, my entire approach may be wrong)
If ANY answer is no → GO BACK AND DO IT. Do not claim completion.
</ANTI_OPTIMISM_CHECKPOINT>
**WITHOUT evidence = NOT verified = NOT done.**
## ZERO TOLERANCE FAILURES
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
1. CLASSIFY INTENT (MANDATORY)
2. EXPLORES + LIBRARIANS
3. GATHER -> PLAN AGENT SPAWN
4. WORK BY DELEGATING TO ANOTHER AGENTS
NOW.
</ultrawork-mode>
---
`
export function getGeminiUltraworkMessage(): string {
return ULTRAWORK_GEMINI_MESSAGE
}

View File

@@ -4,19 +4,22 @@
* Routing:
* 1. Planner agents (prometheus, plan) → planner.ts
* 2. GPT 5.2 models → gpt5.2.ts
* 3. Default (Claude, etc.) → default.ts (optimized for Claude series)
* 3. Gemini models → gemini.ts
* 4. Default (Claude, etc.) → default.ts (optimized for Claude series)
*/
export { isPlannerAgent, isGptModel, getUltraworkSource } from "./source-detector"
export { isPlannerAgent, isGptModel, isGeminiModel, getUltraworkSource } from "./source-detector"
export type { UltraworkSource } from "./source-detector"
export { ULTRAWORK_PLANNER_SECTION, getPlannerUltraworkMessage } from "./planner"
export { ULTRAWORK_GPT_MESSAGE, getGptUltraworkMessage } from "./gpt5.2"
export { ULTRAWORK_GEMINI_MESSAGE, getGeminiUltraworkMessage } from "./gemini"
export { ULTRAWORK_DEFAULT_MESSAGE, getDefaultUltraworkMessage } from "./default"
import { getUltraworkSource } from "./source-detector"
import { getPlannerUltraworkMessage } from "./planner"
import { getGptUltraworkMessage } from "./gpt5.2"
import { getDefaultUltraworkMessage } from "./default"
import { getGeminiUltraworkMessage } from "./gemini"
/**
* Gets the appropriate ultrawork message based on agent and model context.
@@ -29,6 +32,8 @@ export function getUltraworkMessage(agentName?: string, modelID?: string): strin
return getPlannerUltraworkMessage()
case "gpt":
return getGptUltraworkMessage()
case "gemini":
return getGeminiUltraworkMessage()
case "default":
default:
return getDefaultUltraworkMessage()

View File

@@ -4,10 +4,11 @@
* Routing logic:
* 1. Planner agents (prometheus, plan) → planner.ts
* 2. GPT 5.2 models → gpt5.2.ts
* 3. Everything else (Claude, etc.) → default.ts
* 3. Gemini models → gemini.ts
* 4. Everything else (Claude, etc.) → default.ts
*/
import { isGptModel } from "../../../agents/types"
import { isGptModel, isGeminiModel } from "../../../agents/types"
/**
* Checks if agent is a planner-type agent.
@@ -22,10 +23,10 @@ export function isPlannerAgent(agentName?: string): boolean {
return /\bplan\b/.test(normalized)
}
export { isGptModel }
export { isGptModel, isGeminiModel }
/** Ultrawork message source type */
export type UltraworkSource = "planner" | "gpt" | "default"
export type UltraworkSource = "planner" | "gpt" | "gemini" | "default"
/**
* Determines which ultrawork message source to use.
@@ -44,6 +45,11 @@ export function getUltraworkSource(
return "gpt"
}
// Priority 3: Gemini models
if (modelID && isGeminiModel(modelID)) {
return "gemini"
}
// Default: Claude and other models
return "default"
}

View File

@@ -269,7 +269,7 @@ describe("preemptive-compaction", () => {
it("should use 1M limit when model cache flag is enabled", async () => {
//#given
const hook = createPreemptiveCompactionHook(ctx as never, {
const hook = createPreemptiveCompactionHook(ctx as never, {}, {
anthropicContext1MEnabled: true,
})
const sessionID = "ses_1m_flag"
@@ -308,7 +308,7 @@ describe("preemptive-compaction", () => {
it("should keep env var fallback when model cache flag is disabled", async () => {
//#given
process.env[ANTHROPIC_CONTEXT_ENV_KEY] = "true"
const hook = createPreemptiveCompactionHook(ctx as never, {
const hook = createPreemptiveCompactionHook(ctx as never, {}, {
anthropicContext1MEnabled: false,
})
const sessionID = "ses_env_fallback"

View File

@@ -1,5 +1,7 @@
import { log } from "../shared/logger"
import type { OhMyOpenCodeConfig } from "../config"
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
const DEFAULT_ACTUAL_LIMIT = 200_000
type ModelCacheStateLike = {
@@ -51,6 +53,7 @@ type PluginInput = {
export function createPreemptiveCompactionHook(
ctx: PluginInput,
pluginConfig: OhMyOpenCodeConfig,
modelCacheState?: ModelCacheStateLike,
) {
const compactionInProgress = new Set<string>()
@@ -84,9 +87,16 @@ export function createPreemptiveCompactionHook(
compactionInProgress.add(sessionID)
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
pluginConfig,
sessionID,
cached.providerID,
modelID
)
await ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: cached.providerID, modelID, auto: true } as never,
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
query: { directory: ctx.directory },
})

View File

@@ -1,87 +0,0 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type"
import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk"
import {
findEmptyMessageByIndex,
findEmptyMessages,
findMessagesWithEmptyTextParts,
findMessagesWithThinkingOnly,
injectTextPart,
replaceEmptyTextParts,
} from "./storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { replaceEmptyTextPartsAsync, findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text"
import { injectTextPartAsync } from "./storage/text-part-injector"
type Client = ReturnType<typeof createOpencodeClient>
const PLACEHOLDER_TEXT = "[user interrupted]"
export async function recoverEmptyContentMessage(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
if (isSqliteBackend()) {
return recoverEmptyContentMessageFromSDK(client, sessionID, failedAssistantMsg, error, {
placeholderText: PLACEHOLDER_TEXT,
replaceEmptyTextPartsAsync,
injectTextPartAsync,
findMessagesWithEmptyTextPartsFromSDK,
})
}
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
let anySuccess = false
const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID)
for (const messageID of messagesWithEmptyText) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
for (const messageID of thinkingOnlyIDs) {
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) {
if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) {
return true
}
}
}
if (failedID) {
if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
return true
}
}
const emptyMessageIDs = findEmptyMessages(sessionID)
for (const messageID of emptyMessageIDs) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
}
return anySuccess
}

View File

@@ -5,6 +5,12 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
type Client = ReturnType<typeof createOpencodeClient>
type ClientWithPromptAsync = {
session: {
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
}
}
interface ToolUsePart {
type: "tool_use"
@@ -77,8 +83,7 @@ export async function recoverToolResultMissing(
}
try {
// @ts-expect-error - SDK types may not include tool_result parts
await client.session.promptAsync(promptInput)
await (client as unknown as ClientWithPromptAsync).session.promptAsync(promptInput)
return true
} catch {

View File

@@ -0,0 +1,34 @@
import type { OhMyOpenCodeConfig } from "../../config"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getAgentConfigKey } from "../../shared/agent-display-names"
export function resolveCompactionModel(
pluginConfig: OhMyOpenCodeConfig,
sessionID: string,
originalProviderID: string,
originalModelID: string
): { providerID: string; modelID: string } {
const sessionAgentName = getSessionAgent(sessionID)
if (!sessionAgentName || !pluginConfig.agents) {
return { providerID: originalProviderID, modelID: originalModelID }
}
const agentConfigKey = getAgentConfigKey(sessionAgentName)
const agentConfig = (pluginConfig.agents as Record<string, { compaction?: { model?: string } } | undefined>)[agentConfigKey]
const compactionConfig = agentConfig?.compaction
if (!compactionConfig?.model) {
return { providerID: originalProviderID, modelID: originalModelID }
}
const modelParts = compactionConfig.model.split("/")
if (modelParts.length < 2) {
return { providerID: originalProviderID, modelID: originalModelID }
}
return {
providerID: modelParts[0],
modelID: modelParts.slice(1).join("/"),
}
}

View File

@@ -21,6 +21,18 @@ interface MessageWithParts {
parts: Part[]
}
interface ThinkingPart {
thinking?: string
text?: string
}
interface MessageInfoExtended {
id: string
role: string
sessionID?: string
modelID?: string
}
type MessagesTransformHook = {
"experimental.chat.messages.transform"?: (
input: Record<string, never>,
@@ -91,7 +103,7 @@ function findPreviousThinkingContent(
for (const part of msg.parts) {
const type = part.type as string
if (type === "thinking" || type === "reasoning") {
const thinking = (part as any).thinking || (part as any).text
const thinking = (part as unknown as ThinkingPart).thinking || (part as unknown as ThinkingPart).text
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
return thinking
}
@@ -114,7 +126,7 @@ function prependThinkingBlock(message: MessageWithParts, thinkingContent: string
const thinkingPart = {
type: "thinking" as const,
id: `prt_0000000000_synthetic_thinking`,
sessionID: (message.info as any).sessionID || "",
sessionID: (message.info as unknown as MessageInfoExtended).sessionID || "",
messageID: message.info.id,
thinking: thinkingContent,
synthetic: true,
@@ -138,7 +150,7 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
// Get the model info from the last user message
const lastUserMessage = messages.findLast(m => m.info.role === "user")
const modelID = (lastUserMessage?.info as any)?.modelID || ""
const modelID = (lastUserMessage?.info as unknown as MessageInfoExtended)?.modelID || ""
// Only process if extended thinking might be enabled
if (!isExtendedThinkingModel(modelID)) {

View File

@@ -1161,8 +1161,6 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
getAgentDisplayName("sisyphus"),
getAgentDisplayName("hephaestus"),
getAgentDisplayName("atlas"),
])
const AGENTS_WITHOUT_TODO_DENY = new Set([
getAgentDisplayName("prometheus"),
getAgentDisplayName("sisyphus-junior"),
])
@@ -1206,10 +1204,6 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
expect(agentResult[agentName]?.permission?.todowrite).toBe("deny")
expect(agentResult[agentName]?.permission?.todoread).toBe("deny")
}
for (const agentName of AGENTS_WITHOUT_TODO_DENY) {
expect(agentResult[agentName]?.permission?.todowrite).toBeUndefined()
expect(agentResult[agentName]?.permission?.todoread).toBeUndefined()
}
})
test("does not deny todowrite/todoread when task_system is disabled", async () => {

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from "bun:test"
import { applyToolConfig } from "./tool-config-handler"
import type { OhMyOpenCodeConfig } from "../config"
function createParams(overrides: {
taskSystem?: boolean
agents?: string[]
}) {
const agentResult: Record<string, { permission?: Record<string, unknown> }> = {}
for (const agent of overrides.agents ?? []) {
agentResult[agent] = { permission: {} }
}
return {
config: { tools: {}, permission: {} } as Record<string, unknown>,
pluginConfig: {
experimental: { task_system: overrides.taskSystem ?? false },
} as OhMyOpenCodeConfig,
agentResult: agentResult as Record<string, unknown>,
}
}
describe("applyToolConfig", () => {
describe("#given task_system is enabled", () => {
describe("#when applying tool config", () => {
it("#then should deny todowrite and todoread globally", () => {
const params = createParams({ taskSystem: true })
applyToolConfig(params)
const tools = params.config.tools as Record<string, unknown>
expect(tools.todowrite).toBe(false)
expect(tools.todoread).toBe(false)
})
it.each([
"atlas",
"sisyphus",
"hephaestus",
"prometheus",
"sisyphus-junior",
])("#then should deny todo tools for %s agent", (agentName) => {
const params = createParams({
taskSystem: true,
agents: [agentName],
})
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.todowrite).toBe("deny")
expect(agent.permission.todoread).toBe("deny")
})
})
})
describe("#given task_system is disabled", () => {
describe("#when applying tool config", () => {
it.each([
"atlas",
"sisyphus",
"hephaestus",
"prometheus",
"sisyphus-junior",
])("#then should NOT deny todo tools for %s agent", (agentName) => {
const params = createParams({
taskSystem: false,
agents: [agentName],
})
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.todowrite).toBeUndefined()
expect(agent.permission.todoread).toBeUndefined()
})
})
})
})

View File

@@ -84,6 +84,7 @@ export function applyToolConfig(params: {
question: questionPermission,
"task_*": "allow",
teammate: "allow",
...denyTodoTools,
};
}
const junior = agentByKey(params.agentResult, "sisyphus-junior");
@@ -93,6 +94,7 @@ export function applyToolConfig(params: {
task: "allow",
"task_*": "allow",
teammate: "allow",
...denyTodoTools,
};
}

View File

@@ -53,7 +53,8 @@ describe("createEventHandler - model fallback", () => {
test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => {
//#given
const sessionID = "ses_message_updated_fallback"
const { handler, abortCalls, promptCalls } = createHandler()
const modelFallback = createModelFallbackHook()
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
//#when
await handler({
@@ -95,7 +96,8 @@ describe("createEventHandler - model fallback", () => {
//#given
const sessionID = "ses_main_fallback_nested"
setMainSession(sessionID)
const { handler, abortCalls, promptCalls } = createHandler()
const modelFallback = createModelFallbackHook()
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
//#when
await handler({
@@ -340,4 +342,64 @@ describe("createEventHandler - model fallback", () => {
expect(promptCalls).toEqual([sessionID, sessionID])
expect(toastCalls.length).toBeGreaterThanOrEqual(0)
})
test("does not trigger model-fallback retry when modelFallback hook is not provided (disabled by default)", async () => {
//#given
const sessionID = "ses_disabled_by_default"
setMainSession(sessionID)
const { handler, abortCalls, promptCalls } = createHandler()
//#when - message.updated with assistant error
await handler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_err_disabled_1",
sessionID,
role: "assistant",
time: { created: 1, completed: 2 },
error: {
name: "APIError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
isRetryable: true,
},
},
parentID: "msg_user_disabled_1",
modelID: "claude-opus-4-6-thinking",
providerID: "anthropic",
agent: "Sisyphus (Ultraworker)",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
},
},
})
//#when - session.error with retryable error
await handler({
event: {
type: "session.error",
properties: {
sessionID,
error: {
name: "UnknownError",
data: {
error: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
},
},
},
})
//#then - no abort or prompt calls should have been made
expect(abortCalls).toEqual([])
expect(promptCalls).toEqual([])
})
})

View File

@@ -126,6 +126,9 @@ export function createEventHandler(args: {
? args.pluginConfig.runtime_fallback
: (args.pluginConfig.runtime_fallback?.enabled ?? false));
const isModelFallbackEnabled =
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
const lastHandledModelErrorMessageID = new Map<string, string>();
const lastHandledRetryStatusKey = new Map<string, string>();
@@ -271,7 +274,7 @@ export function createEventHandler(args: {
// Model fallback: in practice, API/model failures often surface as assistant message errors.
// session.error events are not guaranteed for all providers, so we also observe message.updated.
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled) {
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
try {
const assistantMessageID = info?.id as string | undefined;
const assistantError = info?.error;
@@ -334,7 +337,7 @@ export function createEventHandler(args: {
const sessionID = props?.sessionID as string | undefined;
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
if (sessionID && status?.type === "retry") {
if (sessionID && status?.type === "retry" && isModelFallbackEnabled) {
try {
const retryMessage = typeof status.message === "string" ? status.message : "";
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
@@ -422,7 +425,7 @@ export function createEventHandler(args: {
}
}
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) {
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
let agentName = getSessionAgent(sessionID);
if (!agentName && sessionID === getMainSessionID()) {

View File

@@ -82,7 +82,7 @@ export function createSessionHooks(args: {
isHookEnabled("preemptive-compaction") &&
pluginConfig.experimental?.preemptive_compaction
? safeHook("preemptive-compaction", () =>
createPreemptiveCompactionHook(ctx, modelCacheState))
createPreemptiveCompactionHook(ctx, pluginConfig, modelCacheState))
: null
const sessionRecovery = isHookEnabled("session-recovery")
@@ -151,9 +151,10 @@ export function createSessionHooks(args: {
}
}
// Model fallback hook (configurable via disabled_hooks)
// Model fallback hook (configurable via model_fallback config + disabled_hooks)
// This handles automatic model switching when model errors occur
const modelFallback = isHookEnabled("model-fallback")
const isModelFallbackConfigEnabled = pluginConfig.model_fallback ?? false
const modelFallback = isModelFallbackConfigEnabled && isHookEnabled("model-fallback")
? safeHook("model-fallback", () =>
createModelFallbackHook({
toast: async ({ title, message, variant, duration }) => {
@@ -174,7 +175,7 @@ export function createSessionHooks(args: {
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
? safeHook("anthropic-context-window-limit-recovery", () =>
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental }))
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental, pluginConfig }))
: null
const autoUpdateChecker = isHookEnabled("auto-update-checker")

View File

@@ -199,7 +199,7 @@ export async function fetchAvailableModels(
// Handle both string[] (legacy) and object[] (with metadata) formats
const modelId = typeof modelItem === 'string'
? modelItem
: (modelItem as any)?.id
: modelItem?.id
if (modelId) {
modelSet.add(`${providerId}/${modelId}`)

View File

@@ -1,52 +0,0 @@
import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { getOpenCodeCacheDir } from "./data-path"
import { log } from "./logger"
import { isRecord } from "./record-type-guard"
export function addModelsFromModelsJsonCache(
connectedProviders: Set<string>,
modelSet: Set<string>,
): boolean {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
if (!existsSync(cacheFile)) {
log("[fetchAvailableModels] models.json cache file not found, falling back to client")
return false
}
try {
const content = readFileSync(cacheFile, "utf-8")
const data: unknown = JSON.parse(content)
if (!isRecord(data)) {
return false
}
const providerIds = Object.keys(data)
log("[fetchAvailableModels] providers found in models.json", {
count: providerIds.length,
providers: providerIds.slice(0, 10),
})
const previousSize = modelSet.size
for (const providerId of providerIds) {
if (!connectedProviders.has(providerId)) continue
const providerValue = data[providerId]
if (!isRecord(providerValue)) continue
const modelsValue = providerValue["models"]
if (!isRecord(modelsValue)) continue
for (const modelKey of Object.keys(modelsValue)) {
modelSet.add(`${providerId}/${modelKey}`)
}
}
log("[fetchAvailableModels] parsed models from models.json (NO whitelist filtering)", {
count: modelSet.size,
connectedProviders: Array.from(connectedProviders).slice(0, 5),
})
return modelSet.size > previousSize
} catch (err) {
log("[fetchAvailableModels] error", { error: String(err) })
return false
}
}

View File

@@ -1,20 +0,0 @@
import type { ModelListFunction, ProviderListFunction } from "./open-code-client-shapes"
import { isRecord } from "./record-type-guard"
export function getProviderListFunction(client: unknown): ProviderListFunction | null {
if (!isRecord(client)) return null
const provider = client["provider"]
if (!isRecord(provider)) return null
const list = provider["list"]
if (typeof list !== "function") return null
return list as ProviderListFunction
}
export function getModelListFunction(client: unknown): ModelListFunction | null {
if (!isRecord(client)) return null
const model = client["model"]
if (!isRecord(model)) return null
const list = model["list"]
if (typeof list !== "function") return null
return list as ModelListFunction
}

View File

@@ -1,7 +0,0 @@
export type ProviderListResponse = { data?: { connected?: string[] } }
export type ModelListResponse = {
data?: Array<{ id?: string; provider?: string }>
}
export type ProviderListFunction = () => Promise<ProviderListResponse>
export type ModelListFunction = () => Promise<ModelListResponse>

View File

@@ -1,39 +0,0 @@
import { readProviderModelsCache } from "./connected-providers-cache"
import { log } from "./logger"
export function addModelsFromProviderModelsCache(
connectedProviders: Set<string>,
modelSet: Set<string>,
): boolean {
const providerModelsCache = readProviderModelsCache()
if (!providerModelsCache) {
return false
}
const providerCount = Object.keys(providerModelsCache.models).length
if (providerCount === 0) {
log("[fetchAvailableModels] provider-models cache empty, falling back to models.json")
return false
}
log("[fetchAvailableModels] using provider-models cache (whitelist-filtered)")
const previousSize = modelSet.size
for (const [providerId, modelIds] of Object.entries(providerModelsCache.models)) {
if (!connectedProviders.has(providerId)) continue
for (const modelItem of modelIds) {
if (!modelItem) continue
const modelId = typeof modelItem === "string" ? modelItem : modelItem.id
if (modelId) {
modelSet.add(`${providerId}/${modelId}`)
}
}
}
log("[fetchAvailableModels] parsed from provider-models cache", {
count: modelSet.size,
connectedProviders: Array.from(connectedProviders).slice(0, 5),
})
return modelSet.size > previousSize
}

View File

@@ -2,6 +2,11 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import { consumeNewMessages } from "../../shared/session-cursor"
interface SDKMessage {
info?: { role?: string; time?: { created?: number } }
parts?: Array<{ type: string; text?: string; content?: string | Array<{ type: string; text?: string }> }>
}
export async function processMessages(
sessionID: string,
ctx: PluginInput
@@ -20,9 +25,8 @@ export async function processMessages(
// Include both assistant messages AND tool messages
// Tool results (grep, glob, bash output) come from role "tool"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const relevantMessages = messages.filter(
(m: any) => m.info?.role === "assistant" || m.info?.role === "tool"
(m: SDKMessage) => m.info?.role === "assistant" || m.info?.role === "tool"
)
if (relevantMessages.length === 0) {
@@ -34,8 +38,7 @@ export async function processMessages(
log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`)
// Sort by time ascending (oldest first) to process messages in order
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sortedMessages = [...relevantMessages].sort((a: any, b: any) => {
const sortedMessages = [...relevantMessages].sort((a: SDKMessage, b: SDKMessage) => {
const timeA = a.info?.time?.created ?? 0
const timeB = b.info?.time?.created ?? 0
return timeA - timeB
@@ -52,12 +55,11 @@ export async function processMessages(
const extractedContent: string[] = []
for (const message of newMessages) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const part of (message as any).parts ?? []) {
for (const part of message.parts ?? []) {
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
if ((part.type === "text" || part.type === "reasoning") && part.text) {
extractedContent.push(part.text)
} else if (part.type === "tool_result") {
} else if ((part.type as string) === "tool_result") {
// Tool results contain the actual output from tool calls
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
if (typeof toolResult.content === "string" && toolResult.content) {

View File

@@ -1,76 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
function getSessionStatusType(statusResult: unknown, sessionID: string): string | null {
if (typeof statusResult !== "object" || statusResult === null) return null
if (!("data" in statusResult)) return null
const data = (statusResult as { data?: unknown }).data
if (typeof data !== "object" || data === null) return null
const record = data as Record<string, unknown>
const entry = record[sessionID]
if (typeof entry !== "object" || entry === null) return null
const typeValue = (entry as Record<string, unknown>)["type"]
return typeof typeValue === "string" ? typeValue : null
}
function getMessagesArray(result: unknown): unknown[] {
if (Array.isArray(result)) return result
if (typeof result !== "object" || result === null) return []
if (!("data" in result)) return []
const data = (result as { data?: unknown }).data
return Array.isArray(data) ? data : []
}
export async function waitForSessionCompletion(
ctx: PluginInput,
options: {
sessionID: string
abortSignal?: AbortSignal
maxPollTimeMs: number
pollIntervalMs: number
stabilityRequired: number
},
): Promise<{ ok: true } | { ok: false; reason: "aborted" | "timeout" }> {
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < options.maxPollTimeMs) {
if (options.abortSignal?.aborted) {
log("[call_omo_agent] Aborted by user")
return { ok: false, reason: "aborted" }
}
await new Promise<void>((resolve) => {
setTimeout(resolve, options.pollIntervalMs)
})
const statusResult = await ctx.client.session.status()
const sessionStatusType = getSessionStatusType(statusResult, options.sessionID)
if (sessionStatusType && sessionStatusType !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
const messagesCheck = await ctx.client.session.messages({
path: { id: options.sessionID },
})
const currentMsgCount = getMessagesArray(messagesCheck).length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= options.stabilityRequired) {
log("[call_omo_agent] Session complete", { messageCount: currentMsgCount })
return { ok: true }
}
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
log("[call_omo_agent] Timeout reached")
return { ok: false, reason: "timeout" }
}

View File

@@ -39,7 +39,7 @@ export async function createOrGetSession(
body: {
parentID: toolContext.sessionID,
title: `${args.description} (@${args.subagent_type} subagent)`,
} as any,
} as Record<string, unknown>,
query: {
directory: parentDirectory,
},

View File

@@ -1,93 +0,0 @@
import { consumeNewMessages, type CursorMessage } from "../../shared/session-cursor"
type SessionMessagePart = {
type: string
text?: string
content?: unknown
}
export type SessionMessage = CursorMessage & {
info?: CursorMessage["info"] & { role?: string }
parts?: SessionMessagePart[]
}
function getRole(message: SessionMessage): string | null {
const role = message.info?.role
return typeof role === "string" ? role : null
}
function getCreatedTime(message: SessionMessage): number {
const time = message.info?.time
if (typeof time === "number") return time
if (typeof time === "string") return Number(time) || 0
const created = time?.created
if (typeof created === "number") return created
if (typeof created === "string") return Number(created) || 0
return 0
}
function isRelevantRole(role: string | null): boolean {
return role === "assistant" || role === "tool"
}
function extractTextFromParts(parts: SessionMessagePart[] | undefined): string[] {
if (!parts) return []
const extracted: string[] = []
for (const part of parts) {
if ((part.type === "text" || part.type === "reasoning") && part.text) {
extracted.push(part.text)
continue
}
if (part.type !== "tool_result") continue
const content = part.content
if (typeof content === "string" && content) {
extracted.push(content)
continue
}
if (!Array.isArray(content)) continue
for (const block of content) {
if (typeof block !== "object" || block === null) continue
const record = block as Record<string, unknown>
const typeValue = record["type"]
const textValue = record["text"]
if (
(typeValue === "text" || typeValue === "reasoning") &&
typeof textValue === "string" &&
textValue
) {
extracted.push(textValue)
}
}
}
return extracted
}
export function extractNewSessionOutput(
sessionID: string,
messages: SessionMessage[],
): { output: string; hasNewOutput: boolean } {
const relevantMessages = messages.filter((message) =>
isRelevantRole(getRole(message)),
)
if (relevantMessages.length === 0) {
return { output: "", hasNewOutput: false }
}
const sortedMessages = [...relevantMessages].sort(
(a, b) => getCreatedTime(a) - getCreatedTime(b),
)
const newMessages = consumeNewMessages(sessionID, sortedMessages)
if (newMessages.length === 0) {
return { output: "", hasNewOutput: false }
}
const chunks: string[] = []
for (const message of newMessages) {
chunks.push(...extractTextFromParts(message.parts))
}
const output = chunks.filter((text) => text.length > 0).join("\n\n")
return { output, hasNewOutput: output.length > 0 }
}

View File

@@ -1,27 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log, getAgentToolRestrictions } from "../../shared"
export async function promptSubagentSession(
ctx: PluginInput,
options: { sessionID: string; agent: string; prompt: string },
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
await ctx.client.session.promptAsync({
path: { id: options.sessionID },
body: {
agent: options.agent,
tools: {
...getAgentToolRestrictions(options.agent),
task: false,
question: false,
},
parts: [{ type: "text", text: options.prompt }],
},
})
return { ok: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
log("[call_omo_agent] Prompt error", { error: errorMessage })
return { ok: false, error: errorMessage }
}
}

View File

@@ -6,6 +6,10 @@ import { createOrGetSession } from "./session-creator"
import { waitForCompletion } from "./completion-poller"
import { processMessages } from "./message-processor"
type SessionWithPromptAsync = {
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
}
type ExecuteSyncDeps = {
createOrGetSession: typeof createOrGetSession
waitForCompletion: typeof waitForCompletion
@@ -41,7 +45,7 @@ export async function executeSync(
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
try {
await (ctx.client.session as any).promptAsync({
await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({
path: { id: sessionID },
body: {
agent: args.subagent_type,

View File

@@ -13,7 +13,7 @@ export async function createSyncSession(
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agentToUse} subagent)`,
} as any,
} as Record<string, unknown>,
query: {
directory: parentDirectory,
},

View File

@@ -1,5 +1,5 @@
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[A-Z]{2}:/
const DIFF_PLUS_RE = /^[+-](?![+-])/
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}:/
const DIFF_PLUS_RE = /^[+](?![+])/
function equalsIgnoringWhitespace(a: string, b: string): boolean {
if (a === b) return true

View File

@@ -21,7 +21,7 @@ describe("computeLineHash", () => {
expect(hash1).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/)
})
it("produces different hashes for same content on different lines", () => {
it("produces same hashes for significant content on different lines", () => {
//#given
const content = "function hello() {"
@@ -29,6 +29,18 @@ describe("computeLineHash", () => {
const hash1 = computeLineHash(1, content)
const hash2 = computeLineHash(2, content)
//#then
expect(hash1).toBe(hash2)
})
it("mixes line number for non-significant lines", () => {
//#given
const punctuationOnly = "{}"
//#when
const hash1 = computeLineHash(1, punctuationOnly)
const hash2 = computeLineHash(2, punctuationOnly)
//#then
expect(hash1).not.toBe(hash2)
})

View File

@@ -1,10 +1,12 @@
import { HASHLINE_DICT } from "./constants"
import { createHashlineChunkFormatter } from "./hashline-chunk-formatter"
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u
export function computeLineHash(lineNumber: number, content: string): string {
const stripped = content.replace(/\s+/g, "")
const hashInput = `${lineNumber}:${stripped}`
const hash = Bun.hash.xxHash32(hashInput)
const stripped = content.endsWith("\r") ? content.slice(0, -1).replace(/\s+/g, "") : content.replace(/\s+/g, "")
const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber
const hash = Bun.hash.xxHash32(stripped, seed)
const index = hash % 256
return HASHLINE_DICT[index]
}

View File

@@ -1,14 +1,14 @@
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { applyHashlineEditsWithReport } from "./edit-operations"
import { countLineDiffs, generateUnifiedDiff, toHashlineContent } from "./diff-utils"
import { countLineDiffs, generateUnifiedDiff } from "./diff-utils"
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
import { generateHashlineDiff } from "./hashline-edit-diff"
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
import type { HashlineEdit } from "./types"
interface HashlineEditArgs {
filePath: string
edits: HashlineEdit[]
edits: RawHashlineEdit[]
delete?: boolean
rename?: string
}
@@ -44,6 +44,17 @@ function buildSuccessMeta(
) {
const unifiedDiff = generateUnifiedDiff(beforeContent, afterContent, effectivePath)
const { additions, deletions } = countLineDiffs(beforeContent, afterContent)
const beforeLines = beforeContent.split("\n")
const afterLines = afterContent.split("\n")
const maxLength = Math.max(beforeLines.length, afterLines.length)
let firstChangedLine: number | undefined
for (let index = 0; index < maxLength; index += 1) {
if ((beforeLines[index] ?? "") !== (afterLines[index] ?? "")) {
firstChangedLine = index + 1
break
}
}
return {
title: effectivePath,
@@ -54,6 +65,7 @@ function buildSuccessMeta(
diff: unifiedDiff,
noopEdits,
deduplicatedEdits,
firstChangedLine,
filediff: {
file: effectivePath,
path: effectivePath,
@@ -71,14 +83,17 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
try {
const metadataContext = context as ToolContextWithMetadata
const filePath = args.filePath
const { edits, delete: deleteMode, rename } = args
const { delete: deleteMode, rename } = args
if (!deleteMode && (!args.edits || !Array.isArray(args.edits) || args.edits.length === 0)) {
return "Error: edits parameter must be a non-empty array"
}
const edits = deleteMode ? [] : normalizeHashlineEdits(args.edits)
if (deleteMode && rename) {
return "Error: delete and rename cannot be used together"
}
if (!deleteMode && (!edits || !Array.isArray(edits) || edits.length === 0)) {
return "Error: edits parameter must be a non-empty array"
}
if (deleteMode && edits.length > 0) {
return "Error: delete mode requires edits to be an empty array"
}
@@ -100,6 +115,15 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
const applyResult = applyHashlineEditsWithReport(oldEnvelope.content, edits)
const canonicalNewContent = applyResult.content
if (canonicalNewContent === oldEnvelope.content && !rename) {
let diagnostic = `No changes made to ${filePath}. The edits produced identical content.`
if (applyResult.noopEdits > 0) {
diagnostic += ` No-op edits: ${applyResult.noopEdits}. Re-read the file and provide content that differs from current lines.`
}
return `Error: ${diagnostic}`
}
const writeContent = restoreFileText(canonicalNewContent, oldEnvelope)
await Bun.write(filePath, writeContent)
@@ -110,8 +134,6 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
}
const effectivePath = rename && rename !== filePath ? rename : filePath
const diff = generateHashlineDiff(oldEnvelope.content, canonicalNewContent, effectivePath)
const newHashlined = toHashlineContent(canonicalNewContent)
const meta = buildSuccessMeta(
effectivePath,
oldEnvelope.content,
@@ -129,13 +151,11 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
storeToolMetadata(context.sessionID, callID, meta)
}
return `Successfully applied ${edits.length} edit(s) to ${effectivePath}
No-op edits: ${applyResult.noopEdits}, deduplicated edits: ${applyResult.deduplicatedEdits}
if (rename && rename !== filePath) {
return `Moved ${filePath} to ${rename}`
}
${diff}
Updated file (LINE#ID:content):
${newHashlined}`
return `Updated ${effectivePath}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.toLowerCase().includes("hash")) {

View File

@@ -0,0 +1,142 @@
import type { HashlineEdit } from "./types"
export interface RawHashlineEdit {
type?:
| "set_line"
| "replace_lines"
| "insert_after"
| "insert_before"
| "insert_between"
| "replace"
| "append"
| "prepend"
line?: string
start_line?: string
end_line?: string
after_line?: string
before_line?: string
text?: string | string[]
old_text?: string
new_text?: string | string[]
}
function firstDefined(...values: Array<string | undefined>): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.trim() !== "") return value
}
return undefined
}
function requireText(edit: RawHashlineEdit, index: number): string | string[] {
const text = edit.text ?? edit.new_text
if (text === undefined) {
throw new Error(`Edit ${index}: text is required for ${edit.type ?? "unknown"}`)
}
return text
}
function requireLine(anchor: string | undefined, index: number, op: string): string {
if (!anchor) {
throw new Error(`Edit ${index}: ${op} requires at least one anchor line reference`)
}
return anchor
}
export function normalizeHashlineEdits(rawEdits: RawHashlineEdit[]): HashlineEdit[] {
const normalized: HashlineEdit[] = []
for (let index = 0; index < rawEdits.length; index += 1) {
const edit = rawEdits[index] ?? {}
const type = edit.type
switch (type) {
case "set_line": {
const anchor = firstDefined(edit.line, edit.start_line, edit.end_line, edit.after_line, edit.before_line)
normalized.push({
type: "set_line",
line: requireLine(anchor, index, "set_line"),
text: requireText(edit, index),
})
break
}
case "replace_lines": {
const startAnchor = firstDefined(edit.start_line, edit.line, edit.after_line)
const endAnchor = firstDefined(edit.end_line, edit.line, edit.before_line)
if (!startAnchor && !endAnchor) {
throw new Error(`Edit ${index}: replace_lines requires start_line or end_line`)
}
if (startAnchor && endAnchor) {
normalized.push({
type: "replace_lines",
start_line: startAnchor,
end_line: endAnchor,
text: requireText(edit, index),
})
} else {
normalized.push({
type: "set_line",
line: requireLine(startAnchor ?? endAnchor, index, "replace_lines"),
text: requireText(edit, index),
})
}
break
}
case "insert_after": {
const anchor = firstDefined(edit.line, edit.after_line, edit.end_line, edit.start_line)
normalized.push({
type: "insert_after",
line: requireLine(anchor, index, "insert_after"),
text: requireText(edit, index),
})
break
}
case "insert_before": {
const anchor = firstDefined(edit.line, edit.before_line, edit.start_line, edit.end_line)
normalized.push({
type: "insert_before",
line: requireLine(anchor, index, "insert_before"),
text: requireText(edit, index),
})
break
}
case "insert_between": {
const afterLine = firstDefined(edit.after_line, edit.line, edit.start_line)
const beforeLine = firstDefined(edit.before_line, edit.end_line, edit.line)
normalized.push({
type: "insert_between",
after_line: requireLine(afterLine, index, "insert_between.after_line"),
before_line: requireLine(beforeLine, index, "insert_between.before_line"),
text: requireText(edit, index),
})
break
}
case "replace": {
const oldText = edit.old_text
const newText = edit.new_text ?? edit.text
if (!oldText) {
throw new Error(`Edit ${index}: replace requires old_text`)
}
if (newText === undefined) {
throw new Error(`Edit ${index}: replace requires new_text or text`)
}
normalized.push({ type: "replace", old_text: oldText, new_text: newText })
break
}
case "append": {
normalized.push({ type: "append", text: requireText(edit, index) })
break
}
case "prepend": {
normalized.push({ type: "prepend", text: requireText(edit, index) })
break
}
default: {
throw new Error(`Edit ${index}: unsupported type "${String(type)}"`)
}
}
}
return normalized
}

View File

@@ -48,9 +48,7 @@ describe("createHashlineEditTool", () => {
//#then
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nmodified line2\nline3")
expect(result).toContain("Successfully")
expect(result).toContain("Updated file (LINE#ID:content)")
expect(result).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}:modified line2/)
expect(result).toBe(`Updated ${filePath}`)
})
it("applies replace_lines and insert_after", async () => {
@@ -184,7 +182,7 @@ describe("createHashlineEditTool", () => {
const line2 = computeLineHash(2, "line2")
//#when
await tool.execute(
const result = await tool.execute(
{
filePath,
rename: renamedPath,
@@ -196,6 +194,7 @@ describe("createHashlineEditTool", () => {
//#then
expect(fs.existsSync(filePath)).toBe(false)
expect(fs.readFileSync(renamedPath, "utf-8")).toBe("line1\nline2-updated")
expect(result).toBe(`Moved ${filePath} to ${renamedPath}`)
})
it("supports file delete mode", async () => {
@@ -237,7 +236,46 @@ describe("createHashlineEditTool", () => {
//#then
expect(fs.existsSync(filePath)).toBe(true)
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline2")
expect(result).toContain("Successfully applied 2 edit(s)")
expect(result).toBe(`Updated ${filePath}`)
})
it("accepts replace_lines with one anchor and downgrades to set_line", async () => {
//#given
const filePath = path.join(tempDir, "degrade.txt")
fs.writeFileSync(filePath, "line1\nline2\nline3")
const line2Hash = computeLineHash(2, "line2")
//#when
const result = await tool.execute(
{
filePath,
edits: [{ type: "replace_lines", start_line: `2#${line2Hash}`, text: ["line2-updated"] }],
},
createMockContext(),
)
//#then
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline2-updated\nline3")
expect(result).toBe(`Updated ${filePath}`)
})
it("accepts insert_after using after_line alias", async () => {
//#given
const filePath = path.join(tempDir, "alias.txt")
fs.writeFileSync(filePath, "line1\nline2")
const line1Hash = computeLineHash(1, "line1")
//#when
await tool.execute(
{
filePath,
edits: [{ type: "insert_after", after_line: `1#${line1Hash}`, text: ["inserted"] }],
},
createMockContext(),
)
//#then
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\ninserted\nline2")
})
it("preserves BOM and CRLF through hashline_edit", async () => {

View File

@@ -1,11 +1,11 @@
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool"
import type { HashlineEdit } from "./types"
import { executeHashlineEditTool } from "./hashline-edit-executor"
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
import type { RawHashlineEdit } from "./normalize-edits"
interface HashlineEditArgs {
filePath: string
edits: HashlineEdit[]
edits: RawHashlineEdit[]
delete?: boolean
rename?: string
}
@@ -19,64 +19,34 @@ export function createHashlineEditTool(): ToolDefinition {
rename: tool.schema.string().optional().describe("Rename output file path after edits"),
edits: tool.schema
.array(
tool.schema.union([
tool.schema.object({
type: tool.schema.literal("set_line"),
line: tool.schema.string().describe("Line reference in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("New content for the line (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("replace_lines"),
start_line: tool.schema.string().describe("Start line in LINE#ID format"),
end_line: tool.schema.string().describe("End line in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("New content to replace the range (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("insert_after"),
line: tool.schema.string().describe("Line reference in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to insert after the line (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("insert_before"),
line: tool.schema.string().describe("Line reference in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to insert before the line (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("insert_between"),
after_line: tool.schema.string().describe("After line in LINE#ID format"),
before_line: tool.schema.string().describe("Before line in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to insert between anchor lines (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("replace"),
old_text: tool.schema.string().describe("Text to find"),
new_text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Replacement text (string or string[] for multiline)"),
}),
tool.schema.object({
type: tool.schema.literal("append"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to append at EOF; also creates missing file"),
}),
tool.schema.object({
type: tool.schema.literal("prepend"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.describe("Content to prepend at BOF; also creates missing file"),
}),
])
tool.schema.object({
type: tool.schema
.union([
tool.schema.literal("set_line"),
tool.schema.literal("replace_lines"),
tool.schema.literal("insert_after"),
tool.schema.literal("insert_before"),
tool.schema.literal("insert_between"),
tool.schema.literal("replace"),
tool.schema.literal("append"),
tool.schema.literal("prepend"),
])
.describe("Edit operation type"),
line: tool.schema.string().optional().describe("Anchor line in LINE#ID format"),
start_line: tool.schema.string().optional().describe("Range start in LINE#ID format"),
end_line: tool.schema.string().optional().describe("Range end in LINE#ID format"),
after_line: tool.schema.string().optional().describe("Insert boundary (after) in LINE#ID format"),
before_line: tool.schema.string().optional().describe("Insert boundary (before) in LINE#ID format"),
text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.optional()
.describe("Operation content"),
old_text: tool.schema.string().optional().describe("Legacy text replacement source"),
new_text: tool.schema
.union([tool.schema.string(), tool.schema.array(tool.schema.string())])
.optional()
.describe("Legacy text replacement target"),
})
)
.describe("Array of edit operations to apply (empty when delete=true)"),
},