opencode-dux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/agents/descriptions.d.ts +6 -0
- package/dist/agents/designer.d.ts +2 -0
- package/dist/agents/explorer.d.ts +2 -0
- package/dist/agents/fixer.d.ts +2 -0
- package/dist/agents/index.d.ts +22 -0
- package/dist/agents/interpreter.d.ts +2 -0
- package/dist/agents/librarian.d.ts +2 -0
- package/dist/agents/oracle.d.ts +2 -0
- package/dist/agents/orchestrator.d.ts +27 -0
- package/dist/agents/overrides.d.ts +18 -0
- package/dist/agents/prompt-blocks.d.ts +97 -0
- package/dist/agents/steward.d.ts +3 -0
- package/dist/cli/config-io.d.ts +24 -0
- package/dist/cli/config-manager.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1006 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/mcps.d.ts +13 -0
- package/dist/cli/model-key-normalization.d.ts +1 -0
- package/dist/cli/paths.d.ts +35 -0
- package/dist/cli/providers.d.ts +137 -0
- package/dist/cli/skills.d.ts +22 -0
- package/dist/cli/system.d.ts +5 -0
- package/dist/cli/types.d.ts +38 -0
- package/dist/config/constants.d.ts +12 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/loader.d.ts +40 -0
- package/dist/config/runtime-preset.d.ts +12 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/utils.d.ts +10 -0
- package/dist/discovery/local/types.d.ts +79 -0
- package/dist/discovery/local.d.ts +73 -0
- package/dist/discovery/mcp-servers.d.ts +88 -0
- package/dist/discovery/skills.d.ts +94 -0
- package/dist/hooks/apply-patch/codec.d.ts +7 -0
- package/dist/hooks/apply-patch/errors.d.ts +25 -0
- package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
- package/dist/hooks/apply-patch/index.d.ts +15 -0
- package/dist/hooks/apply-patch/matching.d.ts +26 -0
- package/dist/hooks/apply-patch/operations.d.ts +3 -0
- package/dist/hooks/apply-patch/patch.d.ts +2 -0
- package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
- package/dist/hooks/apply-patch/resolution.d.ts +19 -0
- package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
- package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
- package/dist/hooks/apply-patch/types.d.ts +80 -0
- package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
- package/dist/hooks/auto-update-checker/index.d.ts +18 -0
- package/dist/hooks/auto-update-checker/types.d.ts +22 -0
- package/dist/hooks/chat-headers.d.ts +16 -0
- package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
- package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
- package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
- package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
- package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
- package/dist/hooks/filter-available-skills/index.d.ts +32 -0
- package/dist/hooks/foreground-fallback/index.d.ts +72 -0
- package/dist/hooks/image-hook.d.ts +5 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
- package/dist/hooks/json-error-recovery/index.d.ts +1 -0
- package/dist/hooks/phase-reminder/index.d.ts +26 -0
- package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
- package/dist/hooks/task-session-manager/index.d.ts +52 -0
- package/dist/hooks/todo-continuation/index.d.ts +53 -0
- package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +31782 -0
- package/dist/mcp/context7.d.ts +6 -0
- package/dist/mcp/grep-app.d.ts +6 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/websearch.d.ts +9 -0
- package/dist/skills/registry.d.ts +29 -0
- package/dist/subscriptions/accounts-store.d.ts +57 -0
- package/dist/subscriptions/index.d.ts +13 -0
- package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
- package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
- package/dist/subscriptions/types.d.ts +115 -0
- package/dist/subscriptions/usage-service.d.ts +74 -0
- package/dist/tools/ast-grep/cli.d.ts +15 -0
- package/dist/tools/ast-grep/constants.d.ts +25 -0
- package/dist/tools/ast-grep/downloader.d.ts +5 -0
- package/dist/tools/ast-grep/index.d.ts +10 -0
- package/dist/tools/ast-grep/tools.d.ts +3 -0
- package/dist/tools/ast-grep/types.d.ts +30 -0
- package/dist/tools/ast-grep/utils.d.ts +4 -0
- package/dist/tools/delegate.d.ts +14 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/preset-manager.d.ts +27 -0
- package/dist/tools/smartfetch/binary.d.ts +3 -0
- package/dist/tools/smartfetch/cache.d.ts +6 -0
- package/dist/tools/smartfetch/constants.d.ts +12 -0
- package/dist/tools/smartfetch/index.d.ts +3 -0
- package/dist/tools/smartfetch/network.d.ts +38 -0
- package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
- package/dist/tools/smartfetch/tool.d.ts +3 -0
- package/dist/tools/smartfetch/types.d.ts +122 -0
- package/dist/tools/smartfetch/utils.d.ts +18 -0
- package/dist/tui-state.d.ts +168 -0
- package/dist/tui.d.ts +37 -0
- package/dist/tui.js +1896 -0
- package/dist/utils/agent-variant.d.ts +63 -0
- package/dist/utils/compat.d.ts +30 -0
- package/dist/utils/env.d.ts +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/internal-initiator.d.ts +6 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/polling.d.ts +21 -0
- package/dist/utils/session-manager.d.ts +55 -0
- package/dist/utils/session.d.ts +90 -0
- package/dist/utils/subagent-depth.d.ts +35 -0
- package/dist/utils/system-collapse.d.ts +6 -0
- package/dist/utils/task.d.ts +4 -0
- package/dist/utils/zip-extractor.d.ts +1 -0
- package/index.ts +1 -0
- package/opencode-dux.schema.json +634 -0
- package/package.json +103 -0
- package/src/agents/descriptions.ts +55 -0
- package/src/agents/designer.test.ts +86 -0
- package/src/agents/designer.ts +154 -0
- package/src/agents/display-name.test.ts +186 -0
- package/src/agents/explorer.test.ts +79 -0
- package/src/agents/explorer.ts +144 -0
- package/src/agents/fixer.test.ts +79 -0
- package/src/agents/fixer.ts +145 -0
- package/src/agents/index.test.ts +472 -0
- package/src/agents/index.ts +248 -0
- package/src/agents/interpreter.ts +136 -0
- package/src/agents/librarian.test.ts +80 -0
- package/src/agents/librarian.ts +145 -0
- package/src/agents/oracle.test.ts +89 -0
- package/src/agents/oracle.ts +184 -0
- package/src/agents/orchestrator.test.ts +116 -0
- package/src/agents/orchestrator.ts +574 -0
- package/src/agents/overrides.ts +95 -0
- package/src/agents/prompt-blocks.test.ts +114 -0
- package/src/agents/prompt-blocks.ts +640 -0
- package/src/agents/steward.ts +146 -0
- package/src/cli/config-io.test.ts +536 -0
- package/src/cli/config-io.ts +473 -0
- package/src/cli/config-manager.test.ts +141 -0
- package/src/cli/config-manager.ts +4 -0
- package/src/cli/index.ts +88 -0
- package/src/cli/install.ts +282 -0
- package/src/cli/mcps.test.ts +62 -0
- package/src/cli/mcps.ts +39 -0
- package/src/cli/model-key-normalization.test.ts +21 -0
- package/src/cli/model-key-normalization.ts +60 -0
- package/src/cli/paths.test.ts +167 -0
- package/src/cli/paths.ts +144 -0
- package/src/cli/providers.test.ts +118 -0
- package/src/cli/providers.ts +141 -0
- package/src/cli/skills.test.ts +111 -0
- package/src/cli/skills.ts +103 -0
- package/src/cli/system.test.ts +91 -0
- package/src/cli/system.ts +180 -0
- package/src/cli/types.ts +43 -0
- package/src/config/constants.ts +58 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.test.ts +1194 -0
- package/src/config/loader.ts +269 -0
- package/src/config/model-resolution.test.ts +176 -0
- package/src/config/runtime-preset.test.ts +61 -0
- package/src/config/runtime-preset.ts +37 -0
- package/src/config/schema.ts +248 -0
- package/src/config/utils.test.ts +41 -0
- package/src/config/utils.ts +23 -0
- package/src/discovery/local/types.ts +85 -0
- package/src/discovery/local.ts +322 -0
- package/src/discovery/mcp-servers.ts +804 -0
- package/src/discovery/skills.ts +959 -0
- package/src/hooks/apply-patch/codec.test.ts +184 -0
- package/src/hooks/apply-patch/codec.ts +352 -0
- package/src/hooks/apply-patch/errors.ts +117 -0
- package/src/hooks/apply-patch/execution-context.ts +432 -0
- package/src/hooks/apply-patch/hook.test.ts +768 -0
- package/src/hooks/apply-patch/index.ts +126 -0
- package/src/hooks/apply-patch/matching.test.ts +215 -0
- package/src/hooks/apply-patch/matching.ts +586 -0
- package/src/hooks/apply-patch/operations.test.ts +1535 -0
- package/src/hooks/apply-patch/operations.ts +3 -0
- package/src/hooks/apply-patch/patch.ts +9 -0
- package/src/hooks/apply-patch/prepared-changes.ts +400 -0
- package/src/hooks/apply-patch/resolution.test.ts +420 -0
- package/src/hooks/apply-patch/resolution.ts +437 -0
- package/src/hooks/apply-patch/rewrite.ts +496 -0
- package/src/hooks/apply-patch/test-helpers.ts +52 -0
- package/src/hooks/apply-patch/types.ts +111 -0
- package/src/hooks/auto-update-checker/cache.test.ts +179 -0
- package/src/hooks/auto-update-checker/cache.ts +188 -0
- package/src/hooks/auto-update-checker/checker.test.ts +159 -0
- package/src/hooks/auto-update-checker/checker.ts +308 -0
- package/src/hooks/auto-update-checker/constants.ts +33 -0
- package/src/hooks/auto-update-checker/index.test.ts +282 -0
- package/src/hooks/auto-update-checker/index.ts +225 -0
- package/src/hooks/auto-update-checker/types.ts +26 -0
- package/src/hooks/chat-headers.test.ts +236 -0
- package/src/hooks/chat-headers.ts +97 -0
- package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
- package/src/hooks/context-pressure-reminder/index.ts +137 -0
- package/src/hooks/delegate-task-retry/guidance.ts +41 -0
- package/src/hooks/delegate-task-retry/hook.ts +23 -0
- package/src/hooks/delegate-task-retry/index.test.ts +38 -0
- package/src/hooks/delegate-task-retry/index.ts +7 -0
- package/src/hooks/delegate-task-retry/patterns.ts +79 -0
- package/src/hooks/filter-available-skills/index.test.ts +297 -0
- package/src/hooks/filter-available-skills/index.ts +160 -0
- package/src/hooks/foreground-fallback/index.test.ts +624 -0
- package/src/hooks/foreground-fallback/index.ts +374 -0
- package/src/hooks/image-hook.ts +6 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/json-error-recovery/hook.ts +73 -0
- package/src/hooks/json-error-recovery/index.test.ts +111 -0
- package/src/hooks/json-error-recovery/index.ts +6 -0
- package/src/hooks/phase-reminder/index.test.ts +74 -0
- package/src/hooks/phase-reminder/index.ts +85 -0
- package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
- package/src/hooks/post-file-tool-nudge/index.ts +63 -0
- package/src/hooks/task-session-manager/index.test.ts +833 -0
- package/src/hooks/task-session-manager/index.ts +434 -0
- package/src/hooks/todo-continuation/index.test.ts +3026 -0
- package/src/hooks/todo-continuation/index.ts +878 -0
- package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
- package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
- package/src/index.ts +1672 -0
- package/src/mcp/context7.ts +14 -0
- package/src/mcp/grep-app.ts +11 -0
- package/src/mcp/index.test.ts +96 -0
- package/src/mcp/index.ts +66 -0
- package/src/mcp/types.ts +16 -0
- package/src/mcp/websearch.ts +47 -0
- package/src/skills/codemap/README.md +60 -0
- package/src/skills/codemap/SKILL.md +174 -0
- package/src/skills/codemap/scripts/codemap.mjs +483 -0
- package/src/skills/codemap/scripts/codemap.test.ts +129 -0
- package/src/skills/registry.ts +218 -0
- package/src/skills/simplify/README.md +19 -0
- package/src/skills/simplify/SKILL.md +138 -0
- package/src/subscriptions/accounts-store.test.ts +236 -0
- package/src/subscriptions/accounts-store.ts +184 -0
- package/src/subscriptions/index.ts +30 -0
- package/src/subscriptions/neuralwatt-scraper.ts +108 -0
- package/src/subscriptions/opencode-go-scraper.ts +301 -0
- package/src/subscriptions/types.ts +145 -0
- package/src/subscriptions/usage-service.test.ts +202 -0
- package/src/subscriptions/usage-service.ts +651 -0
- package/src/tools/ast-grep/cli.ts +257 -0
- package/src/tools/ast-grep/constants.ts +214 -0
- package/src/tools/ast-grep/downloader.ts +131 -0
- package/src/tools/ast-grep/index.ts +24 -0
- package/src/tools/ast-grep/tools.ts +117 -0
- package/src/tools/ast-grep/types.ts +51 -0
- package/src/tools/ast-grep/utils.ts +126 -0
- package/src/tools/delegate-handoff.test.ts +18 -0
- package/src/tools/delegate.ts +508 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/preset-manager.test.ts +795 -0
- package/src/tools/preset-manager.ts +332 -0
- package/src/tools/smartfetch/binary.ts +58 -0
- package/src/tools/smartfetch/cache.test.ts +34 -0
- package/src/tools/smartfetch/cache.ts +112 -0
- package/src/tools/smartfetch/constants.ts +29 -0
- package/src/tools/smartfetch/index.ts +8 -0
- package/src/tools/smartfetch/network.test.ts +178 -0
- package/src/tools/smartfetch/network.ts +614 -0
- package/src/tools/smartfetch/secondary-model.test.ts +85 -0
- package/src/tools/smartfetch/secondary-model.ts +276 -0
- package/src/tools/smartfetch/tool.test.ts +60 -0
- package/src/tools/smartfetch/tool.ts +832 -0
- package/src/tools/smartfetch/types.ts +135 -0
- package/src/tools/smartfetch/utils.test.ts +24 -0
- package/src/tools/smartfetch/utils.ts +456 -0
- package/src/tui-state.test.ts +867 -0
- package/src/tui-state.ts +1255 -0
- package/src/tui.test.ts +336 -0
- package/src/tui.ts +1539 -0
- package/src/utils/agent-variant.test.ts +244 -0
- package/src/utils/agent-variant.ts +187 -0
- package/src/utils/compat.ts +91 -0
- package/src/utils/env.ts +12 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/internal-initiator.ts +28 -0
- package/src/utils/logger.test.ts +220 -0
- package/src/utils/logger.ts +136 -0
- package/src/utils/polling.test.ts +191 -0
- package/src/utils/polling.ts +67 -0
- package/src/utils/session-manager.test.ts +173 -0
- package/src/utils/session-manager.ts +356 -0
- package/src/utils/session.test.ts +110 -0
- package/src/utils/session.ts +389 -0
- package/src/utils/subagent-depth.test.ts +170 -0
- package/src/utils/subagent-depth.ts +75 -0
- package/src/utils/system-collapse.test.ts +86 -0
- package/src/utils/system-collapse.ts +24 -0
- package/src/utils/task.test.ts +24 -0
- package/src/utils/task.ts +20 -0
- package/src/utils/zip-extractor.ts +102 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime model fallback for foreground (interactive) agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* When OpenCode fires a session.error, message.updated, or session.status
|
|
5
|
+
* event containing a rate-limit signal, this manager:
|
|
6
|
+
* 1. Looks up the next untried model in the agent's configured chain
|
|
7
|
+
* 2. Aborts the rate-limited prompt via client.session.abort()
|
|
8
|
+
* 3. Re-queues the last user message via client.session.promptAsync()
|
|
9
|
+
* with the new model - promptAsync returns immediately so we never
|
|
10
|
+
* block the event handler waiting for a full LLM response.
|
|
11
|
+
*
|
|
12
|
+
* This mirrors the same fallback loop used for delegated sessions, but operates
|
|
13
|
+
* reactively through the event system instead of wrapping prompt() in a
|
|
14
|
+
* try/catch, which is not possible for interactive (foreground) sessions.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PluginInput } from '@opencode-ai/plugin';
|
|
18
|
+
import { log } from '../../utils/logger';
|
|
19
|
+
|
|
20
|
+
type OpencodeClient = PluginInput['client'];
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Rate-limit detection
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const RATE_LIMIT_PATTERNS = [
|
|
27
|
+
/\b429\b/,
|
|
28
|
+
/rate.?limit/i,
|
|
29
|
+
/too many requests/i,
|
|
30
|
+
/quota.?exceeded/i,
|
|
31
|
+
/usage.?exceeded/i,
|
|
32
|
+
/ExceededBudget/i,
|
|
33
|
+
/over.?budget/i,
|
|
34
|
+
/usage limit/i,
|
|
35
|
+
/overloaded/i,
|
|
36
|
+
/resource.?exhausted/i,
|
|
37
|
+
/insufficient.?quota/i,
|
|
38
|
+
/high concurrency/i,
|
|
39
|
+
/reduce concurrency/i,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export function isRateLimitError(error: unknown): boolean {
|
|
43
|
+
if (!error || typeof error !== 'object') return false;
|
|
44
|
+
const err = error as {
|
|
45
|
+
message?: string;
|
|
46
|
+
data?: { statusCode?: number; message?: string; responseBody?: string };
|
|
47
|
+
};
|
|
48
|
+
const text = [
|
|
49
|
+
err.message ?? '',
|
|
50
|
+
String(err.data?.statusCode ?? ''),
|
|
51
|
+
err.data?.message ?? '',
|
|
52
|
+
err.data?.responseBody ?? '',
|
|
53
|
+
].join(' ');
|
|
54
|
+
return RATE_LIMIT_PATTERNS.some((p) => p.test(text));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Helpers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function parseModel(
|
|
62
|
+
model: string,
|
|
63
|
+
): { providerID: string; modelID: string } | null {
|
|
64
|
+
const slash = model.indexOf('/');
|
|
65
|
+
if (slash <= 0 || slash >= model.length - 1) return null;
|
|
66
|
+
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Prevent re-triggering within this window for the same session. */
|
|
70
|
+
const DEDUP_WINDOW_MS = 5_000;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Manager
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Manages runtime model fallback for foreground agent sessions.
|
|
78
|
+
*
|
|
79
|
+
* Constructed at plugin init with the ordered fallback chains for each agent
|
|
80
|
+
* (built from _modelArray entries merged with fallback.chains config).
|
|
81
|
+
*/
|
|
82
|
+
export class ForegroundFallbackManager {
|
|
83
|
+
/** sessionID → last observed model string ("providerID/modelID") */
|
|
84
|
+
private readonly sessionModel = new Map<string, string>();
|
|
85
|
+
/** sessionID → agent name (populated from message.updated info.agent field) */
|
|
86
|
+
private readonly sessionAgent = new Map<string, string>();
|
|
87
|
+
/** sessionID → set of models already attempted this session */
|
|
88
|
+
private readonly sessionTried = new Map<string, Set<string>>();
|
|
89
|
+
/** Sessions with an active fallback switch in flight */
|
|
90
|
+
private readonly inProgress = new Set<string>();
|
|
91
|
+
/** sessionID → timestamp of last trigger (for deduplication) */
|
|
92
|
+
private readonly lastTrigger = new Map<string, number>();
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
private readonly client: OpencodeClient,
|
|
96
|
+
/**
|
|
97
|
+
* Ordered fallback chains per agent.
|
|
98
|
+
* e.g. { orchestrator: ['anthropic/claude-opus-4-5', 'openai/gpt-4o'] }
|
|
99
|
+
* The first model that hasn't been tried yet is selected on each fallback.
|
|
100
|
+
*/
|
|
101
|
+
private readonly chains: Record<string, string[]>,
|
|
102
|
+
private readonly enabled: boolean,
|
|
103
|
+
) {}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Process an OpenCode plugin event.
|
|
107
|
+
* Call this from the plugin's `event` hook for every event received.
|
|
108
|
+
*/
|
|
109
|
+
async handleEvent(rawEvent: unknown): Promise<void> {
|
|
110
|
+
if (!this.enabled) return;
|
|
111
|
+
const event = rawEvent as { type: string; properties?: unknown };
|
|
112
|
+
if (!event?.type) return;
|
|
113
|
+
|
|
114
|
+
switch (event.type) {
|
|
115
|
+
case 'message.updated': {
|
|
116
|
+
const info = (
|
|
117
|
+
event.properties as { info?: Record<string, unknown> } | undefined
|
|
118
|
+
)?.info;
|
|
119
|
+
if (!info) break;
|
|
120
|
+
const sessionID = info.sessionID as string | undefined;
|
|
121
|
+
if (!sessionID) break;
|
|
122
|
+
// Capture agent name when available (OpenCode includes it on subagent messages)
|
|
123
|
+
if (typeof info.agent === 'string') {
|
|
124
|
+
this.sessionAgent.set(sessionID, info.agent);
|
|
125
|
+
}
|
|
126
|
+
// Track the model currently serving this session
|
|
127
|
+
if (
|
|
128
|
+
typeof info.providerID === 'string' &&
|
|
129
|
+
typeof info.modelID === 'string'
|
|
130
|
+
) {
|
|
131
|
+
this.sessionModel.set(
|
|
132
|
+
sessionID,
|
|
133
|
+
`${info.providerID}/${info.modelID}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
// Rate-limit on an individual message
|
|
137
|
+
if (info.error && isRateLimitError(info.error)) {
|
|
138
|
+
await this.tryFallback(sessionID);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case 'session.error': {
|
|
144
|
+
const props = event.properties as
|
|
145
|
+
| { sessionID?: string; error?: unknown }
|
|
146
|
+
| undefined;
|
|
147
|
+
if (props?.sessionID && props.error && isRateLimitError(props.error)) {
|
|
148
|
+
await this.tryFallback(props.sessionID);
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'session.status': {
|
|
154
|
+
const props = event.properties as
|
|
155
|
+
| {
|
|
156
|
+
sessionID?: string;
|
|
157
|
+
status?: { type?: string; message?: string };
|
|
158
|
+
}
|
|
159
|
+
| undefined;
|
|
160
|
+
if (!props?.sessionID || props.status?.type !== 'retry') break;
|
|
161
|
+
const msg = props.status.message?.toLowerCase() ?? '';
|
|
162
|
+
if (
|
|
163
|
+
msg.includes('rate limit') ||
|
|
164
|
+
msg.includes('usage limit') ||
|
|
165
|
+
msg.includes('usage exceeded') ||
|
|
166
|
+
msg.includes('quota exceeded') ||
|
|
167
|
+
msg.includes('exceededbudget') ||
|
|
168
|
+
msg.includes('over budget') ||
|
|
169
|
+
msg.includes('high concurrency') ||
|
|
170
|
+
msg.includes('reduce concurrency')
|
|
171
|
+
) {
|
|
172
|
+
await this.tryFallback(props.sessionID);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case 'subagent.session.created': {
|
|
178
|
+
// Some builds of OpenCode include the agent name here.
|
|
179
|
+
const props = event.properties as
|
|
180
|
+
| { sessionID?: string; agentName?: unknown }
|
|
181
|
+
| undefined;
|
|
182
|
+
if (props?.sessionID && typeof props.agentName === 'string') {
|
|
183
|
+
this.sessionAgent.set(props.sessionID, props.agentName);
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case 'session.deleted': {
|
|
189
|
+
// Clean up all per-session state to prevent unbounded memory growth
|
|
190
|
+
// in long-running instances with many subagent sessions.
|
|
191
|
+
// OpenCode emits two shapes depending on context:
|
|
192
|
+
// { properties: { sessionID } } - subagent / task sessions
|
|
193
|
+
// { properties: { info: { id } } } - top-level session deletion
|
|
194
|
+
// Mirror the same dual-shape lookup used elsewhere in the plugin.
|
|
195
|
+
const props = event.properties as
|
|
196
|
+
| { sessionID?: string; info?: { id?: string } }
|
|
197
|
+
| undefined;
|
|
198
|
+
const id = props?.info?.id ?? props?.sessionID;
|
|
199
|
+
if (id) {
|
|
200
|
+
this.sessionModel.delete(id);
|
|
201
|
+
this.sessionAgent.delete(id);
|
|
202
|
+
this.sessionTried.delete(id);
|
|
203
|
+
this.inProgress.delete(id);
|
|
204
|
+
this.lastTrigger.delete(id);
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Core fallback logic
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
private async tryFallback(sessionID: string): Promise<void> {
|
|
216
|
+
if (!sessionID) return;
|
|
217
|
+
if (this.inProgress.has(sessionID)) return;
|
|
218
|
+
|
|
219
|
+
// Deduplicate: multiple events can fire for a single rate-limit event.
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
if (now - (this.lastTrigger.get(sessionID) ?? 0) < DEDUP_WINDOW_MS) return;
|
|
222
|
+
this.lastTrigger.set(sessionID, now);
|
|
223
|
+
|
|
224
|
+
this.inProgress.add(sessionID);
|
|
225
|
+
try {
|
|
226
|
+
const currentModel = this.sessionModel.get(sessionID);
|
|
227
|
+
const agentName = this.sessionAgent.get(sessionID);
|
|
228
|
+
const chain = this.resolveChain(agentName, currentModel);
|
|
229
|
+
if (!chain.length) {
|
|
230
|
+
log('[foreground-fallback] no chain configured', {
|
|
231
|
+
sessionID,
|
|
232
|
+
agentName,
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!this.sessionTried.has(sessionID)) {
|
|
238
|
+
this.sessionTried.set(sessionID, new Set());
|
|
239
|
+
}
|
|
240
|
+
// biome-ignore lint/style/noNonNullAssertion: We just set this above
|
|
241
|
+
const tried = this.sessionTried.get(sessionID)!;
|
|
242
|
+
if (currentModel) tried.add(currentModel);
|
|
243
|
+
|
|
244
|
+
const nextModel = chain.find((m) => !tried.has(m));
|
|
245
|
+
if (!nextModel) {
|
|
246
|
+
log('[foreground-fallback] fallback chain exhausted', {
|
|
247
|
+
sessionID,
|
|
248
|
+
agentName,
|
|
249
|
+
tried: [...tried],
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
tried.add(nextModel);
|
|
254
|
+
|
|
255
|
+
const ref = parseModel(nextModel);
|
|
256
|
+
if (!ref) {
|
|
257
|
+
log('[foreground-fallback] invalid model format', {
|
|
258
|
+
sessionID,
|
|
259
|
+
nextModel,
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Retrieve the last user message to re-submit with the fallback model.
|
|
265
|
+
const result = await this.client.session.messages({
|
|
266
|
+
path: { id: sessionID },
|
|
267
|
+
});
|
|
268
|
+
const messages = (result.data ?? []) as Array<{
|
|
269
|
+
info: { role: string };
|
|
270
|
+
parts: unknown[];
|
|
271
|
+
}>;
|
|
272
|
+
const lastUser = [...messages]
|
|
273
|
+
.reverse()
|
|
274
|
+
.find((m) => m.info.role === 'user');
|
|
275
|
+
if (!lastUser) {
|
|
276
|
+
log('[foreground-fallback] no user message found', { sessionID });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Abort the currently rate-limited prompt so the session becomes idle.
|
|
281
|
+
try {
|
|
282
|
+
await this.client.session.abort({ path: { id: sessionID } });
|
|
283
|
+
} catch {
|
|
284
|
+
// Session may already be idle; safe to ignore.
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Give the server a moment to finalise the abort before re-prompting.
|
|
288
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
289
|
+
|
|
290
|
+
// promptAsync queues the prompt and returns immediately - this avoids
|
|
291
|
+
// blocking the event handler while waiting for a full LLM response.
|
|
292
|
+
// Cast required: promptAsync is not in the plugin TypeScript types for
|
|
293
|
+
// opencode-dux but IS present on the real OpenCode client at
|
|
294
|
+
// runtime (verified by opencode-rate-limit-fallback reference impl).
|
|
295
|
+
const sessionClient = this.client.session as unknown as {
|
|
296
|
+
promptAsync: (args: {
|
|
297
|
+
path: { id: string };
|
|
298
|
+
body: {
|
|
299
|
+
parts: unknown[];
|
|
300
|
+
model: { providerID: string; modelID: string };
|
|
301
|
+
};
|
|
302
|
+
}) => Promise<unknown>;
|
|
303
|
+
};
|
|
304
|
+
await sessionClient.promptAsync({
|
|
305
|
+
path: { id: sessionID },
|
|
306
|
+
body: { parts: lastUser.parts, model: ref },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
this.sessionModel.set(sessionID, nextModel);
|
|
310
|
+
log('[foreground-fallback] switched to fallback model', {
|
|
311
|
+
sessionID,
|
|
312
|
+
agentName,
|
|
313
|
+
from: currentModel,
|
|
314
|
+
to: nextModel,
|
|
315
|
+
});
|
|
316
|
+
} catch (err) {
|
|
317
|
+
log('[foreground-fallback] fallback attempt failed', {
|
|
318
|
+
sessionID,
|
|
319
|
+
error: err instanceof Error ? err.message : String(err),
|
|
320
|
+
});
|
|
321
|
+
} finally {
|
|
322
|
+
this.inProgress.delete(sessionID);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Chain resolution
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Determine the fallback chain to use for a session.
|
|
332
|
+
*
|
|
333
|
+
* Priority:
|
|
334
|
+
* 1. Agent name known AND has a configured chain → return it directly
|
|
335
|
+
* 2. Agent name known but NO chain configured → return [] (no fallback;
|
|
336
|
+
* do NOT bleed into other agents' chains which would re-prompt the
|
|
337
|
+
* session with a model belonging to a completely different agent)
|
|
338
|
+
* 3. Agent name unknown, current model known → search all chains for
|
|
339
|
+
* the model to infer which chain to use
|
|
340
|
+
* 4. Nothing matches → flatten all chains as a last resort (only
|
|
341
|
+
* reached when both agent name and current model are unavailable)
|
|
342
|
+
*/
|
|
343
|
+
private resolveChain(
|
|
344
|
+
agentName: string | undefined,
|
|
345
|
+
currentModel: string | undefined,
|
|
346
|
+
): string[] {
|
|
347
|
+
if (agentName) {
|
|
348
|
+
// Agent is known: use its chain exactly, or no chain at all.
|
|
349
|
+
// Never fall through to cross-agent chains when the agent is identified.
|
|
350
|
+
return this.chains[agentName] ?? [];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Agent unknown: try to infer from the current model.
|
|
354
|
+
if (currentModel) {
|
|
355
|
+
for (const chain of Object.values(this.chains)) {
|
|
356
|
+
if (chain.includes(currentModel)) return chain;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Last resort: merged list across all agents preserving insertion order.
|
|
361
|
+
// Only reached when both agent name and current model are unavailable.
|
|
362
|
+
const all: string[] = [];
|
|
363
|
+
const seen = new Set<string>();
|
|
364
|
+
for (const chain of Object.values(this.chains)) {
|
|
365
|
+
for (const m of chain) {
|
|
366
|
+
if (!seen.has(m)) {
|
|
367
|
+
seen.add(m);
|
|
368
|
+
all.push(m);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return all;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { createApplyPatchHook } from './apply-patch';
|
|
2
|
+
export type { AutoUpdateCheckerOptions } from './auto-update-checker';
|
|
3
|
+
export { createAutoUpdateCheckerHook } from './auto-update-checker';
|
|
4
|
+
export { createChatHeadersHook } from './chat-headers';
|
|
5
|
+
export { createContextPressureReminderHook } from './context-pressure-reminder';
|
|
6
|
+
export { createDelegateTaskRetryHook } from './delegate-task-retry';
|
|
7
|
+
export { createFilterAvailableSkillsHook } from './filter-available-skills';
|
|
8
|
+
export {
|
|
9
|
+
ForegroundFallbackManager,
|
|
10
|
+
isRateLimitError,
|
|
11
|
+
} from './foreground-fallback';
|
|
12
|
+
export { processImageAttachments } from './image-hook';
|
|
13
|
+
export { createJsonErrorRecoveryHook } from './json-error-recovery';
|
|
14
|
+
export { createPhaseReminderHook } from './phase-reminder';
|
|
15
|
+
export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
|
|
16
|
+
export { createTaskSessionManagerHook } from './task-session-manager';
|
|
17
|
+
export { createTodoContinuationHook } from './todo-continuation';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PluginInput } from '@opencode-ai/plugin';
|
|
2
|
+
|
|
3
|
+
export const JSON_ERROR_TOOL_EXCLUDE_LIST = [
|
|
4
|
+
'bash',
|
|
5
|
+
'read',
|
|
6
|
+
'glob',
|
|
7
|
+
'webfetch',
|
|
8
|
+
'grep_app_searchgithub',
|
|
9
|
+
'websearch_web_search_exa',
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export const JSON_ERROR_PATTERNS = [
|
|
13
|
+
/json parse error/i,
|
|
14
|
+
/failed to parse json/i,
|
|
15
|
+
/invalid json/i,
|
|
16
|
+
/malformed json/i,
|
|
17
|
+
/unexpected end of json input/i,
|
|
18
|
+
/syntaxerror:\s*unexpected token.*json/i,
|
|
19
|
+
/json[^\n]*expected '\}'/i,
|
|
20
|
+
/json[^\n]*unexpected eof/i,
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
const JSON_ERROR_REMINDER_MARKER =
|
|
24
|
+
'[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]';
|
|
25
|
+
const JSON_ERROR_EXCLUDED_TOOLS = new Set<string>(JSON_ERROR_TOOL_EXCLUDE_LIST);
|
|
26
|
+
|
|
27
|
+
export const JSON_ERROR_REMINDER = `
|
|
28
|
+
[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]
|
|
29
|
+
|
|
30
|
+
You sent invalid JSON arguments. The system could not parse your tool call.
|
|
31
|
+
STOP and do this NOW:
|
|
32
|
+
|
|
33
|
+
1. LOOK at the error message above to see what was expected vs what you sent.
|
|
34
|
+
2. CORRECT your JSON syntax (missing braces, unescaped quotes, trailing commas, etc).
|
|
35
|
+
3. RETRY the tool call with valid JSON.
|
|
36
|
+
|
|
37
|
+
DO NOT repeat the exact same invalid call.
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
interface ToolExecuteAfterInput {
|
|
41
|
+
tool: string;
|
|
42
|
+
sessionID: string;
|
|
43
|
+
callID: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ToolExecuteAfterOutput {
|
|
47
|
+
title: string;
|
|
48
|
+
output: unknown;
|
|
49
|
+
metadata: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createJsonErrorRecoveryHook(_ctx: PluginInput) {
|
|
53
|
+
return {
|
|
54
|
+
'tool.execute.after': async (
|
|
55
|
+
input: ToolExecuteAfterInput,
|
|
56
|
+
output: ToolExecuteAfterOutput,
|
|
57
|
+
): Promise<void> => {
|
|
58
|
+
if (JSON_ERROR_EXCLUDED_TOOLS.has(input.tool.toLowerCase())) return;
|
|
59
|
+
if (typeof output.output !== 'string') return;
|
|
60
|
+
if (output.output.includes(JSON_ERROR_REMINDER_MARKER)) return;
|
|
61
|
+
|
|
62
|
+
const outputText = output.output;
|
|
63
|
+
|
|
64
|
+
const hasJsonError = JSON_ERROR_PATTERNS.some((pattern) =>
|
|
65
|
+
pattern.test(outputText),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (hasJsonError) {
|
|
69
|
+
output.output += `\n${JSON_ERROR_REMINDER}`;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { PluginInput } from '@opencode-ai/plugin';
|
|
3
|
+
import {
|
|
4
|
+
createJsonErrorRecoveryHook,
|
|
5
|
+
JSON_ERROR_PATTERNS,
|
|
6
|
+
JSON_ERROR_REMINDER,
|
|
7
|
+
JSON_ERROR_TOOL_EXCLUDE_LIST,
|
|
8
|
+
} from './index';
|
|
9
|
+
|
|
10
|
+
describe('json-error-recovery hook', () => {
|
|
11
|
+
let hook: ReturnType<typeof createJsonErrorRecoveryHook>;
|
|
12
|
+
|
|
13
|
+
type ToolExecuteAfterHandler = NonNullable<
|
|
14
|
+
ReturnType<typeof createJsonErrorRecoveryHook>['tool.execute.after']
|
|
15
|
+
>;
|
|
16
|
+
type ToolExecuteAfterInput = Parameters<ToolExecuteAfterHandler>[0];
|
|
17
|
+
type ToolExecuteAfterOutput = Parameters<ToolExecuteAfterHandler>[1];
|
|
18
|
+
|
|
19
|
+
const createMockPluginInput = (): PluginInput => {
|
|
20
|
+
return {
|
|
21
|
+
client: {} as PluginInput['client'],
|
|
22
|
+
directory: '/tmp/test',
|
|
23
|
+
} as PluginInput;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
hook = createJsonErrorRecoveryHook(createMockPluginInput());
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const createInput = (tool = 'Edit'): ToolExecuteAfterInput => ({
|
|
31
|
+
tool,
|
|
32
|
+
sessionID: 'test-session',
|
|
33
|
+
callID: 'test-call-id',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const createOutput = (outputText: unknown): ToolExecuteAfterOutput => ({
|
|
37
|
+
title: 'Tool Error',
|
|
38
|
+
output: outputText,
|
|
39
|
+
metadata: {},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('appends reminder when output includes JSON parse error', async () => {
|
|
43
|
+
const output = createOutput("JSON parse error: expected '}' in JSON body");
|
|
44
|
+
|
|
45
|
+
await hook['tool.execute.after'](createInput(), output);
|
|
46
|
+
|
|
47
|
+
expect(output.output).toContain(JSON_ERROR_REMINDER);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('does not append reminder for normal output', async () => {
|
|
51
|
+
const output = createOutput('Task completed successfully');
|
|
52
|
+
|
|
53
|
+
await hook['tool.execute.after'](createInput(), output);
|
|
54
|
+
|
|
55
|
+
expect(output.output).toBe('Task completed successfully');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('does not append reminder for excluded tools', async () => {
|
|
59
|
+
const output = createOutput(
|
|
60
|
+
'JSON parse error: unexpected end of JSON input',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await hook['tool.execute.after'](createInput('Read'), output);
|
|
64
|
+
|
|
65
|
+
expect(output.output).toBe(
|
|
66
|
+
'JSON parse error: unexpected end of JSON input',
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('does not append duplicate reminder on repeated execution', async () => {
|
|
71
|
+
const output = createOutput('JSON parse error: invalid JSON arguments');
|
|
72
|
+
|
|
73
|
+
await hook['tool.execute.after'](createInput(), output);
|
|
74
|
+
await hook['tool.execute.after'](createInput(), output);
|
|
75
|
+
|
|
76
|
+
const reminderCount =
|
|
77
|
+
String(output.output).split(
|
|
78
|
+
'[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]',
|
|
79
|
+
).length - 1;
|
|
80
|
+
expect(reminderCount).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('ignores non-string output values', async () => {
|
|
84
|
+
const values: unknown[] = [42, null, undefined, { error: 'invalid json' }];
|
|
85
|
+
|
|
86
|
+
for (const value of values) {
|
|
87
|
+
const output = createOutput(value);
|
|
88
|
+
await hook['tool.execute.after'](createInput(), output);
|
|
89
|
+
expect(output.output).toBe(value);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('pattern list detects known JSON parse errors', () => {
|
|
94
|
+
const output = 'JSON parse error: unexpected end of JSON input';
|
|
95
|
+
const isMatched = JSON_ERROR_PATTERNS.some((pattern) =>
|
|
96
|
+
pattern.test(output),
|
|
97
|
+
);
|
|
98
|
+
expect(isMatched).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('exclude list contains content-heavy tools', () => {
|
|
102
|
+
const expectedExcludedTools: Array<
|
|
103
|
+
(typeof JSON_ERROR_TOOL_EXCLUDE_LIST)[number]
|
|
104
|
+
> = ['read', 'bash', 'webfetch'];
|
|
105
|
+
|
|
106
|
+
const allExpectedToolsIncluded = expectedExcludedTools.every((toolName) =>
|
|
107
|
+
JSON_ERROR_TOOL_EXCLUDE_LIST.includes(toolName),
|
|
108
|
+
);
|
|
109
|
+
expect(allExpectedToolsIncluded).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
|
|
3
|
+
import { createPhaseReminderHook, PHASE_REMINDER } from './index';
|
|
4
|
+
|
|
5
|
+
describe('createPhaseReminderHook', () => {
|
|
6
|
+
test('appends reminder for orchestrator sessions', async () => {
|
|
7
|
+
const hook = createPhaseReminderHook();
|
|
8
|
+
const output = {
|
|
9
|
+
messages: [
|
|
10
|
+
{
|
|
11
|
+
info: { role: 'user', agent: 'orchestrator' },
|
|
12
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
await hook['experimental.chat.messages.transform']({}, output);
|
|
18
|
+
|
|
19
|
+
expect(output.messages[0].parts[0].text).toBe(
|
|
20
|
+
`hello\n\n---\n\n${PHASE_REMINDER}`,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('skips non-orchestrator sessions', async () => {
|
|
25
|
+
const hook = createPhaseReminderHook();
|
|
26
|
+
const output = {
|
|
27
|
+
messages: [
|
|
28
|
+
{
|
|
29
|
+
info: { role: 'user', agent: 'explorer' },
|
|
30
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
await hook['experimental.chat.messages.transform']({}, output);
|
|
36
|
+
|
|
37
|
+
expect(output.messages[0].parts[0].text).toBe('hello');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('does not mutate internal notification turns', async () => {
|
|
41
|
+
const hook = createPhaseReminderHook();
|
|
42
|
+
const text = `[Background task "x" completed]\n${SLIM_INTERNAL_INITIATOR_MARKER}`;
|
|
43
|
+
const output = {
|
|
44
|
+
messages: [
|
|
45
|
+
{
|
|
46
|
+
info: { role: 'user' },
|
|
47
|
+
parts: [{ type: 'text', text }],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
await hook['experimental.chat.messages.transform']({}, output);
|
|
53
|
+
|
|
54
|
+
expect(output.messages[0].parts[0].text).toBe(text);
|
|
55
|
+
expect(output.messages[0].parts[0].text).not.toContain(PHASE_REMINDER);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('does not append duplicate reminder', async () => {
|
|
59
|
+
const hook = createPhaseReminderHook();
|
|
60
|
+
const text = `hello\n\n---\n\n${PHASE_REMINDER}`;
|
|
61
|
+
const output = {
|
|
62
|
+
messages: [
|
|
63
|
+
{
|
|
64
|
+
info: { role: 'user', agent: 'orchestrator' },
|
|
65
|
+
parts: [{ type: 'text', text }],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
await hook['experimental.chat.messages.transform']({}, output);
|
|
71
|
+
|
|
72
|
+
expect(output.messages[0].parts[0].text).toBe(text);
|
|
73
|
+
});
|
|
74
|
+
});
|