Compare commits
1 Commits
v3.8.4
...
feature/ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48907ae2e |
@@ -82,9 +82,6 @@
|
||||
"hashline_edit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"model_fallback": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -291,18 +288,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -510,18 +495,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -729,18 +702,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -948,18 +909,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1167,18 +1116,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1386,18 +1323,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1605,18 +1530,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1824,18 +1737,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2043,18 +1944,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2262,18 +2151,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2481,18 +2358,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2700,18 +2565,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2919,18 +2772,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -3138,18 +2979,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"compaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
28
bun.lock
28
bun.lock
@@ -28,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.8.1",
|
||||
"oh-my-opencode-darwin-x64": "3.8.1",
|
||||
"oh-my-opencode-linux-arm64": "3.8.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.1",
|
||||
"oh-my-opencode-linux-x64": "3.8.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.1",
|
||||
"oh-my-opencode-windows-x64": "3.8.1",
|
||||
"oh-my-opencode-darwin-arm64": "3.7.4",
|
||||
"oh-my-opencode-darwin-x64": "3.7.4",
|
||||
"oh-my-opencode-linux-arm64": "3.7.4",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.7.4",
|
||||
"oh-my-opencode-linux-x64": "3.7.4",
|
||||
"oh-my-opencode-linux-x64-musl": "3.7.4",
|
||||
"oh-my-opencode-windows-x64": "3.7.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -228,19 +228,19 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vbtS0WUFOZpufKzlX2G83fIDry3rpiXej8zNuXNCkx7hF34rK04rj0zeBH9dL+kdNV0Ys0Wl1rR1Mjto28UcAw=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.7.4", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-0m84UiVlOC2gLSFIOTmCsxFCB9CmyWV9vGPYqfBFLoyDJmedevU3R5N4ze54W7jv4HSSxz02Zwr+QF5rkQANoA=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gLz6dLNg9hr7roqBjaqlxta6+XYCs032/FiE0CiwypIBtYOq5EAgDVJ95JY5DQ2M+3Un028d50yMfwsfNfGlSw=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.7.4", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z2dQy8jmc6DuwbN9bafhOwjZBkAkTWlfLAz1tG6xVzMqTcp4YOrzrHFOBRNeFKpOC/x7yUpO3sq/YNCclloelw=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-teAIuHlR5xOAoUmA+e0bGzy3ikgIr+nCdyOPwHYm8jIp0aBUWAqbcdoQLeNTgenWpoM8vhHk+2xh4WcCeQzjEA=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-TZIsK6Dl6yX6pSTocls91bjnvoY/6/kiGnmgdsoDKcPYZ7XuBQaJwH0dK7t9/sxuDI+wKhmtrmLwKSoYOIqsRw=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VzBEq1H5dllEloouIoLdbw1icNUW99qmvErFrNj66mX42DNXK+f1zTtvBG8U6eeFfUBRRJoUjdCsvO65f8BkFA=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.7.4", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UwPOoQP0+1eCKP/XTDsnLJDK5jayiL4VrKz0lfRRRojl1FWvInmQumnDnluvnxW6knU7dFM3yDddlZYG6tEgcw=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8hDcb8s+wdQpQObSmiyaaTV0P/js2Bs9Lu+HmzrkKjuMLXXj/Gk7K0kKWMoEnMbMGfj86GfBHHIWmu9juI/SjA=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+TeA0Bs5wK9EMfKiEEFfyfVqdBDUjDzN8POF8JJibN0GPy1oNIGGEWIJG2cvC5onpnYEvl448vkFbkCUK0g9SQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-idyH5bdYn7wrLkIkYr83omN83E2BjA/9DUHCX2we8VXbhDVbBgmMpUg8B8nKnd5NK/SyLHgRs5QqQJw8XBC0cQ=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.7.4", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-YzX6wFtk8RoTHkAZkfLCVyCU4yjN8D7agj/jhOnFKW50fZYa8zX+/4KLZx0IfanVpXTgrs3iiuKoa87KLDfCxQ=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-O30L1PUF9aq1vSOyadcXQOLnDFSTvYn6cGd5huh0LAK/us0hGezoahtXegMdFtDXPIIREJlkRQhyJiafza7YgA=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.7.4", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-x39M2eFJI6pqv4go5Crf1H2SbPGFmXHIDNtbsSa5nRNcrqTisLrYGW8uXpOrqjntBeTAUBdwZmmoy6zgxHsz8w=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.1",
|
||||
"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.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"
|
||||
"oh-my-opencode-darwin-arm64": "3.8.1",
|
||||
"oh-my-opencode-darwin-x64": "3.8.1",
|
||||
"oh-my-opencode-linux-arm64": "3.8.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.8.1",
|
||||
"oh-my-opencode-linux-x64": "3.8.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.8.1",
|
||||
"oh-my-opencode-windows-x64": "3.8.1"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.8.4",
|
||||
"version": "3.8.1",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1679,22 +1679,6 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,13 +6,12 @@
|
||||
*
|
||||
* Routing:
|
||||
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
|
||||
* 2. Gemini models (google/*, google-vertex/*) → gemini.ts (Gemini-optimized)
|
||||
* 3. Default (Claude, etc.) → default.ts (Claude-optimized)
|
||||
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode, AgentPromptMetadata } from "../types"
|
||||
import { isGptModel, isGeminiModel } from "../types"
|
||||
import { isGptModel } from "../types"
|
||||
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
|
||||
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
@@ -21,7 +20,6 @@ import { createAgentToolRestrictions } from "../../shared/permission-compat"
|
||||
|
||||
import { getDefaultAtlasPrompt } from "./default"
|
||||
import { getGptAtlasPrompt } from "./gpt"
|
||||
import { getGeminiAtlasPrompt } from "./gemini"
|
||||
import {
|
||||
getCategoryDescription,
|
||||
buildAgentSelectionSection,
|
||||
@@ -32,7 +30,7 @@ import {
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
|
||||
export type AtlasPromptSource = "default" | "gpt" | "gemini"
|
||||
export type AtlasPromptSource = "default" | "gpt"
|
||||
|
||||
/**
|
||||
* Determines which Atlas prompt to use based on model.
|
||||
@@ -41,9 +39,6 @@ export function getAtlasPromptSource(model?: string): AtlasPromptSource {
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
if (model && isGeminiModel(model)) {
|
||||
return "gemini"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
@@ -63,8 +58,6 @@ export function getAtlasPrompt(model?: string): string {
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return getGptAtlasPrompt()
|
||||
case "gemini":
|
||||
return getGeminiAtlasPrompt()
|
||||
case "default":
|
||||
default:
|
||||
return getDefaultAtlasPrompt()
|
||||
|
||||
@@ -1,2 +1,14 @@
|
||||
export { createAtlasAgent, atlasPromptMetadata } from "./agent"
|
||||
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
|
||||
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
|
||||
export {
|
||||
getCategoryDescription,
|
||||
buildAgentSelectionSection,
|
||||
buildCategorySection,
|
||||
buildSkillsSection,
|
||||
buildDecisionMatrix,
|
||||
} from "./prompt-section-builder"
|
||||
|
||||
export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
|
||||
export type { AtlasPromptSource, OrchestratorContext } from "./agent"
|
||||
|
||||
export { isGptModel } from "../types"
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
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"
|
||||
|
||||
@@ -2,5 +2,15 @@ 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"
|
||||
|
||||
// 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"
|
||||
|
||||
@@ -5,8 +5,7 @@ import { PROMETHEUS_HIGH_ACCURACY_MODE } from "./high-accuracy-mode"
|
||||
import { PROMETHEUS_PLAN_TEMPLATE } from "./plan-template"
|
||||
import { PROMETHEUS_BEHAVIORAL_SUMMARY } from "./behavioral-summary"
|
||||
import { getGptPrometheusPrompt } from "./gpt"
|
||||
import { getGeminiPrometheusPrompt } from "./gemini"
|
||||
import { isGptModel, isGeminiModel } from "../types"
|
||||
import { isGptModel } from "../types"
|
||||
|
||||
/**
|
||||
* Combined Prometheus system prompt (Claude-optimized, default).
|
||||
@@ -31,7 +30,7 @@ export const PROMETHEUS_PERMISSION = {
|
||||
question: "allow" as const,
|
||||
}
|
||||
|
||||
export type PrometheusPromptSource = "default" | "gpt" | "gemini"
|
||||
export type PrometheusPromptSource = "default" | "gpt"
|
||||
|
||||
/**
|
||||
* Determines which Prometheus prompt to use based on model.
|
||||
@@ -40,16 +39,12 @@ export function getPrometheusPromptSource(model?: string): PrometheusPromptSourc
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
if (model && isGeminiModel(model)) {
|
||||
return "gemini"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate Prometheus prompt based on model.
|
||||
* GPT models → GPT-5.2 optimized prompt (XML-tagged, principle-driven)
|
||||
* Gemini models → Gemini-optimized prompt (aggressive tool-call enforcement, thinking checkpoints)
|
||||
* Default (Claude, etc.) → Claude-optimized prompt (modular sections)
|
||||
*/
|
||||
export function getPrometheusPrompt(model?: string): string {
|
||||
@@ -58,8 +53,6 @@ export function getPrometheusPrompt(model?: string): string {
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return getGptPrometheusPrompt()
|
||||
case "gemini":
|
||||
return getGeminiPrometheusPrompt()
|
||||
case "default":
|
||||
default:
|
||||
return PROMETHEUS_SYSTEM_PROMPT
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
* - 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.
|
||||
@@ -79,39 +77,3 @@ 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>`;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
*
|
||||
* Routing:
|
||||
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
|
||||
* 2. Gemini models (google/*, google-vertex/*) -> gemini.ts (Gemini-optimized)
|
||||
* 3. Default (Claude, etc.) -> default.ts (Claude-optimized)
|
||||
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "../types"
|
||||
import { isGptModel, isGeminiModel } from "../types"
|
||||
import { isGptModel } from "../types"
|
||||
import type { AgentOverrideConfig } from "../../config/schema"
|
||||
import {
|
||||
createAgentToolRestrictions,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
|
||||
import { buildDefaultSisyphusJuniorPrompt } from "./default"
|
||||
import { buildGptSisyphusJuniorPrompt } from "./gpt"
|
||||
import { buildGeminiSisyphusJuniorPrompt } from "./gemini"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
@@ -34,7 +32,7 @@ export const SISYPHUS_JUNIOR_DEFAULTS = {
|
||||
temperature: 0.1,
|
||||
} as const
|
||||
|
||||
export type SisyphusJuniorPromptSource = "default" | "gpt" | "gemini"
|
||||
export type SisyphusJuniorPromptSource = "default" | "gpt"
|
||||
|
||||
/**
|
||||
* Determines which Sisyphus-Junior prompt to use based on model.
|
||||
@@ -43,9 +41,6 @@ export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPro
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
if (model && isGeminiModel(model)) {
|
||||
return "gemini"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
@@ -62,8 +57,6 @@ export function buildSisyphusJuniorPrompt(
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
case "gemini":
|
||||
return buildGeminiSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
case "default":
|
||||
default:
|
||||
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { buildDefaultSisyphusJuniorPrompt } from "./default"
|
||||
export { buildGptSisyphusJuniorPrompt } from "./gpt"
|
||||
export { buildGeminiSisyphusJuniorPrompt } from "./gemini"
|
||||
|
||||
export {
|
||||
SISYPHUS_JUNIOR_DEFAULTS,
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk";
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types";
|
||||
import { isGptModel, isGeminiModel } from "./types";
|
||||
import {
|
||||
buildGeminiToolMandate,
|
||||
buildGeminiDelegationOverride,
|
||||
buildGeminiVerificationOverride,
|
||||
buildGeminiIntentGateEnforcement,
|
||||
} from "./sisyphus-gemini-overlays";
|
||||
import { isGptModel } from "./types";
|
||||
|
||||
const MODE: AgentMode = "primary";
|
||||
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
@@ -554,7 +548,7 @@ export function createSisyphusAgent(
|
||||
const tools = availableToolNames ? categorizeTools(availableToolNames) : [];
|
||||
const skills = availableSkills ?? [];
|
||||
const categories = availableCategories ?? [];
|
||||
let prompt = availableAgents
|
||||
const prompt = availableAgents
|
||||
? buildDynamicSisyphusPrompt(
|
||||
model,
|
||||
availableAgents,
|
||||
@@ -565,15 +559,6 @@ export function createSisyphusAgent(
|
||||
)
|
||||
: buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem);
|
||||
|
||||
if (isGeminiModel(model)) {
|
||||
prompt = prompt.replace(
|
||||
"</intent_verbalization>",
|
||||
`</intent_verbalization>\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}`
|
||||
);
|
||||
prompt += "\n" + buildGeminiDelegationOverride();
|
||||
prompt += "\n" + buildGeminiVerificationOverride();
|
||||
}
|
||||
|
||||
const permission = {
|
||||
question: "allow",
|
||||
call_omo_agent: "deny",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { isGptModel, isGeminiModel } from "./types";
|
||||
import { isGptModel } from "./types";
|
||||
|
||||
describe("isGptModel", () => {
|
||||
test("standard openai provider models", () => {
|
||||
@@ -47,47 +47,3 @@ describe("isGptModel", () => {
|
||||
expect(isGptModel("opencode/claude-opus-4-6")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGeminiModel", () => {
|
||||
test("#given google provider models #then returns true", () => {
|
||||
expect(isGeminiModel("google/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("google/gemini-3-flash")).toBe(true);
|
||||
expect(isGeminiModel("google/gemini-2.5-pro")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given google-vertex provider models #then returns true", () => {
|
||||
expect(isGeminiModel("google-vertex/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given github copilot gemini models #then returns true", () => {
|
||||
expect(isGeminiModel("github-copilot/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("github-copilot/gemini-3-flash")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given litellm proxied gemini models #then returns true", () => {
|
||||
expect(isGeminiModel("litellm/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("litellm/gemini-3-flash")).toBe(true);
|
||||
expect(isGeminiModel("litellm/gemini-2.5-pro")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given other proxied gemini models #then returns true", () => {
|
||||
expect(isGeminiModel("custom-provider/gemini-3-pro")).toBe(true);
|
||||
expect(isGeminiModel("ollama/gemini-3-flash")).toBe(true);
|
||||
});
|
||||
|
||||
test("#given gpt models #then returns false", () => {
|
||||
expect(isGeminiModel("openai/gpt-5.2")).toBe(false);
|
||||
expect(isGeminiModel("openai/o3-mini")).toBe(false);
|
||||
expect(isGeminiModel("litellm/gpt-4o")).toBe(false);
|
||||
});
|
||||
|
||||
test("#given claude models #then returns false", () => {
|
||||
expect(isGeminiModel("anthropic/claude-opus-4-6")).toBe(false);
|
||||
expect(isGeminiModel("anthropic/claude-sonnet-4-6")).toBe(false);
|
||||
});
|
||||
|
||||
test("#given opencode provider #then returns false", () => {
|
||||
expect(isGeminiModel("opencode/claude-opus-4-6")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,19 +80,6 @@ export function isGptModel(model: string): boolean {
|
||||
return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix))
|
||||
}
|
||||
|
||||
const GEMINI_PROVIDERS = ["google/", "google-vertex/"]
|
||||
|
||||
export function isGeminiModel(model: string): boolean {
|
||||
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix)))
|
||||
return true
|
||||
|
||||
if (model.startsWith("github-copilot/") && extractModelName(model).toLowerCase().startsWith("gemini"))
|
||||
return true
|
||||
|
||||
const modelName = extractModelName(model).toLowerCase()
|
||||
return modelName.startsWith("gemini-")
|
||||
}
|
||||
|
||||
export type BuiltinAgentName =
|
||||
| "sisyphus"
|
||||
| "hephaestus"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { stripAnsi } from "./format-shared"
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||
import type { DoctorResult } from "./types"
|
||||
|
||||
function createDoctorResult(): DoctorResult {
|
||||
@@ -40,122 +39,78 @@ function createDoctorResult(): DoctorResult {
|
||||
}
|
||||
}
|
||||
|
||||
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("formatter", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("#given status mode", () => {
|
||||
it("renders system version line", async () => {
|
||||
describe("formatDoctorOutput", () => {
|
||||
it("dispatches to default formatter for default mode", async () => {
|
||||
//#given
|
||||
const result = createDoctorResult()
|
||||
const { formatDoctorOutput } = await import(`./formatter?status-ver-${Date.now()}`)
|
||||
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()}`)
|
||||
|
||||
//#when
|
||||
const output = stripAnsi(formatDoctorOutput(result, "status"))
|
||||
const output = formatDoctorOutput(createDoctorResult(), "default")
|
||||
|
||||
//#then
|
||||
expect(output).toContain("1.0.200 · 3.4.0 · Bun 1.2.0")
|
||||
expect(output).toBe("default-output")
|
||||
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
|
||||
expect(formatStatusMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("renders tool and MCP info", async () => {
|
||||
it("dispatches to status formatter for status mode", async () => {
|
||||
//#given
|
||||
const result = createDoctorResult()
|
||||
const { formatDoctorOutput } = await import(`./formatter?status-tools-${Date.now()}`)
|
||||
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()}`)
|
||||
|
||||
//#when
|
||||
const output = stripAnsi(formatDoctorOutput(result, "status"))
|
||||
const output = formatDoctorOutput(createDoctorResult(), "status")
|
||||
|
||||
//#then
|
||||
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")
|
||||
expect(output).toBe("status-output")
|
||||
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatStatusMock).toHaveBeenCalledTimes(1)
|
||||
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("shows check summary counts", async () => {
|
||||
it("dispatches to verbose formatter for verbose mode", async () => {
|
||||
//#given
|
||||
const result = createDoctorResult()
|
||||
const { formatDoctorOutput } = await import(`./formatter?verbose-summary-${Date.now()}`)
|
||||
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()}`)
|
||||
|
||||
//#when
|
||||
const output = stripAnsi(formatDoctorOutput(result, "verbose"))
|
||||
const output = formatDoctorOutput(createDoctorResult(), "verbose")
|
||||
|
||||
//#then
|
||||
expect(output).toContain("1 passed")
|
||||
expect(output).toContain("0 failed")
|
||||
expect(output).toContain("1 warnings")
|
||||
expect(output).toBe("verbose-output")
|
||||
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatStatusMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatJsonOutput", () => {
|
||||
it("returns valid JSON", async () => {
|
||||
it("returns valid JSON payload", 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)
|
||||
@@ -164,6 +119,7 @@ describe("formatDoctorOutput", () => {
|
||||
//#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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function resolveSession(options: {
|
||||
permission: [
|
||||
{ permission: "question", action: "deny" as const, pattern: "*" },
|
||||
],
|
||||
} as Record<string, unknown>,
|
||||
} as any,
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
export {
|
||||
OhMyOpenCodeConfigSchema,
|
||||
AgentOverrideConfigSchema,
|
||||
AgentOverridesSchema,
|
||||
McpNameSchema,
|
||||
AgentNameSchema,
|
||||
HookNameSchema,
|
||||
BuiltinCommandNameSchema,
|
||||
SisyphusAgentConfigSchema,
|
||||
ExperimentalConfigSchema,
|
||||
RalphLoopConfigSchema,
|
||||
TmuxConfigSchema,
|
||||
TmuxLayoutSchema,
|
||||
RuntimeFallbackConfigSchema,
|
||||
FallbackModelsSchema,
|
||||
} from "./schema"
|
||||
|
||||
export type {
|
||||
|
||||
@@ -47,12 +47,6 @@ 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({
|
||||
|
||||
@@ -35,8 +35,6 @@ 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(),
|
||||
|
||||
40
src/features/background-agent/background-task-completer.ts
Normal file
40
src/features/background-agent/background-task-completer.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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
|
||||
}
|
||||
14
src/features/background-agent/format-duration.ts
Normal file
14
src/features/background-agent/format-duration.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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`
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
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"
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
hasMoreFallbacks,
|
||||
} from "../../shared/model-error-classifier"
|
||||
import {
|
||||
MIN_IDLE_TIME_MS,
|
||||
POLLING_INTERVAL_MS,
|
||||
TASK_CLEANUP_DELAY_MS,
|
||||
} from "./constants"
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
import { tryFallbackRetry } from "./fallback-retry-handler"
|
||||
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
|
||||
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
import { MESSAGE_STORAGE } from "../hook-message-injector"
|
||||
import { join } from "node:path"
|
||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||
@@ -268,7 +268,7 @@ export class BackgroundManager {
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agent} subagent)`,
|
||||
} as Record<string, unknown>,
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
@@ -740,15 +740,61 @@ export class BackgroundManager {
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
if (!props || typeof props !== "object") return
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: props as Record<string, unknown>,
|
||||
findBySession: (id) => this.findBySession(id),
|
||||
idleDeferralTimers: this.idleDeferralTimers,
|
||||
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
|
||||
checkSessionTodos: (id) => this.checkSessionTodos(id),
|
||||
tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
|
||||
emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
const startedAt = task.startedAt
|
||||
if (!startedAt) return
|
||||
|
||||
// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
|
||||
const elapsedMs = Date.now() - startedAt.getTime()
|
||||
if (elapsedMs < MIN_IDLE_TIME_MS) {
|
||||
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
|
||||
if (!this.idleDeferralTimers.has(task.id)) {
|
||||
log("[background-agent] Deferring early session.idle:", { elapsedMs, remainingMs, taskId: task.id })
|
||||
const timer = setTimeout(() => {
|
||||
this.idleDeferralTimers.delete(task.id)
|
||||
this.handleEvent({ type: "session.idle", properties: { sessionID } })
|
||||
}, remainingMs)
|
||||
this.idleDeferralTimers.set(task.id, timer)
|
||||
} else {
|
||||
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Edge guard: Verify session has actual assistant output before completing
|
||||
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
|
||||
// Re-check status after async operation (could have been completed by polling)
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during validation, skipping:", { taskId: task.id, status: task.status })
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
|
||||
|
||||
// Re-check status after async operation again
|
||||
if (task.status !== "running") {
|
||||
log("[background-agent] Task status changed during todo check, skipping:", { taskId: task.id, status: task.status })
|
||||
return
|
||||
}
|
||||
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
await this.tryCompleteTask(task, "session.idle event")
|
||||
}).catch(err => {
|
||||
log("[background-agent] Error in session.idle handler:", err)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
1
src/features/background-agent/message-dir.ts
Normal file
1
src/features/background-agent/message-dir.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getMessageDir } from "../../shared"
|
||||
@@ -0,0 +1,81 @@
|
||||
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) }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
103
src/features/background-agent/parent-session-notifier.ts
Normal file
103
src/features/background-agent/parent-session-notifier.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
9
src/features/background-agent/result-handler-context.ts
Normal file
9
src/features/background-agent/result-handler-context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { OpencodeClient } from "./constants"
|
||||
import type { ConcurrencyManager } from "./concurrency"
|
||||
import type { TaskStateManager } from "./state"
|
||||
|
||||
export interface ResultHandlerContext {
|
||||
client: OpencodeClient
|
||||
concurrencyManager: ConcurrencyManager
|
||||
state: TaskStateManager
|
||||
}
|
||||
7
src/features/background-agent/result-handler.ts
Normal file
7
src/features/background-agent/result-handler.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
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"
|
||||
@@ -1,340 +0,0 @@
|
||||
import { describe, it, expect, mock } from "bun:test"
|
||||
|
||||
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
|
||||
import type { BackgroundTask } from "./types"
|
||||
import { MIN_IDLE_TIME_MS } from "./constants"
|
||||
|
||||
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
sessionID: "ses-idle-1",
|
||||
parentSessionID: "parent-ses-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "test idle handler",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 100)),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("handleSessionIdleBackgroundEvent", () => {
|
||||
describe("#given no sessionID in properties", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: {},
|
||||
findBySession: () => undefined,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-string sessionID in properties", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: 123 },
|
||||
findBySession: () => undefined,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given no task found for session", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: "ses-unknown" },
|
||||
findBySession: () => undefined,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given task is not running", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const task = createRunningTask({ status: "completed" })
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given task has no startedAt", () => {
|
||||
it("#then should do nothing", () => {
|
||||
//#given
|
||||
const task = createRunningTask({ startedAt: undefined })
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given elapsed time < MIN_IDLE_TIME_MS", () => {
|
||||
it("#when idle fires early #then should defer with timer", () => {
|
||||
//#given
|
||||
const realDateNow = Date.now
|
||||
const baseNow = realDateNow()
|
||||
const task = createRunningTask({ startedAt: new Date(baseNow) })
|
||||
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const emitIdleEvent = mock(() => {})
|
||||
|
||||
try {
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask: () => Promise.resolve(true),
|
||||
emitIdleEvent,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(idleDeferralTimers.has(task.id)).toBe(true)
|
||||
expect(emitIdleEvent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
clearTimeout(idleDeferralTimers.get(task.id)!)
|
||||
Date.now = realDateNow
|
||||
}
|
||||
})
|
||||
|
||||
it("#when idle already deferred #then should not create duplicate timer", () => {
|
||||
//#given
|
||||
const realDateNow = Date.now
|
||||
const baseNow = realDateNow()
|
||||
const task = createRunningTask({ startedAt: new Date(baseNow) })
|
||||
const existingTimer = setTimeout(() => {}, 99999)
|
||||
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>([
|
||||
[task.id, existingTimer],
|
||||
])
|
||||
const emitIdleEvent = mock(() => {})
|
||||
|
||||
try {
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - 100)
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask: () => Promise.resolve(true),
|
||||
emitIdleEvent,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(idleDeferralTimers.get(task.id)).toBe(existingTimer)
|
||||
} finally {
|
||||
clearTimeout(existingTimer)
|
||||
Date.now = realDateNow
|
||||
}
|
||||
})
|
||||
|
||||
it("#when deferred timer fires #then should emit idle event", async () => {
|
||||
//#given
|
||||
const realDateNow = Date.now
|
||||
const baseNow = realDateNow()
|
||||
const task = createRunningTask({ startedAt: new Date(baseNow) })
|
||||
const idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const emitIdleEvent = mock(() => {})
|
||||
const remainingMs = 50
|
||||
|
||||
try {
|
||||
Date.now = () => baseNow + (MIN_IDLE_TIME_MS - remainingMs)
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers,
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask: () => Promise.resolve(true),
|
||||
emitIdleEvent,
|
||||
})
|
||||
|
||||
//#then - wait for deferred timer
|
||||
await new Promise((resolve) => setTimeout(resolve, remainingMs + 50))
|
||||
expect(emitIdleEvent).toHaveBeenCalledWith(task.sessionID)
|
||||
expect(idleDeferralTimers.has(task.id)).toBe(false)
|
||||
} finally {
|
||||
Date.now = realDateNow
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given elapsed time >= MIN_IDLE_TIME_MS", () => {
|
||||
it("#when session has valid output and no incomplete todos #then should complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event")
|
||||
})
|
||||
|
||||
it("#when session has no valid output #then should not complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(false),
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#when task has incomplete todos #then should not complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: () => Promise.resolve(true),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#when task status changes during validation #then should not complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: async () => {
|
||||
task.status = "completed"
|
||||
return true
|
||||
},
|
||||
checkSessionTodos: () => Promise.resolve(false),
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#when task status changes during todo check #then should not complete task", async () => {
|
||||
//#given
|
||||
const task = createRunningTask()
|
||||
const tryCompleteTask = mock(() => Promise.resolve(true))
|
||||
|
||||
//#when
|
||||
handleSessionIdleBackgroundEvent({
|
||||
properties: { sessionID: task.sessionID! },
|
||||
findBySession: () => task,
|
||||
idleDeferralTimers: new Map(),
|
||||
validateSessionHasOutput: () => Promise.resolve(true),
|
||||
checkSessionTodos: async () => {
|
||||
task.status = "cancelled"
|
||||
return false
|
||||
},
|
||||
tryCompleteTask,
|
||||
emitIdleEvent: () => {},
|
||||
})
|
||||
|
||||
//#then
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(tryCompleteTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
89
src/features/background-agent/session-output-validator.ts
Normal file
89
src/features/background-agent/session-output-validator.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
46
src/features/background-agent/session-task-cleanup.ts
Normal file
46
src/features/background-agent/session-task-cleanup.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
33
src/features/background-agent/session-todo-checker.ts
Normal file
33
src/features/background-agent/session-todo-checker.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,9 @@ export async function startTask(
|
||||
const createResult = await client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
} as Record<string, unknown>,
|
||||
title: `Background: ${input.description}`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LaunchInput } from "../types"
|
||||
|
||||
export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string {
|
||||
return input.model
|
||||
? `${input.model.providerID}/${input.model.modelID}`
|
||||
: input.agent
|
||||
}
|
||||
12
src/features/background-agent/spawner/spawner-context.ts
Normal file
12
src/features/background-agent/spawner/spawner-context.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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)
|
||||
}
|
||||
3
src/features/claude-tasks/index.ts
Normal file
3
src/features/claude-tasks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./storage"
|
||||
export * from "./session-storage"
|
||||
3
src/features/mcp-oauth/index.ts
Normal file
3
src/features/mcp-oauth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./schema"
|
||||
export * from "./oauth-authorization-flow"
|
||||
export * from "./provider"
|
||||
@@ -4,12 +4,6 @@ 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
|
||||
@@ -176,7 +170,8 @@ export class TaskToastManager {
|
||||
* Show consolidated toast with all running/queued tasks
|
||||
*/
|
||||
private showTaskListToast(newTask: TrackedTask): void {
|
||||
const tuiClient = this.client as ClientWithTui
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
if (!tuiClient.tui?.showToast) return
|
||||
|
||||
const message = this.buildTaskListMessage(newTask)
|
||||
@@ -201,7 +196,8 @@ export class TaskToastManager {
|
||||
* Show task completion toast
|
||||
*/
|
||||
showCompletionToast(task: { id: string; description: string; duration: string }): void {
|
||||
const tuiClient = this.client as ClientWithTui
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
if (!tuiClient.tui?.showToast) return
|
||||
|
||||
this.removeTask(task.id)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AutoCompactState } from "./types";
|
||||
import type { OhMyOpenCodeConfig } from "../../config";
|
||||
import type { ExperimentalConfig } from "../../config";
|
||||
import { TRUNCATE_CONFIG } from "./types";
|
||||
|
||||
@@ -16,15 +15,15 @@ export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
autoCompactState: AutoCompactState,
|
||||
client: Client,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string,
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
_experimental?: ExperimentalConfig
|
||||
experimental?: ExperimentalConfig,
|
||||
): Promise<void> {
|
||||
void _experimental
|
||||
void experimental
|
||||
|
||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||
await client.tui
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Compact In Progress",
|
||||
@@ -56,7 +55,7 @@ export async function executeCompact(
|
||||
const result = await runAggressiveTruncationStrategy({
|
||||
sessionID,
|
||||
autoCompactState,
|
||||
client: client,
|
||||
client: client as Client,
|
||||
directory,
|
||||
truncateAttempt: truncateState.truncateAttempt,
|
||||
currentTokens: errorData.currentTokens,
|
||||
@@ -71,9 +70,8 @@ export async function executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
client: client,
|
||||
client: client as Client,
|
||||
directory,
|
||||
pluginConfig,
|
||||
errorType: errorData?.errorType,
|
||||
messageIndex: errorData?.messageIndex,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Client } from "./client"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import type { ExperimentalConfig, OhMyOpenCodeConfig } from "../../config"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
import { attemptDeduplicationRecovery } from "./deduplication-recovery"
|
||||
@@ -9,7 +8,6 @@ import { log } from "../../shared/logger"
|
||||
|
||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
}
|
||||
|
||||
function createRecoveryState(): AutoCompactState {
|
||||
@@ -30,7 +28,6 @@ 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 } }) => {
|
||||
@@ -92,9 +89,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client as Client,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
pluginConfig,
|
||||
experimental,
|
||||
)
|
||||
}, 300)
|
||||
@@ -160,9 +156,8 @@ export function createAnthropicContextWindowLimitRecoveryHook(
|
||||
sessionID,
|
||||
{ providerID, modelID },
|
||||
autoCompactState,
|
||||
ctx.client as Client,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
pluginConfig,
|
||||
experimental,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
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> {
|
||||
@@ -77,14 +74,7 @@ export async function runSummarizeRetryStrategy(params: {
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
|
||||
params.pluginConfig,
|
||||
params.sessionID,
|
||||
providerID,
|
||||
modelID
|
||||
)
|
||||
|
||||
const summarizeBody = { providerID: targetProviderID, modelID: targetModelID, auto: true }
|
||||
const summarizeBody = { providerID, modelID, auto: true }
|
||||
await params.client.session.summarize({
|
||||
path: { id: params.sessionID },
|
||||
body: summarizeBody as never,
|
||||
|
||||
@@ -104,65 +104,6 @@ ALL three must be YES. "Probably" = NO. "I think so" = NO. Investigate until CER
|
||||
|
||||
**DO NOT proceed to the next task until all 4 phases are complete and the gate passes.**`
|
||||
|
||||
export const VERIFICATION_REMINDER_GEMINI = `**THE SUBAGENT HAS FINISHED. THEIR WORK IS EXTREMELY SUSPICIOUS.**
|
||||
|
||||
The subagent CLAIMS this task is done. Based on thousands of executions, subagent claims are FALSE more often than true.
|
||||
They ROUTINELY:
|
||||
- Ship code with syntax errors they didn't bother to check
|
||||
- Create stub implementations with TODOs and call it "done"
|
||||
- Write tests that pass trivially (testing nothing meaningful)
|
||||
- Implement logic that does NOT match what was requested
|
||||
- Add features nobody asked for and call it "improvement"
|
||||
- Report "all tests pass" when they didn't run any tests
|
||||
|
||||
**This is NOT a theoretical warning. This WILL happen on this task. Assume the work is BROKEN.**
|
||||
|
||||
**YOU MUST VERIFY WITH ACTUAL TOOL CALLS. NOT REASONING. TOOL CALLS.**
|
||||
Thinking "it looks correct" is NOT verification. Running \`lsp_diagnostics\` IS.
|
||||
|
||||
---
|
||||
|
||||
**PHASE 1: READ THE CODE FIRST (DO NOT SKIP — DO NOT RUN TESTS YET)**
|
||||
|
||||
Read the code FIRST so you know what you're testing.
|
||||
|
||||
1. \`Bash("git diff --stat")\` — see exactly which files changed.
|
||||
2. \`Read\` EVERY changed file — no exceptions, no skimming.
|
||||
3. For EACH file:
|
||||
- Does this code ACTUALLY do what the task required? RE-READ the task spec.
|
||||
- Any stubs, TODOs, placeholders? \`Grep\` for TODO, FIXME, HACK, xxx
|
||||
- Anti-patterns? \`Grep\` for \`as any\`, \`@ts-ignore\`, empty catch
|
||||
- Scope creep? Did the subagent add things NOT in the task spec?
|
||||
4. Cross-check EVERY claim against actual code.
|
||||
|
||||
**If you cannot explain what every changed line does, GO BACK AND READ AGAIN.**
|
||||
|
||||
**PHASE 2: RUN AUTOMATED CHECKS**
|
||||
|
||||
1. \`lsp_diagnostics\` on EACH changed file — ZERO new errors. ACTUALLY RUN THIS.
|
||||
2. Run tests for changed modules, then full suite. ACTUALLY RUN THESE.
|
||||
3. Build/typecheck — exit 0.
|
||||
|
||||
If Phase 1 found issues but Phase 2 passes: Phase 2 is WRONG. Fix the code.
|
||||
|
||||
**PHASE 3: HANDS-ON QA (MANDATORY for user-facing changes)**
|
||||
|
||||
- **Frontend/UI**: \`/playwright\`
|
||||
- **TUI/CLI**: \`interactive_bash\`
|
||||
- **API/Backend**: \`Bash\` with curl
|
||||
|
||||
**If user-facing and you did not run it, you are shipping UNTESTED BROKEN work.**
|
||||
|
||||
**PHASE 4: GATE DECISION**
|
||||
|
||||
1. Can I explain what EVERY changed line does? (If no → Phase 1)
|
||||
2. Did I SEE it work via tool calls? (If user-facing and no → Phase 3)
|
||||
3. Am I confident nothing is broken? (If no → broader tests)
|
||||
|
||||
ALL three must be YES. "Probably" = NO. "I think so" = NO.
|
||||
|
||||
**DO NOT proceed to the next task until all 4 phases are complete.**`
|
||||
|
||||
export const ORCHESTRATOR_DELEGATION_REQUIRED = `
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { afterEach, describe, it, expect, 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<(directory: string) => string | null>(() => null)
|
||||
const mockGetLocalDevVersion = mock(() => "3.6.0")
|
||||
|
||||
mock.module("./hook/config-errors-toast", () => ({
|
||||
showConfigErrorsIfAny: mockShowConfigErrorsIfAny,
|
||||
@@ -40,49 +40,31 @@ mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
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)
|
||||
})
|
||||
const { createAutoUpdateCheckerHook } = await import("./hook")
|
||||
|
||||
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"
|
||||
const createAutoUpdateCheckerHook = await importFreshHookFactory()
|
||||
mockShowConfigErrorsIfAny.mockClear()
|
||||
mockShowModelCacheWarningIfNeeded.mockClear()
|
||||
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
|
||||
mockShowLocalDevToast.mockClear()
|
||||
mockShowVersionToast.mockClear()
|
||||
mockRunBackgroundUpdateCheck.mockClear()
|
||||
|
||||
const hook = createAutoUpdateCheckerHook(createPluginInput(), {
|
||||
showStartupToast: true,
|
||||
isSisyphusEnabled: true,
|
||||
autoUpdate: true,
|
||||
})
|
||||
const hook = createAutoUpdateCheckerHook(
|
||||
{
|
||||
directory: "/test",
|
||||
client: {} as never,
|
||||
} as never,
|
||||
{ showStartupToast: true, isSisyphusEnabled: true, autoUpdate: true }
|
||||
)
|
||||
|
||||
//#when - session.created event arrives
|
||||
hook.event({
|
||||
@@ -91,7 +73,7 @@ describe("createAutoUpdateCheckerHook", () => {
|
||||
properties: { info: { parentID: undefined } },
|
||||
},
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
|
||||
//#then - no update checker side effects run
|
||||
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
|
||||
@@ -100,144 +82,6 @@ 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")
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,208 +1,177 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test"
|
||||
|
||||
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")
|
||||
// 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)
|
||||
const mockExtractChannel = mock(() => "latest")
|
||||
const mockInvalidatePackage = mock(() => {})
|
||||
const mockRunBunInstall = mock(async () => true)
|
||||
const mockShowUpdateAvailableToast = mock(
|
||||
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
|
||||
)
|
||||
const mockShowAutoUpdatedToast = mock(
|
||||
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
|
||||
)
|
||||
const mockShowUpdateAvailableToast = mock(async () => {})
|
||||
const mockShowAutoUpdatedToast = mock(async () => {})
|
||||
|
||||
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: () => {} }))
|
||||
|
||||
const modulePath = "./background-update-check?test"
|
||||
const { runBackgroundUpdateCheck } = await import(modulePath)
|
||||
mock.module("../../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
|
||||
const { runBackgroundUpdateCheck } = await import("./background-update-check?test")
|
||||
|
||||
describe("runBackgroundUpdateCheck", () => {
|
||||
const mockCtx = { directory: "/test" } as PluginInput
|
||||
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
|
||||
const mockCtx = { directory: "/test" } as any
|
||||
const mockGetToastMessage = (isUpdate: boolean, version?: string) =>
|
||||
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 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()
|
||||
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)
|
||||
|
||||
expect(mockRunBunInstall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("#then should NOT invalidate package cache", async () => {
|
||||
await runBackgroundUpdateCheck(mockCtx, true, mockGetToastMessage)
|
||||
|
||||
expect(mockInvalidatePackage).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 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 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")
|
||||
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(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
|
||||
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
|
||||
expect(mockUpdatePinnedVersion).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()
|
||||
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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,204 +1,161 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
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 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,
|
||||
}),
|
||||
}
|
||||
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>) => {})
|
||||
|
||||
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(() => {
|
||||
storageMaps.clear()
|
||||
findAgentsMdUpMock.mockClear()
|
||||
resolveFilePathMock.mockClear()
|
||||
loadInjectedPathsMock.mockClear()
|
||||
saveInjectedPathsMock.mockClear()
|
||||
|
||||
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")
|
||||
testRoot = join(
|
||||
tmpdir(),
|
||||
`directory-agents-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
)
|
||||
mkdirSync(testRoot, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(testRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
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: {} }
|
||||
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")
|
||||
|
||||
// when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(srcDirectory, "file.ts"),
|
||||
sessionID: "session-parent",
|
||||
output,
|
||||
})
|
||||
loadInjectedPathsMock.mockReturnValueOnce(new Set([cachedDirectory]))
|
||||
findAgentsMdUpMock.mockReturnValueOnce([agentsPath])
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("[Directory Context:")
|
||||
expect(output.output).toContain(srcAgentsContent)
|
||||
})
|
||||
|
||||
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
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(testRoot, "file.ts"),
|
||||
sessionID: "session-root-skip",
|
||||
output,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).not.toContain(rootAgentsContent)
|
||||
expect(output.output).not.toContain("[Directory Context:")
|
||||
})
|
||||
|
||||
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
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(componentsDirectory, "button.ts"),
|
||||
sessionID: "session-multiple",
|
||||
output,
|
||||
})
|
||||
|
||||
// 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,
|
||||
}),
|
||||
const truncator = {
|
||||
truncate: mock(async () => ({ result: "trimmed", truncated: false })),
|
||||
}
|
||||
|
||||
// when
|
||||
mock.module("./finder", () => ({
|
||||
findAgentsMdUp: findAgentsMdUpMock,
|
||||
resolveFilePath: resolveFilePathMock,
|
||||
}))
|
||||
mock.module("./storage", () => ({
|
||||
loadInjectedPaths: loadInjectedPathsMock,
|
||||
saveInjectedPaths: saveInjectedPathsMock,
|
||||
}))
|
||||
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator: truncatedTruncator,
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(srcDirectory, "file.ts"),
|
||||
sessionID: "session-truncated",
|
||||
output,
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("truncated...")
|
||||
expect(output.output).toContain("[Note: Content was truncated")
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does nothing when filePath cannot be resolved", async () => {
|
||||
// given
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
const output = { title: "Read result", output: "base output", metadata: {} }
|
||||
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")
|
||||
|
||||
// when
|
||||
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,
|
||||
}))
|
||||
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx: { directory: testRoot } as PluginInput,
|
||||
truncator,
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: "",
|
||||
sessionID: "session-empty-path",
|
||||
output,
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toBe("base 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)
|
||||
})
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
const { processFilePathForAgentsInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForAgentsInjection({
|
||||
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: {} },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,212 +1,161 @@
|
||||
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"
|
||||
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
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>) => {})
|
||||
|
||||
describe("processFilePathForReadmeInjection", () => {
|
||||
let testRoot = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testRoot = join(tmpdir(), `directory-readme-injector-${randomUUID()}`)
|
||||
findReadmeMdUpMock.mockClear()
|
||||
resolveFilePathMock.mockClear()
|
||||
loadInjectedPathsMock.mockClear()
|
||||
saveInjectedPathsMock.mockClear()
|
||||
|
||||
testRoot = join(
|
||||
tmpdir(),
|
||||
`directory-readme-injector-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
)
|
||||
mkdirSync(testRoot, { recursive: true })
|
||||
storageMaps.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(testRoot, { recursive: true, force: true })
|
||||
storageMaps.clear()
|
||||
})
|
||||
|
||||
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")
|
||||
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,
|
||||
}))
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
const output = { title: "Result", output: "base", metadata: {} }
|
||||
const truncator = createTruncator()
|
||||
|
||||
// when
|
||||
//#when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches: new Map<string, Set<string>>(),
|
||||
filePath: join(sourceDirectory, "file.ts"),
|
||||
sessionID: "session-parent",
|
||||
output,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("[Project README:")
|
||||
expect(output.output).toContain("# Source README")
|
||||
expect(output.output).toContain("local context")
|
||||
})
|
||||
|
||||
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
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches: new Map<string, Set<string>>(),
|
||||
filePath: join(testRoot, "file.ts"),
|
||||
sessionID: "session-root",
|
||||
output,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("[Project README:")
|
||||
expect(output.output).toContain("# Root README")
|
||||
expect(output.output).toContain("root context")
|
||||
})
|
||||
|
||||
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
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches: new Map<string, Set<string>>(),
|
||||
filePath: join(componentsDirectory, "button.ts"),
|
||||
sessionID: "session-multi",
|
||||
output,
|
||||
})
|
||||
|
||||
// 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"),
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: firstOutput,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
|
||||
//#when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches,
|
||||
filePath: join(sourceDirectory, "b.ts"),
|
||||
ctx: { directory: repoRoot } as never,
|
||||
truncator: truncator as never,
|
||||
sessionCaches: new Map(),
|
||||
filePath: join(repoRoot, "src", "file.ts"),
|
||||
sessionID,
|
||||
output: secondOutput,
|
||||
output: { title: "Result", output: "", metadata: {} },
|
||||
})
|
||||
|
||||
// then
|
||||
expect(countReadmeMarkers(firstOutput.output)).toBe(1)
|
||||
expect(secondOutput.output).toBe("")
|
||||
//#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)
|
||||
})
|
||||
|
||||
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")
|
||||
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,
|
||||
}))
|
||||
|
||||
const { processFilePathForReadmeInjection } = await import("./injector")
|
||||
const output = { title: "Result", output: "", metadata: {} }
|
||||
const truncator = createTruncator({ result: "trimmed content", truncated: true })
|
||||
|
||||
// when
|
||||
//#when
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx: createPluginContext(testRoot),
|
||||
truncator,
|
||||
sessionCaches: new Map<string, Set<string>>(),
|
||||
filePath: join(sourceDirectory, "file.ts"),
|
||||
sessionID: "session-truncated",
|
||||
output,
|
||||
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: {} },
|
||||
})
|
||||
|
||||
// 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")
|
||||
//#then
|
||||
expect(saveInjectedPathsMock).toHaveBeenCalledTimes(1)
|
||||
const saveCall = saveInjectedPathsMock.mock.calls[0]
|
||||
expect((saveCall[1] as Set<string>).has(join(repoRoot, "new-dir"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
306
src/hooks/hashline-edit-diff-enhancer/index.test.ts
Normal file
306
src/hooks/hashline-edit-diff-enhancer/index.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
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(() => {})
|
||||
})
|
||||
})
|
||||
})
|
||||
1
src/hooks/hashline-edit-diff-enhancer/index.ts
Normal file
1
src/hooks/hashline-edit-diff-enhancer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createHashlineEditDiffEnhancerHook } from "./hook"
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -4,22 +4,19 @@
|
||||
* Routing:
|
||||
* 1. Planner agents (prometheus, plan) → planner.ts
|
||||
* 2. GPT 5.2 models → gpt5.2.ts
|
||||
* 3. Gemini models → gemini.ts
|
||||
* 4. Default (Claude, etc.) → default.ts (optimized for Claude series)
|
||||
* 3. Default (Claude, etc.) → default.ts (optimized for Claude series)
|
||||
*/
|
||||
|
||||
export { isPlannerAgent, isGptModel, isGeminiModel, getUltraworkSource } from "./source-detector"
|
||||
export { isPlannerAgent, isGptModel, 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.
|
||||
@@ -32,8 +29,6 @@ export function getUltraworkMessage(agentName?: string, modelID?: string): strin
|
||||
return getPlannerUltraworkMessage()
|
||||
case "gpt":
|
||||
return getGptUltraworkMessage()
|
||||
case "gemini":
|
||||
return getGeminiUltraworkMessage()
|
||||
case "default":
|
||||
default:
|
||||
return getDefaultUltraworkMessage()
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
* Routing logic:
|
||||
* 1. Planner agents (prometheus, plan) → planner.ts
|
||||
* 2. GPT 5.2 models → gpt5.2.ts
|
||||
* 3. Gemini models → gemini.ts
|
||||
* 4. Everything else (Claude, etc.) → default.ts
|
||||
* 3. Everything else (Claude, etc.) → default.ts
|
||||
*/
|
||||
|
||||
import { isGptModel, isGeminiModel } from "../../../agents/types"
|
||||
import { isGptModel } from "../../../agents/types"
|
||||
|
||||
/**
|
||||
* Checks if agent is a planner-type agent.
|
||||
@@ -23,10 +22,10 @@ export function isPlannerAgent(agentName?: string): boolean {
|
||||
return /\bplan\b/.test(normalized)
|
||||
}
|
||||
|
||||
export { isGptModel, isGeminiModel }
|
||||
export { isGptModel }
|
||||
|
||||
/** Ultrawork message source type */
|
||||
export type UltraworkSource = "planner" | "gpt" | "gemini" | "default"
|
||||
export type UltraworkSource = "planner" | "gpt" | "default"
|
||||
|
||||
/**
|
||||
* Determines which ultrawork message source to use.
|
||||
@@ -45,11 +44,6 @@ export function getUltraworkSource(
|
||||
return "gpt"
|
||||
}
|
||||
|
||||
|
||||
// Priority 3: Gemini models
|
||||
if (modelID && isGeminiModel(modelID)) {
|
||||
return "gemini"
|
||||
}
|
||||
// Default: Claude and other models
|
||||
return "default"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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 = {
|
||||
@@ -53,7 +51,6 @@ type PluginInput = {
|
||||
|
||||
export function createPreemptiveCompactionHook(
|
||||
ctx: PluginInput,
|
||||
pluginConfig: OhMyOpenCodeConfig,
|
||||
modelCacheState?: ModelCacheStateLike,
|
||||
) {
|
||||
const compactionInProgress = new Set<string>()
|
||||
@@ -87,16 +84,9 @@ 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: targetProviderID, modelID: targetModelID, auto: true } as never,
|
||||
body: { providerID: cached.providerID, modelID, auto: true } as never,
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
|
||||
|
||||
const { createSessionNotification } = require("./session-notification")
|
||||
const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state")
|
||||
const utils = require("./session-notification-utils")
|
||||
|
||||
describe("session-notification input-needed events", () => {
|
||||
let notificationCalls: string[]
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
$: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
}
|
||||
|
||||
return { stdout: "", stderr: "", exitCode: 0 }
|
||||
},
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
notificationCalls = []
|
||||
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
||||
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
subagentSessions.clear()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
test("sends question notification when question tool asks for input", async () => {
|
||||
const sessionID = "main-question"
|
||||
setMainSession(sessionID)
|
||||
const hook = createSessionNotification(createMockPluginInput())
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: {
|
||||
sessionID,
|
||||
tool: "question",
|
||||
args: {
|
||||
questions: [
|
||||
{
|
||||
question: "Which branch should we use?",
|
||||
options: [{ label: "main" }, { label: "dev" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(notificationCalls).toHaveLength(1)
|
||||
expect(notificationCalls[0]).toContain("Agent is asking a question")
|
||||
})
|
||||
|
||||
test("sends permission notification for permission events", async () => {
|
||||
const sessionID = "main-permission"
|
||||
setMainSession(sessionID)
|
||||
const hook = createSessionNotification(createMockPluginInput())
|
||||
|
||||
await hook({
|
||||
event: {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
sessionID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(notificationCalls).toHaveLength(1)
|
||||
expect(notificationCalls[0]).toContain("Agent needs permission to continue")
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -15,8 +15,6 @@ import { createIdleNotificationScheduler } from "./session-notification-schedule
|
||||
interface SessionNotificationConfig {
|
||||
title?: string
|
||||
message?: string
|
||||
questionMessage?: string
|
||||
permissionMessage?: string
|
||||
playSound?: boolean
|
||||
soundPath?: string
|
||||
/** Delay in ms before sending notification to confirm session is still idle (default: 1500) */
|
||||
@@ -38,8 +36,6 @@ export function createSessionNotification(
|
||||
const mergedConfig = {
|
||||
title: "OpenCode",
|
||||
message: "Agent is ready for input",
|
||||
questionMessage: "Agent is asking a question",
|
||||
permissionMessage: "Agent needs permission to continue",
|
||||
playSound: false,
|
||||
soundPath: defaultSoundPath,
|
||||
idleConfirmationDelay: 1500,
|
||||
@@ -57,56 +53,6 @@ export function createSessionNotification(
|
||||
playSound: playSessionNotificationSound,
|
||||
})
|
||||
|
||||
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
|
||||
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
|
||||
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
|
||||
|
||||
const getSessionID = (properties: Record<string, unknown> | undefined): string | undefined => {
|
||||
const sessionID = properties?.sessionID
|
||||
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
|
||||
|
||||
const sessionId = properties?.sessionId
|
||||
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
|
||||
|
||||
const info = properties?.info as Record<string, unknown> | undefined
|
||||
const infoSessionID = info?.sessionID
|
||||
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
|
||||
|
||||
const infoSessionId = info?.sessionId
|
||||
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const shouldNotifyForSession = (sessionID: string): boolean => {
|
||||
if (subagentSessions.has(sessionID)) return false
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID && sessionID !== mainSessionID) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const getEventToolName = (properties: Record<string, unknown> | undefined): string | undefined => {
|
||||
const tool = properties?.tool
|
||||
if (typeof tool === "string" && tool.length > 0) return tool
|
||||
|
||||
const name = properties?.name
|
||||
if (typeof name === "string" && name.length > 0) return name
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getQuestionText = (properties: Record<string, unknown> | undefined): string => {
|
||||
const args = properties?.args as Record<string, unknown> | undefined
|
||||
const questions = args?.questions
|
||||
if (!Array.isArray(questions) || questions.length === 0) return ""
|
||||
|
||||
const firstQuestion = questions[0] as Record<string, unknown> | undefined
|
||||
const questionText = firstQuestion?.question
|
||||
return typeof questionText === "string" ? questionText : ""
|
||||
}
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (currentPlatform === "unsupported") return
|
||||
|
||||
@@ -122,10 +68,14 @@ export function createSessionNotification(
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = getSessionID(props)
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (!shouldNotifyForSession(sessionID)) return
|
||||
if (subagentSessions.has(sessionID)) return
|
||||
|
||||
// Only trigger notifications for the main session (not subagent sessions)
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID && sessionID !== mainSessionID) return
|
||||
|
||||
scheduler.scheduleIdleNotification(sessionID)
|
||||
return
|
||||
@@ -133,47 +83,17 @@ export function createSessionNotification(
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = getSessionID({ ...props, info })
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
scheduler.markSessionActivity(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (PERMISSION_EVENTS.has(event.type)) {
|
||||
const sessionID = getSessionID(props)
|
||||
if (!sessionID) return
|
||||
if (!shouldNotifyForSession(sessionID)) return
|
||||
|
||||
scheduler.markSessionActivity(sessionID)
|
||||
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.permissionMessage)
|
||||
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
||||
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = getSessionID(props)
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
scheduler.markSessionActivity(sessionID)
|
||||
|
||||
if (event.type === "tool.execute.before") {
|
||||
const toolName = getEventToolName(props)?.toLowerCase()
|
||||
if (toolName && QUESTION_TOOLS.has(toolName)) {
|
||||
if (!shouldNotifyForSession(sessionID)) return
|
||||
|
||||
const questionText = getQuestionText(props)
|
||||
const message = PERMISSION_HINT_PATTERN.test(questionText)
|
||||
? mergedConfig.permissionMessage
|
||||
: mergedConfig.questionMessage
|
||||
|
||||
await sendSessionNotification(ctx, currentPlatform, mergedConfig.title, message)
|
||||
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
||||
await playSessionNotificationSound(ctx, currentPlatform, mergedConfig.soundPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
87
src/hooks/session-recovery/recover-empty-content-message.ts
Normal file
87
src/hooks/session-recovery/recover-empty-content-message.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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
|
||||
}
|
||||
@@ -5,12 +5,6 @@ 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"
|
||||
@@ -83,7 +77,8 @@ export async function recoverToolResultMissing(
|
||||
}
|
||||
|
||||
try {
|
||||
await (client as unknown as ClientWithPromptAsync).session.promptAsync(promptInput)
|
||||
// @ts-expect-error - SDK types may not include tool_result parts
|
||||
await client.session.promptAsync(promptInput)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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("/"),
|
||||
}
|
||||
}
|
||||
@@ -21,18 +21,6 @@ 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>,
|
||||
@@ -103,7 +91,7 @@ function findPreviousThinkingContent(
|
||||
for (const part of msg.parts) {
|
||||
const type = part.type as string
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
const thinking = (part as unknown as ThinkingPart).thinking || (part as unknown as ThinkingPart).text
|
||||
const thinking = (part as any).thinking || (part as any).text
|
||||
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
|
||||
return thinking
|
||||
}
|
||||
@@ -126,7 +114,7 @@ function prependThinkingBlock(message: MessageWithParts, thinkingContent: string
|
||||
const thinkingPart = {
|
||||
type: "thinking" as const,
|
||||
id: `prt_0000000000_synthetic_thinking`,
|
||||
sessionID: (message.info as unknown as MessageInfoExtended).sessionID || "",
|
||||
sessionID: (message.info as any).sessionID || "",
|
||||
messageID: message.info.id,
|
||||
thinking: thinkingContent,
|
||||
synthetic: true,
|
||||
@@ -150,7 +138,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 unknown as MessageInfoExtended)?.modelID || ""
|
||||
const modelID = (lastUserMessage?.info as any)?.modelID || ""
|
||||
|
||||
// Only process if extended thinking might be enabled
|
||||
if (!isExtendedThinkingModel(modelID)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { existsSync, realpathSync } from "fs"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
|
||||
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path"
|
||||
|
||||
import { log } from "../../shared"
|
||||
|
||||
@@ -14,7 +14,7 @@ type GuardArgs = {
|
||||
|
||||
const MAX_TRACKED_SESSIONS = 256
|
||||
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
|
||||
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -37,8 +37,6 @@ function isPathInsideDirectory(pathToCheck: string, directory: string): boolean
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
}
|
||||
|
||||
|
||||
|
||||
function toCanonicalPath(absolutePath: string): string {
|
||||
let canonicalPath = absolutePath
|
||||
|
||||
@@ -75,6 +73,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
const readPermissionsBySession = new Map<string, Set<string>>()
|
||||
const sessionLastAccess = new Map<string, number>()
|
||||
const canonicalSessionRoot = toCanonicalPath(resolveInputPath(ctx, ctx.directory))
|
||||
const sisyphusRoot = join(canonicalSessionRoot, ".sisyphus") + sep
|
||||
|
||||
const touchSession = (sessionID: string): void => {
|
||||
sessionLastAccess.set(sessionID, Date.now())
|
||||
@@ -175,7 +174,16 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
const isInsideSessionDirectory = isPathInsideDirectory(canonicalPath, canonicalSessionRoot)
|
||||
|
||||
if (!isInsideSessionDirectory) {
|
||||
return
|
||||
if (toolName === "read") {
|
||||
return
|
||||
}
|
||||
|
||||
log("[write-existing-file-guard] Blocking write outside session directory", {
|
||||
sessionID: input.sessionID,
|
||||
filePath,
|
||||
resolvedPath,
|
||||
})
|
||||
throw new Error(OUTSIDE_SESSION_MESSAGE)
|
||||
}
|
||||
|
||||
if (toolName === "read") {
|
||||
@@ -198,7 +206,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
return
|
||||
}
|
||||
|
||||
const isSisyphusPath = canonicalPath.includes("/.sisyphus/")
|
||||
const isSisyphusPath = canonicalPath.startsWith(sisyphusRoot)
|
||||
if (isSisyphusPath) {
|
||||
log("[write-existing-file-guard] Allowing .sisyphus/** overwrite", {
|
||||
sessionID: input.sessionID,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
|
||||
import { createWriteExistingFileGuardHook } from "./index"
|
||||
|
||||
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
|
||||
const OUTSIDE_SESSION_MESSAGE = "Path must be inside session directory."
|
||||
|
||||
type Hook = ReturnType<typeof createWriteExistingFileGuardHook>
|
||||
|
||||
@@ -338,7 +339,7 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test("#given existing file outside session directory #when write executes #then allows", async () => {
|
||||
test("#given existing file outside session directory #when write executes #then blocks", async () => {
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-outside-"))
|
||||
|
||||
try {
|
||||
@@ -348,9 +349,9 @@ describe("createWriteExistingFileGuardHook", () => {
|
||||
await expect(
|
||||
invoke({
|
||||
tool: "write",
|
||||
outputArgs: { filePath: outsideFile, content: "allowed overwrite" },
|
||||
outputArgs: { filePath: outsideFile, content: "attempted overwrite" },
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
).rejects.toThrow(OUTSIDE_SESSION_MESSAGE)
|
||||
} finally {
|
||||
rmSync(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1161,6 +1161,8 @@ 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"),
|
||||
])
|
||||
@@ -1204,6 +1206,10 @@ 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 () => {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,7 +84,6 @@ export function applyToolConfig(params: {
|
||||
question: questionPermission,
|
||||
"task_*": "allow",
|
||||
teammate: "allow",
|
||||
...denyTodoTools,
|
||||
};
|
||||
}
|
||||
const junior = agentByKey(params.agentResult, "sisyphus-junior");
|
||||
@@ -94,7 +93,6 @@ export function applyToolConfig(params: {
|
||||
task: "allow",
|
||||
"task_*": "allow",
|
||||
teammate: "allow",
|
||||
...denyTodoTools,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,7 @@ 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 modelFallback = createModelFallbackHook()
|
||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||
const { handler, abortCalls, promptCalls } = createHandler()
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
@@ -96,8 +95,7 @@ describe("createEventHandler - model fallback", () => {
|
||||
//#given
|
||||
const sessionID = "ses_main_fallback_nested"
|
||||
setMainSession(sessionID)
|
||||
const modelFallback = createModelFallbackHook()
|
||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||
const { handler, abortCalls, promptCalls } = createHandler()
|
||||
|
||||
//#when
|
||||
await handler({
|
||||
@@ -342,64 +340,4 @@ 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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,58 +1,53 @@
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import type { PluginContext } from "./types";
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
import type { PluginContext } from "./types"
|
||||
|
||||
import {
|
||||
clearSessionAgent,
|
||||
getMainSessionID,
|
||||
getSessionAgent,
|
||||
setMainSession,
|
||||
subagentSessions,
|
||||
syncSubagentSessions,
|
||||
setMainSession,
|
||||
updateSessionAgent,
|
||||
} from "../features/claude-code-session-state";
|
||||
import {
|
||||
clearPendingModelFallback,
|
||||
clearSessionFallbackChain,
|
||||
setPendingModelFallback,
|
||||
} from "../hooks/model-fallback/hook";
|
||||
import { resetMessageCursor } from "../shared";
|
||||
import { log } from "../shared/logger";
|
||||
import { shouldRetryError } from "../shared/model-error-classifier";
|
||||
import { clearSessionModel, setSessionModel } from "../shared/session-model-state";
|
||||
import { deleteSessionTools } from "../shared/session-tools-store";
|
||||
import { lspManager } from "../tools";
|
||||
} from "../features/claude-code-session-state"
|
||||
import { resetMessageCursor } from "../shared"
|
||||
import { lspManager } from "../tools"
|
||||
import { shouldRetryError } from "../shared/model-error-classifier"
|
||||
import { clearPendingModelFallback, clearSessionFallbackChain, setPendingModelFallback } from "../hooks/model-fallback/hook"
|
||||
import { log } from "../shared/logger"
|
||||
import { clearSessionModel, setSessionModel } from "../shared/session-model-state"
|
||||
|
||||
import type { CreatedHooks } from "../create-hooks";
|
||||
import type { Managers } from "../create-managers";
|
||||
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles";
|
||||
import { normalizeSessionStatusToIdle } from "./session-status-normalizer";
|
||||
import type { CreatedHooks } from "../create-hooks"
|
||||
import type { Managers } from "../create-managers"
|
||||
import { normalizeSessionStatusToIdle } from "./session-status-normalizer"
|
||||
import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"
|
||||
|
||||
type FirstMessageVariantGate = {
|
||||
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void;
|
||||
clear: (sessionID: string) => void;
|
||||
};
|
||||
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void
|
||||
clear: (sessionID: string) => void
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function normalizeFallbackModelID(modelID: string): string {
|
||||
return modelID
|
||||
.replace(/-thinking$/i, "")
|
||||
.replace(/-max$/i, "")
|
||||
.replace(/-high$/i, "");
|
||||
.replace(/-high$/i, "")
|
||||
}
|
||||
|
||||
function extractErrorName(error: unknown): string | undefined {
|
||||
if (isRecord(error) && typeof error.name === "string") return error.name;
|
||||
if (error instanceof Error) return error.name;
|
||||
return undefined;
|
||||
if (isRecord(error) && typeof error.name === "string") return error.name
|
||||
if (error instanceof Error) return error.name
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) return error.message;
|
||||
if (!error) return ""
|
||||
if (typeof error === "string") return error
|
||||
if (error instanceof Error) return error.message
|
||||
|
||||
if (isRecord(error)) {
|
||||
const candidates: unknown[] = [
|
||||
@@ -61,115 +56,116 @@ function extractErrorMessage(error: unknown): string {
|
||||
error.error,
|
||||
isRecord(error.data) ? error.data.error : undefined,
|
||||
error.cause,
|
||||
];
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (isRecord(candidate) && typeof candidate.message === "string" && candidate.message.length > 0) {
|
||||
return candidate.message;
|
||||
return candidate.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
return JSON.stringify(error)
|
||||
} catch {
|
||||
return String(error);
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function extractProviderModelFromErrorMessage(message: string): { providerID?: string; modelID?: string } {
|
||||
const lower = message.toLowerCase();
|
||||
function extractProviderModelFromErrorMessage(
|
||||
message: string,
|
||||
): { providerID?: string; modelID?: string } {
|
||||
const lower = message.toLowerCase()
|
||||
|
||||
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i);
|
||||
const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i)
|
||||
if (providerModel) {
|
||||
return {
|
||||
providerID: providerModel[1],
|
||||
modelID: providerModel[2],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i);
|
||||
const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i)
|
||||
if (modelOnly) {
|
||||
return {
|
||||
modelID: modelOnly[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
type EventInput = Parameters<NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>>[0];
|
||||
type EventInput = Parameters<
|
||||
NonNullable<NonNullable<CreatedHooks["writeExistingFileGuard"]>["event"]>
|
||||
>[0]
|
||||
export function createEventHandler(args: {
|
||||
ctx: PluginContext;
|
||||
pluginConfig: OhMyOpenCodeConfig;
|
||||
firstMessageVariantGate: FirstMessageVariantGate;
|
||||
managers: Managers;
|
||||
hooks: CreatedHooks;
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
firstMessageVariantGate: FirstMessageVariantGate
|
||||
managers: Managers
|
||||
hooks: CreatedHooks
|
||||
}): (input: EventInput) => Promise<void> {
|
||||
const { ctx, firstMessageVariantGate, managers, hooks } = args;
|
||||
const { ctx, firstMessageVariantGate, managers, hooks } = args
|
||||
const pluginContext = ctx as {
|
||||
directory: string;
|
||||
directory: string
|
||||
client: {
|
||||
session: {
|
||||
abort: (input: { path: { id: string } }) => Promise<unknown>;
|
||||
abort: (input: { path: { id: string } }) => Promise<unknown>
|
||||
prompt: (input: {
|
||||
path: { id: string };
|
||||
body: { parts: Array<{ type: "text"; text: string }> };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
path: { id: string }
|
||||
body: { parts: Array<{ type: "text"; text: string }> }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
const isRuntimeFallbackEnabled =
|
||||
hooks.runtimeFallback !== null &&
|
||||
hooks.runtimeFallback !== undefined &&
|
||||
(typeof args.pluginConfig.runtime_fallback === "boolean"
|
||||
? args.pluginConfig.runtime_fallback
|
||||
: (args.pluginConfig.runtime_fallback?.enabled ?? false));
|
||||
|
||||
const isModelFallbackEnabled =
|
||||
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
|
||||
: (args.pluginConfig.runtime_fallback?.enabled ?? false))
|
||||
|
||||
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
|
||||
const lastHandledModelErrorMessageID = new Map<string, string>();
|
||||
const lastHandledRetryStatusKey = new Map<string, string>();
|
||||
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();
|
||||
const lastHandledModelErrorMessageID = new Map<string, string>()
|
||||
const lastHandledRetryStatusKey = new Map<string, string>()
|
||||
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>()
|
||||
|
||||
const dispatchToHooks = async (input: EventInput): Promise<void> => {
|
||||
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input));
|
||||
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
|
||||
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
|
||||
await Promise.resolve(hooks.sessionNotification?.(input));
|
||||
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
|
||||
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
|
||||
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
|
||||
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input));
|
||||
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input));
|
||||
await Promise.resolve(hooks.rulesInjector?.event?.(input));
|
||||
await Promise.resolve(hooks.thinkMode?.event?.(input));
|
||||
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input));
|
||||
await Promise.resolve(hooks.runtimeFallback?.event?.(input));
|
||||
await Promise.resolve(hooks.agentUsageReminder?.event?.(input));
|
||||
await Promise.resolve(hooks.categorySkillReminder?.event?.(input));
|
||||
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput));
|
||||
await Promise.resolve(hooks.ralphLoop?.event?.(input));
|
||||
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input));
|
||||
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
|
||||
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
|
||||
await Promise.resolve(hooks.atlasHook?.handler?.(input));
|
||||
};
|
||||
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input))
|
||||
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input))
|
||||
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input))
|
||||
await Promise.resolve(hooks.sessionNotification?.(input))
|
||||
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input))
|
||||
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input))
|
||||
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input))
|
||||
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input))
|
||||
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input))
|
||||
await Promise.resolve(hooks.rulesInjector?.event?.(input))
|
||||
await Promise.resolve(hooks.thinkMode?.event?.(input))
|
||||
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input))
|
||||
await Promise.resolve(hooks.runtimeFallback?.event?.(input))
|
||||
await Promise.resolve(hooks.agentUsageReminder?.event?.(input))
|
||||
await Promise.resolve(hooks.categorySkillReminder?.event?.(input))
|
||||
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput))
|
||||
await Promise.resolve(hooks.ralphLoop?.event?.(input))
|
||||
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input))
|
||||
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input))
|
||||
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input))
|
||||
await Promise.resolve(hooks.atlasHook?.handler?.(input))
|
||||
}
|
||||
|
||||
const recentSyntheticIdles = new Map<string, number>();
|
||||
const recentRealIdles = new Map<string, number>();
|
||||
const DEDUP_WINDOW_MS = 500;
|
||||
const recentSyntheticIdles = new Map<string, number>()
|
||||
const recentRealIdles = new Map<string, number>()
|
||||
const DEDUP_WINDOW_MS = 500
|
||||
|
||||
const shouldAutoRetrySession = (sessionID: string): boolean => {
|
||||
if (syncSubagentSessions.has(sessionID)) return true;
|
||||
const mainSessionID = getMainSessionID();
|
||||
if (mainSessionID) return sessionID === mainSessionID;
|
||||
if (syncSubagentSessions.has(sessionID)) return true
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID) return sessionID === mainSessionID
|
||||
// Headless runs (or resumed sessions) may not emit session.created, so mainSessionID can be unset.
|
||||
// In that case, treat any non-subagent session as the "main" interactive session.
|
||||
return !subagentSessions.has(sessionID);
|
||||
};
|
||||
return !subagentSessions.has(sessionID)
|
||||
}
|
||||
|
||||
return async (input): Promise<void> => {
|
||||
pruneRecentSyntheticIdles({
|
||||
@@ -177,227 +173,230 @@ export function createEventHandler(args: {
|
||||
recentRealIdles,
|
||||
now: Date.now(),
|
||||
dedupWindowMs: DEDUP_WINDOW_MS,
|
||||
});
|
||||
})
|
||||
|
||||
if (input.event.type === "session.idle") {
|
||||
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as
|
||||
| string
|
||||
| undefined;
|
||||
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const emittedAt = recentSyntheticIdles.get(sessionID);
|
||||
const emittedAt = recentSyntheticIdles.get(sessionID)
|
||||
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
|
||||
recentSyntheticIdles.delete(sessionID);
|
||||
return;
|
||||
recentSyntheticIdles.delete(sessionID)
|
||||
return
|
||||
}
|
||||
recentRealIdles.set(sessionID, Date.now());
|
||||
recentRealIdles.set(sessionID, Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
await dispatchToHooks(input);
|
||||
await dispatchToHooks(input)
|
||||
|
||||
const syntheticIdle = normalizeSessionStatusToIdle(input);
|
||||
const syntheticIdle = normalizeSessionStatusToIdle(input)
|
||||
if (syntheticIdle) {
|
||||
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string;
|
||||
const emittedAt = recentRealIdles.get(sessionID);
|
||||
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string
|
||||
const emittedAt = recentRealIdles.get(sessionID)
|
||||
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
|
||||
recentRealIdles.delete(sessionID);
|
||||
return;
|
||||
recentRealIdles.delete(sessionID)
|
||||
return
|
||||
}
|
||||
recentSyntheticIdles.set(sessionID, Date.now());
|
||||
await dispatchToHooks(syntheticIdle as EventInput);
|
||||
recentSyntheticIdles.set(sessionID, Date.now())
|
||||
await dispatchToHooks(syntheticIdle as EventInput)
|
||||
}
|
||||
|
||||
const { event } = input;
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
const { event } = input
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.created") {
|
||||
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined;
|
||||
const sessionInfo = props?.info as
|
||||
| { id?: string; title?: string; parentID?: string }
|
||||
| undefined
|
||||
|
||||
if (!sessionInfo?.parentID) {
|
||||
setMainSession(sessionInfo?.id);
|
||||
setMainSession(sessionInfo?.id)
|
||||
}
|
||||
|
||||
firstMessageVariantGate.markSessionCreated(sessionInfo);
|
||||
firstMessageVariantGate.markSessionCreated(sessionInfo)
|
||||
|
||||
await managers.tmuxSessionManager.onSessionCreated(
|
||||
event as {
|
||||
type: string;
|
||||
type: string
|
||||
properties?: {
|
||||
info?: { id?: string; parentID?: string; title?: string };
|
||||
};
|
||||
info?: { id?: string; parentID?: string; title?: string }
|
||||
}
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id === getMainSessionID()) {
|
||||
setMainSession(undefined);
|
||||
setMainSession(undefined)
|
||||
}
|
||||
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionAgent(sessionInfo.id);
|
||||
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
||||
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
||||
lastKnownModelBySession.delete(sessionInfo.id);
|
||||
clearPendingModelFallback(sessionInfo.id);
|
||||
clearSessionFallbackChain(sessionInfo.id);
|
||||
resetMessageCursor(sessionInfo.id);
|
||||
firstMessageVariantGate.clear(sessionInfo.id);
|
||||
clearSessionModel(sessionInfo.id);
|
||||
syncSubagentSessions.delete(sessionInfo.id);
|
||||
deleteSessionTools(sessionInfo.id);
|
||||
await managers.skillMcpManager.disconnectSession(sessionInfo.id);
|
||||
await lspManager.cleanupTempDirectoryClients();
|
||||
clearSessionAgent(sessionInfo.id)
|
||||
lastHandledModelErrorMessageID.delete(sessionInfo.id)
|
||||
lastHandledRetryStatusKey.delete(sessionInfo.id)
|
||||
lastKnownModelBySession.delete(sessionInfo.id)
|
||||
clearPendingModelFallback(sessionInfo.id)
|
||||
clearSessionFallbackChain(sessionInfo.id)
|
||||
resetMessageCursor(sessionInfo.id)
|
||||
firstMessageVariantGate.clear(sessionInfo.id)
|
||||
clearSessionModel(sessionInfo.id)
|
||||
syncSubagentSessions.delete(sessionInfo.id)
|
||||
await managers.skillMcpManager.disconnectSession(sessionInfo.id)
|
||||
await lspManager.cleanupTempDirectoryClients()
|
||||
await managers.tmuxSessionManager.onSessionDeleted({
|
||||
sessionID: sessionInfo.id,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined;
|
||||
const sessionID = info?.sessionID as string | undefined;
|
||||
const agent = info?.agent as string | undefined;
|
||||
const role = info?.role as string | undefined;
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const agent = info?.agent as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
if (sessionID && role === "user") {
|
||||
if (agent) {
|
||||
updateSessionAgent(sessionID, agent);
|
||||
updateSessionAgent(sessionID, agent)
|
||||
}
|
||||
const providerID = info?.providerID as string | undefined;
|
||||
const modelID = info?.modelID as string | undefined;
|
||||
const providerID = info?.providerID as string | undefined
|
||||
const modelID = info?.modelID as string | undefined
|
||||
if (providerID && modelID) {
|
||||
lastKnownModelBySession.set(sessionID, { providerID, modelID });
|
||||
setSessionModel(sessionID, { providerID, modelID });
|
||||
lastKnownModelBySession.set(sessionID, { providerID, modelID })
|
||||
setSessionModel(sessionID, { providerID, modelID })
|
||||
}
|
||||
}
|
||||
|
||||
// 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 && isModelFallbackEnabled) {
|
||||
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled) {
|
||||
try {
|
||||
const assistantMessageID = info?.id as string | undefined;
|
||||
const assistantError = info?.error;
|
||||
const assistantMessageID = info?.id as string | undefined
|
||||
const assistantError = info?.error
|
||||
if (assistantMessageID && assistantError) {
|
||||
const lastHandled = lastHandledModelErrorMessageID.get(sessionID);
|
||||
const lastHandled = lastHandledModelErrorMessageID.get(sessionID)
|
||||
if (lastHandled === assistantMessageID) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const errorName = extractErrorName(assistantError);
|
||||
const errorMessage = extractErrorMessage(assistantError);
|
||||
const errorInfo = { name: errorName, message: errorMessage };
|
||||
const errorName = extractErrorName(assistantError)
|
||||
const errorMessage = extractErrorMessage(assistantError)
|
||||
const errorInfo = { name: errorName, message: errorMessage }
|
||||
|
||||
if (shouldRetryError(errorInfo)) {
|
||||
// Prefer the agent/model/provider from the assistant message payload.
|
||||
let agentName = agent ?? getSessionAgent(sessionID);
|
||||
let agentName = agent ?? getSessionAgent(sessionID)
|
||||
if (!agentName && sessionID === getMainSessionID()) {
|
||||
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
|
||||
agentName = "sisyphus";
|
||||
agentName = "sisyphus"
|
||||
} else if (errorMessage.includes("gpt-5")) {
|
||||
agentName = "hephaestus";
|
||||
agentName = "hephaestus"
|
||||
} else {
|
||||
agentName = "sisyphus";
|
||||
agentName = "sisyphus"
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
const currentProvider = (info?.providerID as string | undefined) ?? "opencode";
|
||||
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6";
|
||||
const currentModel = normalizeFallbackModelID(rawModel);
|
||||
const currentProvider = (info?.providerID as string | undefined) ?? "opencode"
|
||||
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"
|
||||
const currentModel = normalizeFallbackModelID(rawModel)
|
||||
|
||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
||||
const setFallback = setPendingModelFallback(
|
||||
sessionID,
|
||||
agentName,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
)
|
||||
|
||||
if (
|
||||
setFallback &&
|
||||
shouldAutoRetrySession(sessionID) &&
|
||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||
) {
|
||||
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID);
|
||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
|
||||
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log("[event] model-fallback error in message.updated:", { sessionID, error: err });
|
||||
log("[event] model-fallback error in message.updated:", { sessionID, error: err })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.status") {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
|
||||
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" && isModelFallbackEnabled) {
|
||||
if (sessionID && status?.type === "retry") {
|
||||
try {
|
||||
const retryMessage = typeof status.message === "string" ? status.message : "";
|
||||
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`;
|
||||
const retryMessage = typeof status.message === "string" ? status.message : ""
|
||||
const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`
|
||||
if (lastHandledRetryStatusKey.get(sessionID) === retryKey) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
lastHandledRetryStatusKey.set(sessionID, retryKey);
|
||||
lastHandledRetryStatusKey.set(sessionID, retryKey)
|
||||
|
||||
const errorInfo = { name: undefined as string | undefined, message: retryMessage };
|
||||
const errorInfo = { name: undefined as string | undefined, message: retryMessage }
|
||||
if (shouldRetryError(errorInfo)) {
|
||||
let agentName = getSessionAgent(sessionID);
|
||||
let agentName = getSessionAgent(sessionID)
|
||||
if (!agentName && sessionID === getMainSessionID()) {
|
||||
if (retryMessage.includes("claude-opus") || retryMessage.includes("opus")) {
|
||||
agentName = "sisyphus";
|
||||
agentName = "sisyphus"
|
||||
} else if (retryMessage.includes("gpt-5")) {
|
||||
agentName = "hephaestus";
|
||||
agentName = "hephaestus"
|
||||
} else {
|
||||
agentName = "sisyphus";
|
||||
agentName = "sisyphus"
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
const parsed = extractProviderModelFromErrorMessage(retryMessage);
|
||||
const lastKnown = lastKnownModelBySession.get(sessionID);
|
||||
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode";
|
||||
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6";
|
||||
currentModel = normalizeFallbackModelID(currentModel);
|
||||
const parsed = extractProviderModelFromErrorMessage(retryMessage)
|
||||
const lastKnown = lastKnownModelBySession.get(sessionID)
|
||||
const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"
|
||||
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"
|
||||
currentModel = normalizeFallbackModelID(currentModel)
|
||||
|
||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
||||
const setFallback = setPendingModelFallback(
|
||||
sessionID,
|
||||
agentName,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
)
|
||||
|
||||
if (
|
||||
setFallback &&
|
||||
shouldAutoRetrySession(sessionID) &&
|
||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||
) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
|
||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log("[event] model-fallback error in session.status:", { sessionID, error: err });
|
||||
log("[event] model-fallback error in session.status:", { sessionID, error: err })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
try {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const error = props?.error;
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const error = props?.error
|
||||
|
||||
const errorName = extractErrorName(error);
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
const errorInfo = { name: errorName, message: errorMessage };
|
||||
const errorName = extractErrorName(error)
|
||||
const errorMessage = extractErrorMessage(error)
|
||||
const errorInfo = { name: errorName, message: errorMessage }
|
||||
|
||||
// First, try session recovery for internal errors (thinking blocks, tool results, etc.)
|
||||
if (hooks.sessionRecovery?.isRecoverableError(error)) {
|
||||
@@ -406,8 +405,8 @@ export function createEventHandler(args: {
|
||||
role: "assistant" as const,
|
||||
sessionID,
|
||||
error,
|
||||
};
|
||||
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo);
|
||||
}
|
||||
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo)
|
||||
|
||||
if (
|
||||
recovered &&
|
||||
@@ -421,52 +420,53 @@ export function createEventHandler(args: {
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
|
||||
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled && isModelFallbackEnabled) {
|
||||
let agentName = getSessionAgent(sessionID);
|
||||
|
||||
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) {
|
||||
let agentName = getSessionAgent(sessionID)
|
||||
|
||||
if (!agentName && sessionID === getMainSessionID()) {
|
||||
if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) {
|
||||
agentName = "sisyphus";
|
||||
agentName = "sisyphus"
|
||||
} else if (errorMessage.includes("gpt-5")) {
|
||||
agentName = "hephaestus";
|
||||
agentName = "hephaestus"
|
||||
} else {
|
||||
agentName = "sisyphus";
|
||||
agentName = "sisyphus"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (agentName) {
|
||||
const parsed = extractProviderModelFromErrorMessage(errorMessage);
|
||||
const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode";
|
||||
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6";
|
||||
currentModel = normalizeFallbackModelID(currentModel);
|
||||
const parsed = extractProviderModelFromErrorMessage(errorMessage)
|
||||
const currentProvider = props?.providerID as string || parsed.providerID || "opencode"
|
||||
let currentModel = props?.modelID as string || parsed.modelID || "claude-opus-4-6"
|
||||
currentModel = normalizeFallbackModelID(currentModel)
|
||||
|
||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
||||
|
||||
if (
|
||||
setFallback &&
|
||||
shouldAutoRetrySession(sessionID) &&
|
||||
!hooks.stopContinuationGuard?.isStopped(sessionID)
|
||||
) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {});
|
||||
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
const setFallback = setPendingModelFallback(
|
||||
sessionID,
|
||||
agentName,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
)
|
||||
|
||||
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
|
||||
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
|
||||
await pluginContext.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: pluginContext.directory },
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
log("[event] model-fallback error in session.error:", { sessionID, error: err });
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
log("[event] model-fallback error in session.error:", { sessionID, error: err })
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function createSessionHooks(args: {
|
||||
isHookEnabled("preemptive-compaction") &&
|
||||
pluginConfig.experimental?.preemptive_compaction
|
||||
? safeHook("preemptive-compaction", () =>
|
||||
createPreemptiveCompactionHook(ctx, pluginConfig, modelCacheState))
|
||||
createPreemptiveCompactionHook(ctx, modelCacheState))
|
||||
: null
|
||||
|
||||
const sessionRecovery = isHookEnabled("session-recovery")
|
||||
@@ -151,10 +151,9 @@ export function createSessionHooks(args: {
|
||||
}
|
||||
}
|
||||
|
||||
// Model fallback hook (configurable via model_fallback config + disabled_hooks)
|
||||
// Model fallback hook (configurable via disabled_hooks)
|
||||
// This handles automatic model switching when model errors occur
|
||||
const isModelFallbackConfigEnabled = pluginConfig.model_fallback ?? false
|
||||
const modelFallback = isModelFallbackConfigEnabled && isHookEnabled("model-fallback")
|
||||
const modelFallback = isHookEnabled("model-fallback")
|
||||
? safeHook("model-fallback", () =>
|
||||
createModelFallbackHook({
|
||||
toast: async ({ title, message, variant, duration }) => {
|
||||
@@ -175,7 +174,7 @@ export function createSessionHooks(args: {
|
||||
|
||||
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
|
||||
? safeHook("anthropic-context-window-limit-recovery", () =>
|
||||
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental, pluginConfig }))
|
||||
createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental: pluginConfig.experimental }))
|
||||
: null
|
||||
|
||||
const autoUpdateChecker = isHookEnabled("auto-update-checker")
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
const { describe, expect, test, spyOn } = require("bun:test")
|
||||
|
||||
const sessionState = require("../features/claude-code-session-state")
|
||||
const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
|
||||
|
||||
describe("createToolExecuteBeforeHandler session notification sessionID", () => {
|
||||
test("uses main session fallback when input sessionID is empty", async () => {
|
||||
const mainSessionID = "ses_main"
|
||||
const getMainSessionIDSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(mainSessionID)
|
||||
|
||||
let capturedSessionID: string | undefined
|
||||
const hooks = {
|
||||
sessionNotification: async (input) => {
|
||||
capturedSessionID = input.event.properties?.sessionID
|
||||
},
|
||||
}
|
||||
|
||||
const handler = createToolExecuteBeforeHandler({
|
||||
ctx: { client: { session: { messages: async () => ({ data: [] }) } } },
|
||||
hooks,
|
||||
})
|
||||
|
||||
await handler(
|
||||
{ tool: "question", sessionID: "", callID: "call_q" },
|
||||
{ args: { questions: [{ question: "Continue?", options: [{ label: "Yes" }] }] } },
|
||||
)
|
||||
|
||||
expect(getMainSessionIDSpy).toHaveBeenCalled()
|
||||
expect(capturedSessionID).toBe(mainSessionID)
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -31,60 +31,6 @@ describe("createToolExecuteBeforeHandler", () => {
|
||||
await expect(run).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("triggers session notification hook for question tools", async () => {
|
||||
let called = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const hooks = {
|
||||
sessionNotification: async (input: { event: { type: string; properties?: Record<string, unknown> } }) => {
|
||||
called = true
|
||||
expect(input.event.type).toBe("tool.execute.before")
|
||||
expect(input.event.properties?.sessionID).toBe("ses_q")
|
||||
expect(input.event.properties?.tool).toBe("question")
|
||||
},
|
||||
}
|
||||
|
||||
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
|
||||
const input = { tool: "question", sessionID: "ses_q", callID: "call_q" }
|
||||
const output = { args: { questions: [{ question: "Proceed?", options: [{ label: "Yes" }] }] } as Record<string, unknown> }
|
||||
|
||||
await handler(input, output)
|
||||
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
|
||||
test("does not trigger session notification hook for non-question tools", async () => {
|
||||
let called = false
|
||||
const ctx = {
|
||||
client: {
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const hooks = {
|
||||
sessionNotification: async () => {
|
||||
called = true
|
||||
},
|
||||
}
|
||||
|
||||
const handler = createToolExecuteBeforeHandler({ ctx, hooks })
|
||||
|
||||
await handler(
|
||||
{ tool: "bash", sessionID: "ses_b", callID: "call_b" },
|
||||
{ args: { command: "pwd" } as Record<string, unknown> },
|
||||
)
|
||||
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
describe("task tool subagent_type normalization", () => {
|
||||
const emptyHooks = {}
|
||||
|
||||
|
||||
@@ -30,26 +30,6 @@ export function createToolExecuteBeforeHandler(args: {
|
||||
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
|
||||
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
|
||||
|
||||
const normalizedToolName = input.tool.toLowerCase()
|
||||
if (
|
||||
normalizedToolName === "question"
|
||||
|| normalizedToolName === "ask_user_question"
|
||||
|| normalizedToolName === "askuserquestion"
|
||||
) {
|
||||
const sessionID = input.sessionID || getMainSessionID()
|
||||
await hooks.sessionNotification?.({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: {
|
||||
sessionID,
|
||||
tool: input.tool,
|
||||
args: output.args,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (input.tool === "task") {
|
||||
const argsObject = output.args
|
||||
const category = typeof argsObject.category === "string" ? argsObject.category : undefined
|
||||
|
||||
@@ -1,129 +1,78 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { getHomeDirectory } from "./home-directory";
|
||||
import { findBashPath, findZshPath } from "./shell-path";
|
||||
import { spawn } from "node:child_process"
|
||||
import { getHomeDirectory } from "./home-directory"
|
||||
import { findBashPath, findZshPath } from "./shell-path"
|
||||
|
||||
export interface CommandResult {
|
||||
exitCode: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode: number
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
|
||||
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
|
||||
const SIGKILL_GRACE_MS = 5_000;
|
||||
|
||||
export interface ExecuteHookOptions {
|
||||
forceZsh?: boolean;
|
||||
zshPath?: string;
|
||||
/** Timeout in milliseconds. Process is killed after this. Default: 30000 */
|
||||
timeoutMs?: number;
|
||||
forceZsh?: boolean
|
||||
zshPath?: string
|
||||
}
|
||||
|
||||
export async function executeHookCommand(
|
||||
command: string,
|
||||
stdin: string,
|
||||
cwd: string,
|
||||
options?: ExecuteHookOptions,
|
||||
command: string,
|
||||
stdin: string,
|
||||
cwd: string,
|
||||
options?: ExecuteHookOptions,
|
||||
): Promise<CommandResult> {
|
||||
const home = getHomeDirectory();
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
||||
const home = getHomeDirectory()
|
||||
|
||||
const expandedCommand = command
|
||||
.replace(/^~(?=\/|$)/g, home)
|
||||
.replace(/\s~(?=\/)/g, ` ${home}`)
|
||||
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
|
||||
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd);
|
||||
const expandedCommand = command
|
||||
.replace(/^~(?=\/|$)/g, home)
|
||||
.replace(/\s~(?=\/)/g, ` ${home}`)
|
||||
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
|
||||
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
|
||||
|
||||
let finalCommand = expandedCommand;
|
||||
let finalCommand = expandedCommand
|
||||
|
||||
if (options?.forceZsh) {
|
||||
const zshPath = findZshPath(options.zshPath);
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''");
|
||||
if (zshPath) {
|
||||
finalCommand = `${zshPath} -lc '${escapedCommand}'`;
|
||||
} else {
|
||||
const bashPath = findBashPath();
|
||||
if (bashPath) {
|
||||
finalCommand = `${bashPath} -lc '${escapedCommand}'`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options?.forceZsh) {
|
||||
const zshPath = findZshPath(options.zshPath)
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
if (zshPath) {
|
||||
finalCommand = `${zshPath} -lc '${escapedCommand}'`
|
||||
} else {
|
||||
const bashPath = findBashPath()
|
||||
if (bashPath) {
|
||||
finalCommand = `${bashPath} -lc '${escapedCommand}'`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(finalCommand, {
|
||||
cwd,
|
||||
shell: true,
|
||||
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
|
||||
})
|
||||
|
||||
const isWin32 = process.platform === "win32";
|
||||
const proc = spawn(finalCommand, {
|
||||
cwd,
|
||||
shell: true,
|
||||
detached: !isWin32,
|
||||
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
|
||||
});
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.stdin?.write(stdin)
|
||||
proc.stdin?.end()
|
||||
|
||||
proc.stdin?.on("error", () => {});
|
||||
proc.stdin?.write(stdin);
|
||||
proc.stdin?.end();
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
})
|
||||
})
|
||||
|
||||
const settle = (result: CommandResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
proc.on("close", code => {
|
||||
settle({
|
||||
exitCode: code ?? 1,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", err => {
|
||||
settle({ exitCode: 1, stderr: err.message });
|
||||
});
|
||||
|
||||
const killProcessGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
if (!isWin32 && proc.pid) {
|
||||
try {
|
||||
process.kill(-proc.pid, signal);
|
||||
} catch {
|
||||
proc.kill(signal);
|
||||
}
|
||||
} else {
|
||||
proc.kill(signal);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
// Kill entire process group to avoid orphaned children
|
||||
killProcessGroup("SIGTERM");
|
||||
killTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
killProcessGroup("SIGKILL");
|
||||
}, SIGKILL_GRACE_MS);
|
||||
// Append timeout notice to stderr
|
||||
stderr += `\nHook command timed out after ${timeoutMs}ms`;
|
||||
}, timeoutMs);
|
||||
|
||||
// Don't let the timeout timer keep the process alive
|
||||
if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) {
|
||||
timeoutTimer.unref();
|
||||
}
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
resolve({ exitCode: 1, stderr: err.message })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ export async function fetchAvailableModels(
|
||||
// Handle both string[] (legacy) and object[] (with metadata) formats
|
||||
const modelId = typeof modelItem === 'string'
|
||||
? modelItem
|
||||
: modelItem?.id
|
||||
: (modelItem as any)?.id
|
||||
|
||||
if (modelId) {
|
||||
modelSet.add(`${providerId}/${modelId}`)
|
||||
|
||||
52
src/shared/models-json-cache-reader.ts
Normal file
52
src/shared/models-json-cache-reader.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
20
src/shared/open-code-client-accessors.ts
Normal file
20
src/shared/open-code-client-accessors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
}
|
||||
7
src/shared/open-code-client-shapes.ts
Normal file
7
src/shared/open-code-client-shapes.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
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>
|
||||
39
src/shared/provider-models-cache-model-reader.ts
Normal file
39
src/shared/provider-models-cache-model-reader.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
const store = new Map<string, Record<string, boolean>>();
|
||||
const store = new Map<string, Record<string, boolean>>()
|
||||
|
||||
export function setSessionTools(sessionID: string, tools: Record<string, boolean>): void {
|
||||
store.set(sessionID, { ...tools });
|
||||
store.set(sessionID, { ...tools })
|
||||
}
|
||||
|
||||
export function getSessionTools(sessionID: string): Record<string, boolean> | undefined {
|
||||
const tools = store.get(sessionID);
|
||||
return tools ? { ...tools } : undefined;
|
||||
}
|
||||
|
||||
export function deleteSessionTools(sessionID: string): void {
|
||||
store.delete(sessionID);
|
||||
const tools = store.get(sessionID)
|
||||
return tools ? { ...tools } : undefined
|
||||
}
|
||||
|
||||
export function clearSessionTools(): void {
|
||||
store.clear();
|
||||
store.clear()
|
||||
}
|
||||
|
||||
@@ -2,11 +2,6 @@ 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
|
||||
@@ -25,8 +20,9 @@ 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: SDKMessage) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
(m: any) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
|
||||
if (relevantMessages.length === 0) {
|
||||
@@ -38,7 +34,8 @@ export async function processMessages(
|
||||
log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`)
|
||||
|
||||
// Sort by time ascending (oldest first) to process messages in order
|
||||
const sortedMessages = [...relevantMessages].sort((a: SDKMessage, b: SDKMessage) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sortedMessages = [...relevantMessages].sort((a: any, b: any) => {
|
||||
const timeA = a.info?.time?.created ?? 0
|
||||
const timeB = b.info?.time?.created ?? 0
|
||||
return timeA - timeB
|
||||
@@ -55,11 +52,12 @@ export async function processMessages(
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of newMessages) {
|
||||
for (const part of message.parts ?? []) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
for (const part of (message as any).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 as string) === "tool_result") {
|
||||
} else if (part.type === "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) {
|
||||
|
||||
76
src/tools/call-omo-agent/session-completion-poller.ts
Normal file
76
src/tools/call-omo-agent/session-completion-poller.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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" }
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export async function createOrGetSession(
|
||||
body: {
|
||||
parentID: toolContext.sessionID,
|
||||
title: `${args.description} (@${args.subagent_type} subagent)`,
|
||||
} as Record<string, unknown>,
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
|
||||
93
src/tools/call-omo-agent/session-message-output-extractor.ts
Normal file
93
src/tools/call-omo-agent/session-message-output-extractor.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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 }
|
||||
}
|
||||
27
src/tools/call-omo-agent/subagent-session-prompter.ts
Normal file
27
src/tools/call-omo-agent/subagent-session-prompter.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,6 @@ 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
|
||||
@@ -45,7 +41,7 @@ export async function executeSync(
|
||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||
|
||||
try {
|
||||
await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({
|
||||
await (ctx.client.session as any).promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: args.subagent_type,
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function createSyncSession(
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `${input.description} (@${input.agentToUse} subagent)`,
|
||||
} as Record<string, unknown>,
|
||||
} as any,
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
DEFAULT_MAX_DEPTH,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
RG_FILES_FLAGS,
|
||||
DEFAULT_RG_THREADS,
|
||||
} from "./constants"
|
||||
import type { GlobOptions, GlobResult, FileMatch } from "./types"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { rgSemaphore } from "../shared/semaphore"
|
||||
|
||||
export interface ResolvedCli {
|
||||
path: string
|
||||
@@ -21,7 +19,6 @@ export interface ResolvedCli {
|
||||
function buildRgArgs(options: GlobOptions): string[] {
|
||||
const args: string[] = [
|
||||
...RG_FILES_FLAGS,
|
||||
`--threads=${Math.min(options.threads ?? DEFAULT_RG_THREADS, DEFAULT_RG_THREADS)}`,
|
||||
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
|
||||
]
|
||||
|
||||
@@ -94,18 +91,6 @@ export { buildRgArgs, buildFindArgs, buildPowerShellCommand }
|
||||
export async function runRgFiles(
|
||||
options: GlobOptions,
|
||||
resolvedCli?: ResolvedCli
|
||||
): Promise<GlobResult> {
|
||||
await rgSemaphore.acquire()
|
||||
try {
|
||||
return await runRgFilesInternal(options, resolvedCli)
|
||||
} finally {
|
||||
rgSemaphore.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function runRgFilesInternal(
|
||||
options: GlobOptions,
|
||||
resolvedCli?: ResolvedCli
|
||||
): Promise<GlobResult> {
|
||||
const cli = resolvedCli ?? resolveGrepCli()
|
||||
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../grep/constants"
|
||||
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend } from "../grep/constants"
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||
export const DEFAULT_LIMIT = 100
|
||||
|
||||
@@ -19,5 +19,4 @@ export interface GlobOptions {
|
||||
maxDepth?: number
|
||||
timeout?: number
|
||||
limit?: number
|
||||
threads?: number // limit rg thread count
|
||||
}
|
||||
|
||||
@@ -8,17 +8,14 @@ import {
|
||||
DEFAULT_MAX_COLUMNS,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
DEFAULT_RG_THREADS,
|
||||
RG_SAFETY_FLAGS,
|
||||
GREP_SAFETY_FLAGS,
|
||||
} from "./constants"
|
||||
import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types"
|
||||
import { rgSemaphore } from "../shared/semaphore"
|
||||
|
||||
function buildRgArgs(options: GrepOptions): string[] {
|
||||
const args: string[] = [
|
||||
...RG_SAFETY_FLAGS,
|
||||
`--threads=${Math.min(options.threads ?? DEFAULT_RG_THREADS, DEFAULT_RG_THREADS)}`,
|
||||
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
|
||||
`--max-filesize=${options.maxFilesize ?? DEFAULT_MAX_FILESIZE}`,
|
||||
`--max-count=${Math.min(options.maxCount ?? DEFAULT_MAX_COUNT, DEFAULT_MAX_COUNT)}`,
|
||||
@@ -54,12 +51,6 @@ function buildRgArgs(options: GrepOptions): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.outputMode === "files_with_matches") {
|
||||
args.push("--files-with-matches")
|
||||
} else if (options.outputMode === "count") {
|
||||
args.push("--count")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -95,7 +86,7 @@ function buildArgs(options: GrepOptions, backend: GrepBackend): string[] {
|
||||
return backend === "rg" ? buildRgArgs(options) : buildGrepArgs(options)
|
||||
}
|
||||
|
||||
function parseOutput(output: string, filesOnly = false): GrepMatch[] {
|
||||
function parseOutput(output: string): GrepMatch[] {
|
||||
if (!output.trim()) return []
|
||||
|
||||
const matches: GrepMatch[] = []
|
||||
@@ -104,16 +95,6 @@ function parseOutput(output: string, filesOnly = false): GrepMatch[] {
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
if (filesOnly) {
|
||||
// --files-with-matches outputs only file paths, one per line
|
||||
matches.push({
|
||||
file: line.trim(),
|
||||
line: 0,
|
||||
text: "",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const match = line.match(/^(.+?):(\d+):(.*)$/)
|
||||
if (match) {
|
||||
matches.push({
|
||||
@@ -149,15 +130,6 @@ function parseCountOutput(output: string): CountResult[] {
|
||||
}
|
||||
|
||||
export async function runRg(options: GrepOptions): Promise<GrepResult> {
|
||||
await rgSemaphore.acquire()
|
||||
try {
|
||||
return await runRgInternal(options)
|
||||
} finally {
|
||||
rgSemaphore.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function runRgInternal(options: GrepOptions): Promise<GrepResult> {
|
||||
const cli = resolveGrepCli()
|
||||
const args = buildArgs(options, cli.backend)
|
||||
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||
@@ -201,17 +173,14 @@ async function runRgInternal(options: GrepOptions): Promise<GrepResult> {
|
||||
}
|
||||
}
|
||||
|
||||
const matches = parseOutput(outputToProcess, options.outputMode === "files_with_matches")
|
||||
const limited = options.headLimit && options.headLimit > 0
|
||||
? matches.slice(0, options.headLimit)
|
||||
: matches
|
||||
const filesSearched = new Set(limited.map((m) => m.file)).size
|
||||
const matches = parseOutput(outputToProcess)
|
||||
const filesSearched = new Set(matches.map((m) => m.file)).size
|
||||
|
||||
return {
|
||||
matches: limited,
|
||||
totalMatches: limited.length,
|
||||
matches,
|
||||
totalMatches: matches.length,
|
||||
filesSearched,
|
||||
truncated: truncated || (options.headLimit ? matches.length > options.headLimit : false),
|
||||
truncated,
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
@@ -225,15 +194,6 @@ async function runRgInternal(options: GrepOptions): Promise<GrepResult> {
|
||||
}
|
||||
|
||||
export async function runRgCount(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
|
||||
await rgSemaphore.acquire()
|
||||
try {
|
||||
return await runRgCountInternal(options)
|
||||
} finally {
|
||||
rgSemaphore.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function runRgCountInternal(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
|
||||
const cli = resolveGrepCli()
|
||||
const args = buildArgs({ ...options, context: 0 }, cli.backend)
|
||||
|
||||
|
||||
@@ -113,9 +113,8 @@ export const DEFAULT_MAX_FILESIZE = "10M"
|
||||
export const DEFAULT_MAX_COUNT = 500
|
||||
export const DEFAULT_MAX_COLUMNS = 1000
|
||||
export const DEFAULT_CONTEXT = 2
|
||||
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024
|
||||
export const DEFAULT_RG_THREADS = 4
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export const RG_SAFETY_FLAGS = [
|
||||
"--no-follow",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user