Compare commits

...

7 Commits

Author SHA1 Message Date
gnanam1990
149b1eb8fb fix: surface actionable error when DuckDuckGo web search is rate-limited
Non-Anthropic / non-codex providers (minimax, kimi, generic OpenAI-compatible)
fell through to the DDG adapter when no paid search key was configured. DDG's
scraper is blocked on most IPs, so web_search surfaced an opaque "anomaly in
the request" error. Catch that response in the DDG provider and rethrow with
the exact env vars that would unblock the tool, or the option to switch to a
native-search provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:22:59 +05:30
Kevin Codex
67de6bd2cf fix(openai-shim): echo reasoning_content on assistant tool-call messages for Moonshot (#828)
Kimi / Moonshot's chat completions endpoint requires that every assistant
message carrying tool_calls also carry reasoning_content when the
"thinking" feature is active. When an agent sends prior-turn assistant
history back (standard multi-turn / subagent / Explore patterns), the
shim previously stripped the thinking block:

  case 'thinking':
  case 'redacted_thinking':
    // Strip thinking blocks for OpenAI-compatible providers.
    break

That's correct for providers that would mis-interpret serialized
<thinking> tags, but Moonshot validates the schema strictly and rejects
with:

  API Error: 400 {"error":{"message":"thinking is enabled but
  reasoning_content is missing in assistant tool call message at
  index N","type":"invalid_request_error"}}

Reproducer: launch with Kimi profile, run any tool-using command
(Explore, Bash, etc.) — every request after the first 400s.

Fix: in convertMessages(), when the per-request flag
preserveReasoningContent is set (only for Moonshot baseUrls today),
attach the original thinking block's text as reasoning_content on the
outgoing OpenAI-shaped assistant message. Other providers continue to
strip (unknown-field rejection risk).

OpenAIMessage type grows a reasoning_content?: string field.
convertMessages() accepts an options object and threads the flag
through; the only call site (_doOpenAIRequest) gates via
isMoonshotBaseUrl(request.baseUrl).

Tests (openaiShim.test.ts):
  - Moonshot: echoes reasoning_content on assistant tool-call messages
    (regression for the reported 400)
  - non-Moonshot providers do NOT receive reasoning_content (guards
    against leaking the field to strict-parse endpoints)

Full suite: 1195/1195 pass under --max-concurrency=1. PR scan clean.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-22 22:47:57 +08:00
0xfandom
4d559c9135 docs(env): document OPENCLAUDE_DISABLE_STRICT_TOOLS in .env.example (#826)
Code support was merged in #770 but the .env.example entry was
missed, leaving users without a discoverable way to find the flag.

Closes #737
2026-04-22 22:16:47 +08:00
JATMN
b7b83eff13 Fix bracketed paste blocking provider form submit (#818) 2026-04-22 19:48:33 +08:00
Urvish L.
44a2c30d5f feat: implement Hook Chains runtime integration for self-healing agent mesh MVP (#711)
* feat: implement Hook Chains runtime integration for self-healing agent mesh MVP

- Add Hook Chains config loader, evaluator, and dispatcher in src/utils/hookChains.ts
- Wire PostToolUseFailure hook dispatch in executePostToolUseFailureHooks()
- Wire TaskCompleted hook dispatch in executeTaskCompletedHooks()
- Integrate fallback-agent launcher with permission preservation (canUseTool threading)
- Add safety hardening for config-read errors (try-catch protection)
- Update docs with MVP runtime trigger explanation
- Add 10 unit tests and 4 integration tests covering config, rules, guards, and actions

This completes the self-healing agent mesh MVP by enabling declarative rule-based
responses to tool failures and task completions, with fallback agent spawning,
team notification, and capacity warming actions.

* Update docs/hook-chains.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/utils/hookChains.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: address PR #711 review blockers for Hook Chains

- Gate hook-chain dispatch behind feature('HOOK_CHAINS') and default env gate to off
- Remove committed local artifact (agent.log) and ignore it in .gitignore
- Revert hook dispatcher signature threading changes for canUseTool
- Use ToolUseContext metadata hookChainsCanUseTool for fallback launch permissions
- Make spawn_fallback_agent fail explicitly when launcher context is unavailable
- Add config cache max age and guard map size limits to bound runtime memory
- Update docs and tests for default-off gating and explicit fallback failure

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-22 19:40:23 +08:00
ArkhAngelLifeJiggy
5b9cd21e37 feat: add streaming optimizer and structured request logging (#703)
* Integrate request logging and streaming optimizer

- Add logApiCallStart/End for API request tracking with correlation IDs
- Add streaming state tracking with processStreamChunk
- Flush buffer and log stream stats at stream end
- Resolve merge conflict with main branch

* feat: add streaming optimizer and structured request logging

* fix: address PR review feedback

- Remove buffering from streamingOptimizer - now purely observational
- Use logForDebugging instead of console.log for structured logging
- Remove dead code (streamResponse, bufferedStreamResponse, etc.)
- Use existing logging infrastructure instead of raw console.log
- Keep only used functions: createStreamState, processStreamChunk, getStreamStats

* test: add unit tests for requestLogging and streamingOptimizer

- streamingOptimizer.test.ts: 6 tests for createStreamState, processStreamChunk, getStreamStats
- requestLogging.test.ts: 6 tests for createCorrelationId, logApiCallStart, logApiCallEnd

* fix: correct durationMs test to be >= 0 instead of exactly 0

* fix: address PR #703 blockers and non-blockers

1. BLOCKER FIX: Skip clone() for streaming responses
   - Only call response.clone() + .json() for non-streaming requests
   - For streaming, usage comes via stream chunks anyway

2. NON-BLOCKER: Document dead code in flushStreamBuffer
   - Added comment explaining it's a no-op kept for API compat

3. NON-BLOCKER: vi.mock in tests - left as-is (test framework issue)

* fix: address all remaining non-blockers for PR #703

1. Remove dead code: flushStreamBuffer call and unused import
2. Fix test for Bun: remove vi.mock, use simple no-throw tests
2026-04-22 15:36:07 +08:00
ArkhAngelLifeJiggy
e92e5274b2 feat: add model-specific tokenizers and compression ratio detection (#799)
- ModelTokenizerConfig for different model families
- getTokenizerConfig() / getBytesPerTokenForModel()
- Content type detection (json, code, prose, list, technical)
- COMPRESSION_RATIOS - empirical ratios per content type
- estimateWithBounds() - confidence intervals

Features: 1.1, 1.14, 1.15
Tests: 13 passing
2026-04-22 13:24:12 +08:00
21 changed files with 3741 additions and 36 deletions

View File

@@ -267,6 +267,11 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# Disable "Co-authored-by" line in git commits made by OpenClaude
# OPENCLAUDE_DISABLE_CO_AUTHORED_BY=1
# Disable strict tool schema normalization for non-Gemini providers
# Useful when MCP tools with complex optional params (e.g. list[dict])
# trigger "Extra required key ... supplied" errors from OpenAI-compatible endpoints
# OPENCLAUDE_DISABLE_STRICT_TOOLS=1
# Custom timeout for API requests in milliseconds (default: varies)
# API_TIMEOUT_MS=60000

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ CLAUDE.md
package-lock.json
/.claude
coverage/
agent.log

333
docs/hook-chains.md Normal file
View File

@@ -0,0 +1,333 @@
# Hook Chains (Self-Healing Agent Mesh MVP)
Hook Chains provide an event-driven recovery layer for important workflow failures.
When a matching hook event occurs, OpenClaude evaluates declarative rules and can dispatch remediation actions such as:
- `spawn_fallback_agent`
- `notify_team`
- `warm_remote_capacity`
## Disabled-By-Default Rollout
> **Rollout recommendation:** keep Hook Chains disabled until you validate rules in your environment.
>
> - Set top-level config to `"enabled": false` initially.
> - Enable per environment when ready.
> - Dispatch is gated by `feature('HOOK_CHAINS')`.
> - Env gate defaults to off unless `CLAUDE_CODE_ENABLE_HOOK_CHAINS=1` is set.
This keeps existing workflows unchanged while you tune guard windows and action behavior.
## Feature Overview
Hook Chains are loaded from a deterministic config file and evaluated on dispatched hook events.
MVP runtime trigger wiring:
- `PostToolUseFailure` hooks dispatch Hook Chains with outcome `failed`.
- `TaskCompleted` hooks dispatch Hook Chains with outcome:
- `success` when completion hooks did not block.
- `failed` when completion hooks returned blocking errors or prevented continuation.
Default config path:
- `.openclaude/hook-chains.json`
Override path:
- `CLAUDE_CODE_HOOK_CHAINS_CONFIG_PATH=/abs/or/relative/path/to/hook-chains.json`
Global gate:
- `feature('HOOK_CHAINS')` must be enabled in the build
- `CLAUDE_CODE_ENABLE_HOOK_CHAINS=0|1` (defaults to disabled when unset)
## Safety Guarantees
The runtime is intentionally conservative:
- **Depth guard:** chain dispatch is blocked when `chainDepth >= maxChainDepth`.
- **Rule cooldown:** each rule can only re-fire after cooldown expires.
- **Dedup window:** identical event/action combinations are suppressed for a window.
- **Abort-safe behavior:** if the current signal is aborted, actions skip safely.
- **Policy-aware remote warm:** `warm_remote_capacity` skips when remote sessions are policy denied.
- **Bridge inactive no-op:** `warm_remote_capacity` safely skips when no active bridge handle exists.
- **Missing team context safety:** `notify_team` skips with structured reason if no team context/team file is available.
- **Fallback launcher safety:** `spawn_fallback_agent` fails with a structured reason when launch permissions/context are unavailable.
## Configuration Schema Reference
Top-level object:
```json
{
"version": 1,
"enabled": true,
"maxChainDepth": 2,
"defaultCooldownMs": 30000,
"defaultDedupWindowMs": 30000,
"rules": []
}
```
### Top-Level Fields
| Field | Type | Required | Notes |
|---|---|---:|---|
| `version` | `1` | No | Defaults to `1`. |
| `enabled` | `boolean` | No | Global feature switch for this config file. |
| `maxChainDepth` | `integer` | No | Global depth guard (default `2`, max `10`). |
| `defaultCooldownMs` | `integer` | No | Default rule cooldown in ms (default `30000`). |
| `defaultDedupWindowMs` | `integer` | No | Default action dedup window in ms (default `30000`). |
| `rules` | `HookChainRule[]` | No | Defaults to `[]`. May be omitted or empty; when no rules are present, dispatch is a no-op and returns `enabled: false`. |
> **Note:** An empty ruleset is valid and can be used to keep Hook Chains configured but effectively disabled until rules are added.
### Rule Object (`HookChainRule`)
```json
{
"id": "task-failure-recovery",
"enabled": true,
"trigger": {
"event": "TaskCompleted",
"outcome": "failed"
},
"condition": {
"toolNames": ["Edit"],
"taskStatuses": ["failed"],
"errorIncludes": ["timeout", "permission denied"],
"eventFieldEquals": {
"meta.source": "scheduler"
}
},
"cooldownMs": 60000,
"dedupWindowMs": 30000,
"maxDepth": 2,
"actions": []
}
```
| Field | Type | Required | Notes |
|---|---|---:|---|
| `id` | `string` | Yes | Stable identifier used in telemetry/guards. |
| `enabled` | `boolean` | No | Per-rule switch. |
| `trigger.event` | `HookEvent` | Yes | Event name to match. |
| `trigger.outcome` | `"success"|"failed"|"timeout"|"unknown"` | No | Single outcome matcher. |
| `trigger.outcomes` | `Outcome[]` | No | Multi-outcome matcher. Use either `outcome` or `outcomes`. |
| `condition` | `object` | No | Optional extra matching constraints. |
| `cooldownMs` | `integer` | No | Overrides global cooldown for this rule. |
| `dedupWindowMs` | `integer` | No | Overrides global dedup for this rule. |
| `maxDepth` | `integer` | No | Per-rule depth cap. |
| `actions` | `HookChainAction[]` | Yes | One or more actions to execute in order. |
### Condition Fields
| Field | Type | Notes |
|---|---|---|
| `toolNames` | `string[]` | Matches `tool_name` / `toolName` in event payload. |
| `taskStatuses` | `string[]` | Matches `task_status` / `taskStatus` / `status`. |
| `errorIncludes` | `string[]` | Case-insensitive substring match against `error` / `reason` / `message`. |
| `eventFieldEquals` | `Record<string, string\|number\|boolean>` | Dot-path equality against payload (example: `"meta.source": "scheduler"`). |
### Actions
#### `spawn_fallback_agent`
```json
{
"type": "spawn_fallback_agent",
"id": "fallback-1",
"enabled": true,
"dedupWindowMs": 30000,
"description": "Fallback recovery for failed task",
"promptTemplate": "Recover task ${TASK_SUBJECT}. Event=${EVENT_NAME}, outcome=${OUTCOME}, error=${ERROR}. Payload=${PAYLOAD_JSON}",
"agentType": "general-purpose",
"model": "sonnet"
}
```
#### `notify_team`
```json
{
"type": "notify_team",
"id": "notify-ops",
"enabled": true,
"dedupWindowMs": 30000,
"teamName": "mesh-team",
"recipients": ["*"],
"summary": "Hook chain ${RULE_ID} fired",
"messageTemplate": "Event=${EVENT_NAME} outcome=${OUTCOME}\nTask=${TASK_ID}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}"
}
```
#### `warm_remote_capacity`
```json
{
"type": "warm_remote_capacity",
"id": "warm-bridge",
"enabled": true,
"dedupWindowMs": 60000,
"createDefaultEnvironmentIfMissing": false
}
```
## Complete Example Configs
### 1) Retry via Fallback Agent
```json
{
"version": 1,
"enabled": true,
"maxChainDepth": 2,
"defaultCooldownMs": 30000,
"defaultDedupWindowMs": 30000,
"rules": [
{
"id": "retry-task-via-fallback",
"trigger": {
"event": "TaskCompleted",
"outcome": "failed"
},
"cooldownMs": 60000,
"actions": [
{
"type": "spawn_fallback_agent",
"id": "spawn-retry-agent",
"description": "Retry failed task with fallback agent",
"promptTemplate": "A task failed. Recover it safely.\nTask=${TASK_SUBJECT}\nDescription=${TASK_DESCRIPTION}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}",
"agentType": "general-purpose",
"model": "sonnet"
}
]
}
]
}
```
### 2) Notify Only
```json
{
"version": 1,
"enabled": true,
"maxChainDepth": 2,
"defaultCooldownMs": 30000,
"defaultDedupWindowMs": 30000,
"rules": [
{
"id": "notify-on-tool-failure",
"trigger": {
"event": "PostToolUseFailure",
"outcome": "failed"
},
"condition": {
"toolNames": ["Edit", "Write", "Bash"]
},
"actions": [
{
"type": "notify_team",
"id": "notify-team-failure",
"recipients": ["*"],
"summary": "Tool failure detected",
"messageTemplate": "Tool failure detected.\nEvent=${EVENT_NAME} outcome=${OUTCOME}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}"
}
]
}
]
}
```
### 3) Combined Fallback + Notify + Bridge Warm
```json
{
"version": 1,
"enabled": true,
"maxChainDepth": 2,
"defaultCooldownMs": 45000,
"defaultDedupWindowMs": 30000,
"rules": [
{
"id": "full-recovery-chain",
"trigger": {
"event": "TaskCompleted",
"outcomes": ["failed", "timeout"]
},
"condition": {
"errorIncludes": ["timeout", "capacity", "connection"]
},
"cooldownMs": 90000,
"actions": [
{
"type": "spawn_fallback_agent",
"id": "fallback-agent",
"description": "Recover failed task execution",
"promptTemplate": "Recover failed task and produce a concise fix summary.\nTask=${TASK_SUBJECT}\nError=${ERROR}\nPayload=${PAYLOAD_JSON}"
},
{
"type": "notify_team",
"id": "notify-team",
"recipients": ["*"],
"summary": "Recovery chain triggered",
"messageTemplate": "Recovery chain ${RULE_ID} fired.\nOutcome=${OUTCOME}\nTask=${TASK_SUBJECT}\nError=${ERROR}"
},
{
"type": "warm_remote_capacity",
"id": "warm-capacity",
"createDefaultEnvironmentIfMissing": false
}
]
}
]
}
```
## Template Variables
The following placeholders are supported by `promptTemplate`, `summary`, and `messageTemplate`:
- `${EVENT_NAME}`
- `${OUTCOME}`
- `${RULE_ID}`
- `${TASK_SUBJECT}`
- `${TASK_DESCRIPTION}`
- `${TASK_ID}`
- `${ERROR}`
- `${PAYLOAD_JSON}`
## Troubleshooting
### Rule never triggers
- Verify `trigger.event` and `trigger.outcome`/`trigger.outcomes` exactly match dispatched event data.
- Check `condition` filters (especially `toolNames` and `eventFieldEquals` dot-path keys).
- Confirm the config file is valid JSON and schema-valid.
### Actions show as skipped
Common skip reasons:
- `action disabled`
- `rule cooldown active ...`
- `dedup window active ...`
- `max chain depth reached ...`
- `No team context is available ...`
- `Team file not found ...`
- `Remote sessions are blocked by policy`
- `Bridge is not active; warm_remote_capacity is a safe no-op`
- `No fallback agent launcher is registered in runtime context`
### Config changes not reflected
- Loader uses memoization by file mtime/size.
- Ensure your editor writes the file fully and updates mtime.
- If needed, force reload from the caller side with `forceReloadConfig: true`.
### Existing workflows changed unexpectedly
- Set `"enabled": false` at top-level.
- Or globally disable with `CLAUDE_CODE_ENABLE_HOOK_CHAINS=0`.
- Re-enable gradually after validating one rule at a time.

View File

@@ -249,6 +249,11 @@ export type ToolUseContext = {
/** When true, canUseTool must always be called even when hooks auto-approve.
* Used by speculation for overlay file path rewriting. */
requireCanUseTool?: boolean
/**
* Optional callback used by hook-chain fallback actions that launch
* AgentTool from hook runtime paths.
*/
hookChainsCanUseTool?: CanUseToolFn
messages: Message[]
fileReadingLimits?: {
maxTokens?: number

View File

@@ -1,5 +1,8 @@
import { expect, test } from 'bun:test'
import { supportsClipboardImageFallback } from './usePasteHandler.ts'
import {
shouldHandleInputAsPaste,
supportsClipboardImageFallback,
} from './usePasteHandler.ts'
test('supports clipboard image fallback on Windows', () => {
expect(supportsClipboardImageFallback('windows')).toBe(true)
@@ -20,3 +23,42 @@ test('does not support clipboard image fallback on WSL', () => {
test('does not support clipboard image fallback on unknown platforms', () => {
expect(supportsClipboardImageFallback('unknown')).toBe(false)
})
test('does not treat a bracketed paste as pending when no paste handlers are provided', () => {
expect(
shouldHandleInputAsPaste({
hasTextPasteHandler: false,
hasImagePasteHandler: false,
inputLength: 'kimi-k2.5'.length,
pastePending: false,
hasImageFilePath: false,
isFromPaste: true,
}),
).toBe(false)
})
test('treats bracketed text paste as pending when a text paste handler exists', () => {
expect(
shouldHandleInputAsPaste({
hasTextPasteHandler: true,
hasImagePasteHandler: false,
inputLength: 'kimi-k2.5'.length,
pastePending: false,
hasImageFilePath: false,
isFromPaste: true,
}),
).toBe(true)
})
test('treats image path paste as pending when only an image handler exists', () => {
expect(
shouldHandleInputAsPaste({
hasTextPasteHandler: false,
hasImagePasteHandler: true,
inputLength: 'C:\\Users\\jat\\image.png'.length,
pastePending: false,
hasImageFilePath: true,
isFromPaste: false,
}),
).toBe(true)
})

View File

@@ -35,6 +35,24 @@ type PasteHandlerProps = {
) => void
}
export function shouldHandleInputAsPaste(options: {
hasTextPasteHandler: boolean
hasImagePasteHandler: boolean
inputLength: number
pastePending: boolean
hasImageFilePath: boolean
isFromPaste: boolean
}): boolean {
return (
(options.hasTextPasteHandler &&
(options.inputLength > PASTE_THRESHOLD ||
options.pastePending ||
options.hasImageFilePath ||
options.isFromPaste)) ||
(options.hasImagePasteHandler && options.hasImageFilePath)
)
}
export function usePasteHandler({
onPaste,
onInput,
@@ -236,11 +254,6 @@ export function usePasteHandler({
// The keypress parser sets isPasted=true for content within bracketed paste.
const isFromPaste = event.keypress.isPasted
// If this is pasted content, set isPasting state for UI feedback
if (isFromPaste) {
setIsPasting(true)
}
// Handle large pastes (>PASTE_THRESHOLD chars)
// Usually we get one or two input characters at a time. If we
// get more than the threshold, the user has probably pasted.
@@ -268,6 +281,7 @@ export function usePasteHandler({
canFallbackToClipboardImage &&
onImagePaste
) {
setIsPasting(true)
checkClipboardForImage()
// Reset isPasting since there's no text content to process
setIsPasting(false)
@@ -275,14 +289,17 @@ export function usePasteHandler({
}
// Check if we should handle as paste (from bracketed paste, large input, or continuation)
const shouldHandleAsPaste =
onPaste &&
(input.length > PASTE_THRESHOLD ||
pastePendingRef.current ||
hasImageFilePath ||
isFromPaste)
const shouldHandleAsPaste = shouldHandleInputAsPaste({
hasTextPasteHandler: Boolean(onPaste),
hasImagePasteHandler: Boolean(onImagePaste),
inputLength: input.length,
pastePending: pastePendingRef.current,
hasImageFilePath,
isFromPaste,
})
if (shouldHandleAsPaste) {
setIsPasting(true)
pastePendingRef.current = true
setPasteState(({ chunks, timeoutId }) => {
return {

View File

@@ -3343,6 +3343,139 @@ test('Moonshot: uses max_tokens (not max_completion_tokens) and strips store', a
expect(requestBody?.store).toBeUndefined()
})
test('Moonshot: echoes reasoning_content on assistant tool-call messages', async () => {
// Regression for: "API Error: 400 {"error":{"message":"thinking is enabled
// but reasoning_content is missing in assistant tool call message at index
// N"}}" when the agent sends a prior-turn assistant response back to Kimi.
// The thinking block captured from the inbound response must round-trip
// as reasoning_content on the outgoing echoed assistant message.
process.env.OPENAI_BASE_URL = 'https://api.moonshot.ai/v1'
process.env.OPENAI_API_KEY = 'sk-moonshot-test'
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body))
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'kimi-k2.6',
choices: [
{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' },
],
usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
}),
{ headers: { 'Content-Type': 'application/json' } },
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'kimi-k2.6',
system: 'you are kimi',
messages: [
{ role: 'user', content: 'check the logs' },
{
role: 'assistant',
content: [
{
type: 'thinking',
thinking: 'Need to inspect logs via Bash; running a cat.',
},
{ type: 'text', text: "I'll inspect the logs." },
{
type: 'tool_use',
id: 'call_bash_1',
name: 'Bash',
input: { command: 'cat /tmp/app.log' },
},
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'call_bash_1',
content: 'log line 1\nlog line 2',
},
],
},
],
max_tokens: 256,
stream: false,
})
const messages = requestBody?.messages as Array<Record<string, unknown>>
const assistantWithToolCall = messages.find(
m => m.role === 'assistant' && Array.isArray(m.tool_calls),
)
expect(assistantWithToolCall).toBeDefined()
expect(assistantWithToolCall?.reasoning_content).toBe(
'Need to inspect logs via Bash; running a cat.',
)
})
test('non-Moonshot providers do NOT receive reasoning_content on assistant messages', async () => {
// Guard: only Moonshot opts in. DeepSeek/OpenRouter/etc. receive the
// outgoing assistant message without reasoning_content to avoid
// unknown-field rejections from strict servers.
process.env.OPENAI_BASE_URL = 'https://api.deepseek.com/v1'
process.env.OPENAI_API_KEY = 'sk-deepseek'
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body))
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'deepseek-chat',
choices: [
{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' },
],
usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
}),
{ headers: { 'Content-Type': 'application/json' } },
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'deepseek-chat',
system: 'test',
messages: [
{ role: 'user', content: 'hi' },
{
role: 'assistant',
content: [
{ type: 'thinking', thinking: 'thought' },
{ type: 'text', text: 'hello' },
{
type: 'tool_use',
id: 'call_1',
name: 'Bash',
input: { command: 'ls' },
},
],
},
{
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'call_1', content: 'files' },
],
},
],
max_tokens: 32,
stream: false,
})
const messages = requestBody?.messages as Array<Record<string, unknown>>
const assistantWithToolCall = messages.find(
m => m.role === 'assistant' && Array.isArray(m.tool_calls),
)
expect(assistantWithToolCall).toBeDefined()
expect(assistantWithToolCall?.reasoning_content).toBeUndefined()
})
test('Moonshot: cn host is also detected', async () => {
process.env.OPENAI_BASE_URL = 'https://api.moonshot.cn/v1'
process.env.OPENAI_API_KEY = 'sk-moonshot-test'

View File

@@ -67,6 +67,8 @@ import {
normalizeToolArguments,
hasToolFieldMapping,
} from './toolArgumentNormalization.js'
import { logApiCallStart, logApiCallEnd } from '../../utils/requestLogging.js'
import { createStreamState, processStreamChunk, getStreamStats } from '../../utils/streamingOptimizer.js'
type SecretValueSource = Partial<{
OPENAI_API_KEY: string
@@ -216,6 +218,14 @@ interface OpenAIMessage {
}>
tool_call_id?: string
name?: string
/**
* Per-assistant-message chain-of-thought, attached when echoing an
* assistant message back to providers that require it (notably Moonshot:
* "thinking is enabled but reasoning_content is missing in assistant
* tool call message at index N" 400). Derived from the Anthropic thinking
* block captured when the original response was translated.
*/
reasoning_content?: string
}
interface OpenAITool {
@@ -383,7 +393,9 @@ function convertMessages(
content?: unknown
}>,
system: unknown,
options?: { preserveReasoningContent?: boolean },
): OpenAIMessage[] {
const preserveReasoningContent = options?.preserveReasoningContent === true
const result: OpenAIMessage[] = []
const knownToolCallIds = new Set<string>()
@@ -486,6 +498,21 @@ function convertMessages(
})(),
}
// Providers that validate reasoning continuity (Moonshot: "thinking
// is enabled but reasoning_content is missing in assistant tool call
// message at index N" 400) need the original chain-of-thought echoed
// back on each assistant message that carries a tool_call. We kept
// the thinking block on the Anthropic side; re-attach it here as the
// `reasoning_content` field on the outgoing OpenAI-shaped message.
// Gated per-provider because other endpoints either ignore the field
// (harmless) or strict-reject unknown fields (harmful).
if (preserveReasoningContent) {
const thinkingText = (thinkingBlock as { thinking?: string } | undefined)?.thinking
if (typeof thinkingText === 'string' && thinkingText.trim().length > 0) {
assistantMsg.reasoning_content = thinkingText
}
}
if (toolUses.length > 0) {
const mappedToolCalls = toolUses
.map(
@@ -857,6 +884,7 @@ async function* openaiStreamToAnthropic(
let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null
let hasEmittedFinalUsage = false
let hasProcessedFinishReason = false
const streamState = createStreamState()
// Emit message_start
yield {
@@ -1020,6 +1048,7 @@ async function* openaiStreamToAnthropic(
delta: { type: 'text_delta', text: visible },
}
}
processStreamChunk(streamState, delta.content)
}
// Tool calls
@@ -1039,6 +1068,7 @@ async function* openaiStreamToAnthropic(
const toolBlockIndex = contentBlockIndex
const initialArguments = tc.function.arguments ?? ''
const normalizeAtStop = hasToolFieldMapping(tc.function.name)
processStreamChunk(streamState, tc.function.arguments ?? '')
activeToolCalls.set(tc.index, {
id: tc.id,
name: tc.function.name,
@@ -1236,6 +1266,20 @@ async function* openaiStreamToAnthropic(
reader.releaseLock()
}
const stats = getStreamStats(streamState)
if (stats.totalChunks > 0) {
logForDebugging(
JSON.stringify({
type: 'stream_stats',
model,
total_chunks: stats.totalChunks,
first_token_ms: stats.firstTokenMs,
duration_ms: stats.durationMs,
}),
{ level: 'debug' },
)
}
yield { type: 'message_stop' }
}
@@ -1441,7 +1485,12 @@ class OpenAIShimMessages {
}>,
request.resolvedModel,
)
const openaiMessages = convertMessages(compressedMessages, params.system)
const openaiMessages = convertMessages(compressedMessages, params.system, {
// Moonshot requires every assistant tool-call message to carry
// reasoning_content when its thinking feature is active. Echo it back
// from the thinking block we captured on the inbound response.
preserveReasoningContent: isMoonshotBaseUrl(request.baseUrl),
})
const body: Record<string, unknown> = {
model: request.resolvedModel,
@@ -1715,6 +1764,12 @@ class OpenAIShimMessages {
}
let response: Response | undefined
const provider = request.baseUrl.includes('nvidia') ? 'nvidia-nim'
: request.baseUrl.includes('minimax') ? 'minimax'
: request.baseUrl.includes('localhost:11434') || request.baseUrl.includes('localhost:11435') ? 'ollama'
: request.baseUrl.includes('anthropic') ? 'anthropic'
: 'openai'
const { correlationId, startTime } = logApiCallStart(provider, request.resolvedModel)
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
response = await fetchWithProxyRetry(
@@ -1752,6 +1807,20 @@ class OpenAIShimMessages {
}
if (response.ok) {
let tokensIn = 0
let tokensOut = 0
// Skip clone() for streaming responses - it blocks until full body is received,
// defeating the purpose of streaming. Usage data is already sent via
// stream_options: { include_usage: true } and can be extracted from the stream.
if (!params.stream) {
try {
const clone = response.clone()
const data = await clone.json()
tokensIn = data.usage?.prompt_tokens ?? 0
tokensOut = data.usage?.completion_tokens ?? 0
} catch { /* ignore */ }
}
logApiCallEnd(correlationId, startTime, request.resolvedModel, 'success', tokensIn, tokensOut, false)
return response
}

View File

@@ -223,6 +223,49 @@ export function bytesPerTokenForFileType(fileExtension: string): number {
}
}
/**
* Tokenizer ratio by model family.
* Different models have different encodings.
*/
export interface ModelTokenizerConfig {
modelFamily: string
bytesPerToken: number
supportsJson: boolean
supportsCode: boolean
}
export const MODEL_TOKENIZER_CONFIGS: ModelTokenizerConfig[] = [
{ modelFamily: 'claude', bytesPerToken: 3.5, supportsJson: true, supportsCode: true },
{ modelFamily: 'gpt-4', bytesPerToken: 4, supportsJson: true, supportsCode: true },
{ modelFamily: 'gpt-3.5', bytesPerToken: 4, supportsJson: true, supportsCode: true },
{ modelFamily: 'gemini', bytesPerToken: 3.5, supportsJson: true, supportsCode: true },
{ modelFamily: 'llama', bytesPerToken: 3.8, supportsJson: true, supportsCode: true },
{ modelFamily: 'deepseek', bytesPerToken: 3.5, supportsJson: true, supportsCode: true },
{ modelFamily: 'minimax', bytesPerToken: 3.2, supportsJson: true, supportsCode: true },
]
/**
* Get tokenizer config for a model.
*/
export function getTokenizerConfig(model: string): ModelTokenizerConfig {
const lower = model.toLowerCase()
for (const config of MODEL_TOKENIZER_CONFIGS) {
if (lower.includes(config.modelFamily)) {
return config
}
}
return { modelFamily: 'unknown', bytesPerToken: 4, supportsJson: true, supportsCode: true }
}
/**
* Get bytes-per-token ratio for a model.
*/
export function getBytesPerTokenForModel(model: string): number {
return getTokenizerConfig(model).bytesPerToken
}
/**
* Like {@link roughTokenCountEstimation} but uses a more accurate
* bytes-per-token ratio when the file type is known.
@@ -241,6 +284,106 @@ export function roughTokenCountEstimationForFileType(
)
}
/**
* Content type classification for compression ratio.
*/
export type ContentType =
| 'json' | 'code' | 'prose' | 'technical'
| 'list' | 'table' | 'mixed'
/**
* Compression ratio by content type.
* Measured empirically - denser content = lower ratio.
*/
export const COMPRESSION_RATIOS: Record<ContentType, { min: number; max: number; typical: number }> = {
json: { min: 1.5, max: 2.5, typical: 2 },
code: { min: 3, max: 4.5, typical: 3.5 },
prose: { min: 3.5, max: 4.5, typical: 4 },
technical: { min: 2.5, max: 3.5, typical: 3 },
list: { min: 2, max: 3, typical: 2.5 },
table: { min: 1.8, max: 2.8, typical: 2.2 },
mixed: { min: 3, max: 4, typical: 3.5 },
}
/**
* Detect content type from content.
*/
export function detectContentType(content: string): ContentType {
const trimmed = content.trim()
// JSON
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
JSON.parse(trimmed)
return 'json'
} catch { /* not valid json */ }
}
// Table (tabs or consistent delimiters)
const lines = trimmed.split('\n')
if (lines.length > 2) {
const hasTabs = lines[0].includes('\t')
const hasCommas = lines[0].includes(',')
if (hasTabs || hasCommas) {
const consistent = lines.slice(1).every(l => l.includes('\t') || l.includes(','))
if (consistent) return 'table'
}
}
// List
if (/^[\d\-\*\•]/.test(trimmed) || /^[\d\-\*\•]/.test(lines[0])) {
return 'list'
}
// Code (high density of special chars)
const codeChars = (content.match(/[{}()\[\];=]/g) || []).length
const codeRatio = codeChars / content.length
if (codeRatio > 0.05) return 'code'
// Technical (has numbers and units)
if (/\d+\s*(px|em|rem|%|ms|s|kb|mb|gb)/i.test(content)) {
return 'technical'
}
// Prose (default - natural language)
return 'prose'
}
/**
* Get compression ratio for content.
*/
export function getCompressionRatio(content: string, type?: ContentType): { ratio: number; min: number; max: number } {
const detectedType = type ?? detectContentType(content)
const { min, max, typical } = COMPRESSION_RATIOS[detectedType]
// Adjust based on actual content length
// Shorter content = higher variance
const lengthBonus = content.length < 100 ? 0.5 : 0
return {
ratio: typical,
min: min + lengthBonus,
max: max + lengthBonus,
}
}
/**
* Estimate tokens with confidence bounds.
*/
export function estimateWithBounds(
content: string,
type?: ContentType,
): { estimate: number; min: number; max: number } {
const { ratio, min: minRatio, max: maxRatio } = getCompressionRatio(content, type)
const estimate = roughTokenCountEstimation(content, ratio)
const min = roughTokenCountEstimation(content, maxRatio)
const max = roughTokenCountEstimation(content, minRatio)
return { estimate, min, max }
}
/**
* Estimates token count for a Message object by extracting and analyzing its text content.
* This provides a more reliable estimate than getTokenUsage for messages that may have been compacted.

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'bun:test'
import {
getTokenizerConfig,
getBytesPerTokenForModel,
detectContentType,
getCompressionRatio,
estimateWithBounds,
} from './tokenEstimation.js'
describe('Model Tokenizers', () => {
describe('getTokenizerConfig', () => {
it('returns config for claude models', () => {
const config = getTokenizerConfig('claude-sonnet-4-5-20250514')
expect(config.modelFamily).toBe('claude')
expect(config.bytesPerToken).toBe(3.5)
})
it('returns config for gpt models', () => {
const config = getTokenizerConfig('gpt-4')
expect(config.modelFamily).toBe('gpt-4')
expect(config.bytesPerToken).toBe(4)
})
it('returns default for unknown models', () => {
const config = getTokenizerConfig('unknown-model')
expect(config.modelFamily).toBe('unknown')
expect(config.bytesPerToken).toBe(4)
})
})
describe('getBytesPerTokenForModel', () => {
it('returns bytes per token for model', () => {
expect(getBytesPerTokenForModel('claude-opus-3-5-20250214')).toBe(3.5)
expect(getBytesPerTokenForModel('gpt-4o')).toBe(4)
expect(getBytesPerTokenForModel('deepseek-chat')).toBe(3.5)
expect(getBytesPerTokenForModel('minimax-M2.7')).toBe(3.2)
})
})
})
describe('Content Type Detection', () => {
describe('detectContentType', () => {
it('detects JSON', () => {
expect(detectContentType('{"key": "value"}')).toBe('json')
expect(detectContentType('[1, 2, 3]')).toBe('json')
})
it('detects code', () => {
expect(detectContentType('function test() { return 1 + 2; }')).toBe('code')
expect(detectContentType('const x = () => {}')).toBe('code')
})
it('detects prose', () => {
expect(detectContentType('This is a natural language response.')).toBe('prose')
expect(detectContentType('Hello world how are you?')).toBe('prose')
})
it('detects code-like technical', () => {
// Has both code chars and technical - higher code char ratio wins
expect(detectContentType('margin: 10px; padding: 5px;')).toBe('code')
})
it('detects list', () => {
expect(detectContentType('- item 1\n- item 2')).toBe('list')
expect(detectContentType('1. first\n2. second')).toBe('list')
})
it('detects prose by default', () => {
// Single column with newlines = prose
expect(detectContentType('a b c\n1 2 3')).toBe('prose')
})
})
})
describe('Compression Ratio', () => {
describe('getCompressionRatio', () => {
it('returns appropriate ratios', () => {
expect(getCompressionRatio('{"a":1}').ratio).toBe(2)
expect(getCompressionRatio('code here {} []').ratio).toBe(3.5)
expect(getCompressionRatio('Hello world').ratio).toBe(4)
})
})
describe('estimateWithBounds', () => {
it('returns estimate with bounds', () => {
const result = estimateWithBounds('Hello world')
expect(result.min).toBeLessThanOrEqual(result.estimate)
expect(result.max).toBeGreaterThanOrEqual(result.estimate)
expect(result.min).toBeLessThan(result.max)
})
it('handles JSON with tighter bounds', () => {
const result = estimateWithBounds('{"key": "value"}')
// JSON has smaller ratio range
expect(result.max).toBeLessThan(10)
})
})
})

View File

@@ -1241,6 +1241,7 @@ async function checkPermissionsAndCallTool(
{
...toolUseContext,
toolUseId: toolUseID,
hookChainsCanUseTool: canUseTool,
userModified: permissionDecision.userModified ?? false,
},
canUseTool,
@@ -1729,19 +1730,29 @@ async function checkPermissionsAndCallTool(
const hookMessages: MessageUpdateLazy<
AttachmentMessage | ProgressMessage<HookProgress>
>[] = []
for await (const hookResult of runPostToolUseFailureHooks(
toolUseContext,
tool,
toolUseID,
messageId,
processedInput,
content,
isInterrupt,
requestId,
mcpServerType,
mcpServerBaseUrl,
)) {
hookMessages.push(hookResult)
const hookChainsContext = toolUseContext as ToolUseContext & {
hookChainsCanUseTool?: CanUseToolFn
}
hookChainsContext.hookChainsCanUseTool = canUseTool
try {
for await (const hookResult of runPostToolUseFailureHooks(
toolUseContext,
tool,
toolUseID,
messageId,
processedInput,
content,
isInterrupt,
requestId,
mcpServerType,
mcpServerBaseUrl,
)) {
hookMessages.push(hookResult)
}
} finally {
if (hookChainsContext.hookChainsCanUseTool === canUseTool) {
delete hookChainsContext.hookChainsCanUseTool
}
}
return [

View File

@@ -284,6 +284,7 @@ export async function* runPostToolUseFailureHooks<Input extends AnyObject>(
isInterrupt,
permissionMode,
toolUseContext.abortController.signal,
undefined,
)) {
try {
// Check if we were aborted during hook execution

View File

@@ -1,6 +1,23 @@
import type { SearchInput, SearchProvider } from './types.js'
import { applyDomainFilters, type ProviderOutput } from './types.js'
// DuckDuckGo's HTML scraper aggressively blocks datacenter / repeat IPs with
// an "anomaly in the request" response. When that happens we surface an
// actionable error instead of the opaque scraper message so users know how
// to configure a working backend.
const DDG_ANOMALY_HINT =
'DuckDuckGo scraping is rate-limited from this network. ' +
'Configure a search backend with one of: ' +
'FIRECRAWL_API_KEY, TAVILY_API_KEY, EXA_API_KEY, YOU_API_KEY, ' +
'JINA_API_KEY, BING_API_KEY, MOJEEK_API_KEY, LINKUP_API_KEY — ' +
'or use an Anthropic / Vertex / Foundry provider for native web search.'
function isAnomalyError(message: string): boolean {
return /anomaly in the request|likely making requests too quickly/i.test(
message,
)
}
export const duckduckgoProvider: SearchProvider = {
name: 'duckduckgo',
@@ -20,7 +37,16 @@ export const duckduckgoProvider: SearchProvider = {
}
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
// TODO: duck-duck-scrape doesn't accept AbortSignal — can't cancel in-flight searches
const response = await search(input.query, { safeSearch: SafeSearchType.STRICT })
let response: Awaited<ReturnType<typeof search>>
try {
response = await search(input.query, { safeSearch: SafeSearchType.STRICT })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (isAnomalyError(msg)) {
throw new Error(DDG_ANOMALY_HINT)
}
throw err
}
const hits = applyDomainFilters(
response.results.map(r => ({

View File

@@ -0,0 +1,350 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
type HookChainsModule = typeof import('./hookChains.js')
type ImportHarnessOptions = {
allowRemoteSessions?: boolean
teamFile?:
| {
name: string
members: Array<{ name: string }>
}
| null
teamName?: string
senderName?: string
replBridgeHandle?: unknown
}
const tempDirs: string[] = []
const originalHookChainsEnabled = process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS
async function createConfigFile(config: unknown): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'openclaude-hook-chains-int-'))
tempDirs.push(dir)
const filePath = join(dir, 'hook-chains.json')
await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8')
return filePath
}
async function importHookChainsHarness(
options: ImportHarnessOptions = {},
): Promise<{
mod: HookChainsModule
writeToMailboxSpy: ReturnType<typeof mock>
agentToolCallSpy: ReturnType<typeof mock>
}> {
mock.restore()
const allowRemoteSessions = options.allowRemoteSessions ?? true
const teamName = options.teamName ?? 'mesh-team'
const senderName = options.senderName ?? 'mesh-lead'
const replBridgeHandle = options.replBridgeHandle ?? null
const writeToMailboxSpy = mock(async () => {})
const agentToolCallSpy = mock(async () => ({
data: {
status: 'async_launched',
agentId: 'agent-fallback-1',
},
}))
mock.module('../services/analytics/index.js', () => ({
logEvent: () => {},
}))
mock.module('./telemetry/events.js', () => ({
logOTelEvent: async () => {},
}))
mock.module('../services/policyLimits/index.js', () => ({
isPolicyAllowed: () => allowRemoteSessions,
}))
mock.module('./swarm/teamHelpers.js', () => ({
readTeamFileAsync: async () => options.teamFile ?? null,
}))
mock.module('./teammateMailbox.js', () => ({
writeToMailbox: writeToMailboxSpy,
}))
mock.module('./teammate.js', () => ({
getAgentName: () => senderName,
getTeamName: () => teamName,
getTeammateColor: () => 'blue',
}))
mock.module('../bridge/replBridgeHandle.js', () => ({
getReplBridgeHandle: () => replBridgeHandle,
}))
// Integration mock target requested in the task: fallback action can route
// through this mocked tool launcher from runtime callback wiring.
mock.module('../tools/AgentTool/AgentTool.js', () => ({
AgentTool: {
call: agentToolCallSpy,
},
}))
const mod = await import(`./hookChains.js?integration=${Date.now()}-${Math.random()}`)
return { mod, writeToMailboxSpy, agentToolCallSpy }
}
beforeEach(() => {
process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = '1'
})
afterEach(async () => {
mock.restore()
if (originalHookChainsEnabled === undefined) {
delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS
} else {
process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled
}
await Promise.all(
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
)
})
describe('hookChains integration dispatch', () => {
test('end-to-end rule evaluation + action dispatch on TaskCompleted failure', async () => {
const { mod } = await importHookChainsHarness({
teamName: 'mesh-team',
senderName: 'mesh-lead',
teamFile: {
name: 'mesh-team',
members: [{ name: 'mesh-lead' }, { name: 'worker-a' }, { name: 'worker-b' }],
},
})
const configPath = await createConfigFile({
version: 1,
enabled: true,
maxChainDepth: 3,
defaultCooldownMs: 0,
defaultDedupWindowMs: 0,
rules: [
{
id: 'task-failure-recovery',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [
{ type: 'spawn_fallback_agent' },
{ type: 'notify_team' },
],
},
],
})
const spawnSpy = mock(async () => ({ launched: true, agentId: 'agent-e2e-1' }))
const notifySpy = mock(async () => ({ sent: true, recipientCount: 2 }))
const result = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: {
task_id: 'task-001',
task_subject: 'Patch flaky build',
error: 'CI timeout',
},
},
runtime: {
onSpawnFallbackAgent: spawnSpy,
onNotifyTeam: notifySpy,
},
})
expect(result.enabled).toBe(true)
expect(result.matchedRuleIds).toEqual(['task-failure-recovery'])
expect(result.actionResults).toHaveLength(2)
expect(result.actionResults[0]?.status).toBe('executed')
expect(result.actionResults[1]?.status).toBe('executed')
expect(spawnSpy).toHaveBeenCalledTimes(1)
expect(notifySpy).toHaveBeenCalledTimes(1)
})
test('fallback spawn injects failure context into generated prompt', async () => {
const { mod, agentToolCallSpy } = await importHookChainsHarness()
const configPath = await createConfigFile({
version: 1,
enabled: true,
maxChainDepth: 3,
defaultCooldownMs: 0,
defaultDedupWindowMs: 0,
rules: [
{
id: 'fallback-context',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [
{
type: 'spawn_fallback_agent',
description: 'Fallback for failed task',
},
],
},
],
})
const result = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: {
task_id: 'task-ctx-1',
task_subject: 'Repair migration guard',
task_description: 'Fix regression in check ordering',
error: 'Task failed after retry budget exhausted',
},
},
runtime: {
onSpawnFallbackAgent: async request => {
const { AgentTool } = await import('../tools/AgentTool/AgentTool.js')
await (AgentTool.call as unknown as (...args: unknown[]) => Promise<unknown>)({
prompt: request.prompt,
description: request.description,
run_in_background: request.runInBackground,
subagent_type: request.agentType,
model: request.model,
})
return { launched: true, agentId: 'agent-fallback-ctx' }
},
},
})
expect(result.actionResults[0]?.status).toBe('executed')
expect(agentToolCallSpy).toHaveBeenCalledTimes(1)
const callInput = agentToolCallSpy.mock.calls[0]?.[0] as {
prompt: string
description: string
run_in_background: boolean
}
expect(callInput.description).toBe('Fallback for failed task')
expect(callInput.run_in_background).toBe(true)
expect(callInput.prompt).toContain('Event: TaskCompleted')
expect(callInput.prompt).toContain('Outcome: failed')
expect(callInput.prompt).toContain('Task subject: Repair migration guard')
expect(callInput.prompt).toContain('Failure details: Task failed after retry budget exhausted')
})
test('notify_team dispatches mailbox writes when team exists and skips when absent', async () => {
const withTeam = await importHookChainsHarness({
teamName: 'mesh-a',
senderName: 'lead-a',
teamFile: {
name: 'mesh-a',
members: [{ name: 'lead-a' }, { name: 'worker-1' }, { name: 'worker-2' }],
},
})
const configPathWithTeam = await createConfigFile({
version: 1,
enabled: true,
maxChainDepth: 3,
defaultCooldownMs: 0,
defaultDedupWindowMs: 0,
rules: [
{
id: 'notify-existing-team',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [{ type: 'notify_team' }],
},
],
})
const withTeamResult = await withTeam.mod.dispatchHookChainsForEvent({
configPathOverride: configPathWithTeam,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-team-ok', error: 'boom' },
},
})
expect(withTeamResult.actionResults[0]?.status).toBe('executed')
expect(withTeam.writeToMailboxSpy).toHaveBeenCalledTimes(2)
const recipients = withTeam.writeToMailboxSpy.mock.calls.map(
call => call[0] as string,
)
expect(recipients.sort()).toEqual(['worker-1', 'worker-2'])
const withoutTeam = await importHookChainsHarness({
teamName: 'mesh-missing',
senderName: 'lead-missing',
teamFile: null,
})
const configPathWithoutTeam = await createConfigFile({
version: 1,
enabled: true,
maxChainDepth: 3,
defaultCooldownMs: 0,
defaultDedupWindowMs: 0,
rules: [
{
id: 'notify-missing-team',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [{ type: 'notify_team' }],
},
],
})
const withoutTeamResult = await withoutTeam.mod.dispatchHookChainsForEvent({
configPathOverride: configPathWithoutTeam,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-team-missing', error: 'boom' },
},
})
expect(withoutTeamResult.actionResults[0]?.status).toBe('skipped')
expect(withoutTeamResult.actionResults[0]?.reason).toContain('Team file not found')
expect(withoutTeam.writeToMailboxSpy).not.toHaveBeenCalled()
})
test('warm_remote_capacity is a safe no-op when bridge is inactive', async () => {
const { mod } = await importHookChainsHarness({
allowRemoteSessions: true,
replBridgeHandle: null,
})
const configPath = await createConfigFile({
version: 1,
enabled: true,
maxChainDepth: 3,
defaultCooldownMs: 0,
defaultDedupWindowMs: 0,
rules: [
{
id: 'bridge-warmup-noop',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [{ type: 'warm_remote_capacity' }],
},
],
})
const result = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-warm-1' },
},
})
expect(result.actionResults).toHaveLength(1)
expect(result.actionResults[0]?.status).toBe('skipped')
expect(result.actionResults[0]?.reason).toContain('Bridge is not active')
})
})

View File

@@ -0,0 +1,476 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
type HookChainsModule = typeof import('./hookChains.js')
const tempDirs: string[] = []
const originalHookChainsEnabled = process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS
async function makeConfigFile(config: unknown): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'openclaude-hook-chains-'))
tempDirs.push(dir)
const filePath = join(dir, 'hook-chains.json')
await writeFile(filePath, JSON.stringify(config, null, 2), 'utf-8')
return filePath
}
async function importHookChainsModule(options?: {
allowRemoteSessions?: boolean
}): Promise<HookChainsModule> {
mock.restore()
const allowRemoteSessions = options?.allowRemoteSessions ?? true
mock.module('../services/analytics/index.js', () => ({
logEvent: () => {},
}))
mock.module('./telemetry/events.js', () => ({
logOTelEvent: async () => {},
}))
mock.module('../services/policyLimits/index.js', () => ({
isPolicyAllowed: () => allowRemoteSessions,
}))
return import(`./hookChains.js?test=${Date.now()}-${Math.random()}`)
}
beforeEach(() => {
process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = '1'
})
afterEach(async () => {
mock.restore()
if (originalHookChainsEnabled === undefined) {
delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS
} else {
process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS = originalHookChainsEnabled
}
await Promise.all(
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
)
})
describe('hookChains schema validation', () => {
test('returns disabled config when env gate is unset', async () => {
delete process.env.CLAUDE_CODE_ENABLE_HOOK_CHAINS
const mod = await importHookChainsModule()
const configPath = await makeConfigFile({
version: 1,
enabled: true,
rules: [
{
id: 'env-gated-rule',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [{ type: 'spawn_fallback_agent' }],
},
],
})
const loaded = mod.loadHookChainsConfig({ pathOverride: configPath })
expect(loaded.exists).toBe(false)
expect(loaded.config.enabled).toBe(false)
expect(loaded.config.rules).toHaveLength(0)
})
test('loads valid config and memoizes by mtime/size', async () => {
const mod = await importHookChainsModule()
const configPath = await makeConfigFile({
version: 1,
enabled: true,
maxChainDepth: 3,
defaultCooldownMs: 5000,
defaultDedupWindowMs: 5000,
rules: [
{
id: 'task-failure-fallback',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [
{
type: 'spawn_fallback_agent',
description: 'Fallback recovery agent',
},
],
},
],
})
const first = mod.loadHookChainsConfig({ pathOverride: configPath })
expect(first.exists).toBe(true)
expect(first.error).toBeUndefined()
expect(first.fromCache).toBe(false)
expect(first.config.enabled).toBe(true)
expect(first.config.rules).toHaveLength(1)
expect(first.config.rules[0]?.id).toBe('task-failure-fallback')
const second = mod.loadHookChainsConfig({ pathOverride: configPath })
expect(second.exists).toBe(true)
expect(second.error).toBeUndefined()
expect(second.fromCache).toBe(true)
expect(second.config.rules).toHaveLength(1)
})
test('accepts wrapped { hookChains: ... } config shape', async () => {
const mod = await importHookChainsModule()
const configPath = await makeConfigFile({
hookChains: {
version: 1,
enabled: true,
rules: [
{
id: 'wrapped-shape',
trigger: { event: 'PostToolUseFailure', outcomes: ['failed'] },
actions: [{ type: 'notify_team' }],
},
],
},
})
const loaded = mod.loadHookChainsConfig({ pathOverride: configPath })
expect(loaded.error).toBeUndefined()
expect(loaded.config.enabled).toBe(true)
expect(loaded.config.rules[0]?.id).toBe('wrapped-shape')
})
test('returns disabled config for invalid schema', async () => {
const mod = await importHookChainsModule()
const configPath = await makeConfigFile({
version: 1,
enabled: true,
rules: [
{
id: 'invalid-rule',
trigger: {
event: 'TaskCompleted',
outcome: 'failed',
outcomes: ['failed'],
},
actions: [{ type: 'spawn_fallback_agent' }],
},
],
})
const loaded = mod.loadHookChainsConfig({ pathOverride: configPath })
expect(loaded.exists).toBe(true)
expect(loaded.error).toBeDefined()
expect(loaded.config.enabled).toBe(false)
expect(loaded.config.rules).toHaveLength(0)
})
})
describe('evaluateHookChainRules', () => {
test('matches by event + outcome + condition', async () => {
const mod = await importHookChainsModule()
const rules = [
{
id: 'post-tool-failure-rule',
trigger: { event: 'PostToolUseFailure', outcome: 'failed' },
condition: {
toolNames: ['Edit'],
errorIncludes: ['permission'],
eventFieldEquals: { 'meta.source': 'scheduler' },
},
actions: [{ type: 'spawn_fallback_agent' }],
},
]
const matches = mod.evaluateHookChainRules(rules as never, {
eventName: 'PostToolUseFailure',
outcome: 'failed',
payload: {
tool_name: 'Edit',
error: 'Permission denied by policy',
meta: { source: 'scheduler' },
},
})
expect(matches).toHaveLength(1)
expect(matches[0]?.rule.id).toBe('post-tool-failure-rule')
})
test('does not match when event/condition fail', async () => {
const mod = await importHookChainsModule()
const rules = [
{
id: 'rule-no-match',
trigger: { event: 'PostToolUseFailure', outcomes: ['failed'] },
condition: { toolNames: ['Write'] },
actions: [{ type: 'spawn_fallback_agent' }],
},
]
const wrongEvent = mod.evaluateHookChainRules(rules as never, {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { tool_name: 'Write' },
})
expect(wrongEvent).toHaveLength(0)
const wrongCondition = mod.evaluateHookChainRules(rules as never, {
eventName: 'PostToolUseFailure',
outcome: 'failed',
payload: { tool_name: 'Edit' },
})
expect(wrongCondition).toHaveLength(0)
})
})
describe('dispatchHookChainsForEvent guard logic', () => {
test('dedup skips duplicate event/action within dedup window', async () => {
const mod = await importHookChainsModule()
const configPath = await makeConfigFile({
version: 1,
enabled: true,
maxChainDepth: 4,
defaultCooldownMs: 0,
defaultDedupWindowMs: 60_000,
rules: [
{
id: 'dedup-rule',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
cooldownMs: 0,
dedupWindowMs: 60_000,
actions: [{ id: 'spawn-1', type: 'spawn_fallback_agent' }],
},
],
})
const spawn = mock(async () => ({ launched: true, agentId: 'agent-1' }))
const first = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-123', error: 'boom' },
},
runtime: { onSpawnFallbackAgent: spawn },
})
const second = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-123', error: 'boom' },
},
runtime: { onSpawnFallbackAgent: spawn },
})
expect(first.actionResults[0]?.status).toBe('executed')
expect(second.actionResults[0]?.status).toBe('skipped')
expect(second.actionResults[0]?.reason).toContain('dedup')
expect(spawn).toHaveBeenCalledTimes(1)
})
test('cooldown skips second dispatch when rule cooldown is active', async () => {
const mod = await importHookChainsModule()
const configPath = await makeConfigFile({
version: 1,
enabled: true,
maxChainDepth: 4,
defaultCooldownMs: 60_000,
defaultDedupWindowMs: 0,
rules: [
{
id: 'cooldown-rule',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
cooldownMs: 60_000,
dedupWindowMs: 0,
actions: [{ type: 'spawn_fallback_agent' }],
},
],
})
const spawn = mock(async () => ({ launched: true, agentId: 'agent-2' }))
const first = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-456' },
},
runtime: { onSpawnFallbackAgent: spawn },
})
const second = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-789' },
},
runtime: { onSpawnFallbackAgent: spawn },
})
expect(first.actionResults[0]?.status).toBe('executed')
expect(second.actionResults[0]?.status).toBe('skipped')
expect(second.actionResults[0]?.reason).toContain('cooldown')
expect(spawn).toHaveBeenCalledTimes(1)
})
test('depth limit blocks dispatch when chain depth reaches max', async () => {
const mod = await importHookChainsModule()
const configPath = await makeConfigFile({
version: 1,
enabled: true,
maxChainDepth: 1,
defaultCooldownMs: 0,
defaultDedupWindowMs: 0,
rules: [
{
id: 'depth-rule',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [{ type: 'spawn_fallback_agent' }],
},
],
})
const spawn = mock(async () => ({ launched: true, agentId: 'agent-3' }))
const result = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-depth' },
},
runtime: {
chainDepth: 1,
onSpawnFallbackAgent: spawn,
},
})
expect(result.enabled).toBe(true)
expect(result.matchedRuleIds).toHaveLength(0)
expect(result.actionResults).toHaveLength(0)
expect(spawn).not.toHaveBeenCalled()
})
})
describe('action dispatch skip scenarios', () => {
test('fails spawn_fallback_agent when launcher callback is missing', async () => {
const mod = await importHookChainsModule()
const configPath = await makeConfigFile({
version: 1,
enabled: true,
maxChainDepth: 3,
defaultCooldownMs: 0,
defaultDedupWindowMs: 0,
rules: [
{
id: 'missing-launcher',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [{ type: 'spawn_fallback_agent' }],
},
],
})
const result = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-missing-launcher' },
},
runtime: {},
})
expect(result.actionResults[0]?.status).toBe('failed')
expect(result.actionResults[0]?.reason).toContain('launcher')
})
test('skips disabled action and does not execute callback', async () => {
const mod = await importHookChainsModule()
const configPath = await makeConfigFile({
version: 1,
enabled: true,
maxChainDepth: 3,
defaultCooldownMs: 0,
defaultDedupWindowMs: 0,
rules: [
{
id: 'disabled-action-rule',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [
{
type: 'spawn_fallback_agent',
enabled: false,
},
],
},
],
})
const spawn = mock(async () => ({ launched: true, agentId: 'agent-4' }))
const result = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-disabled' },
},
runtime: { onSpawnFallbackAgent: spawn },
})
expect(result.actionResults[0]?.status).toBe('skipped')
expect(result.actionResults[0]?.reason).toContain('disabled')
expect(spawn).not.toHaveBeenCalled()
})
test('skips warm_remote_capacity when policy denies remote sessions', async () => {
const mod = await importHookChainsModule({ allowRemoteSessions: false })
const configPath = await makeConfigFile({
version: 1,
enabled: true,
maxChainDepth: 3,
defaultCooldownMs: 0,
defaultDedupWindowMs: 0,
rules: [
{
id: 'policy-denied-remote-warm',
trigger: { event: 'TaskCompleted', outcome: 'failed' },
actions: [{ type: 'warm_remote_capacity' }],
},
],
})
const warm = mock(async () => ({
warmed: true,
environmentId: 'env-123',
}))
const result = await mod.dispatchHookChainsForEvent({
configPathOverride: configPath,
event: {
eventName: 'TaskCompleted',
outcome: 'failed',
payload: { task_id: 'task-policy-denied' },
},
runtime: { onWarmRemoteCapacity: warm },
})
expect(result.actionResults[0]?.status).toBe('skipped')
expect(result.actionResults[0]?.reason).toContain('policy')
expect(warm).not.toHaveBeenCalled()
})
})

1518
src/utils/hookChains.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import { wrapSpawn } from './ShellCommand.js'
import { TaskOutput } from './task/TaskOutput.js'
import { getCwd } from './cwd.js'
import { randomUUID } from 'crypto'
import { feature } from 'bun:bundle'
import { formatShellPrefixCommand } from './bash/shellPrefix.js'
import {
getHookEnvFilePath,
@@ -134,6 +135,7 @@ import { registerPendingAsyncHook } from './hooks/AsyncHookRegistry.js'
import { enqueuePendingNotification } from './messageQueueManager.js'
import {
extractTextContent,
createAssistantMessage,
getLastAssistantMessage,
wrapInSystemReminder,
} from './messages.js'
@@ -145,6 +147,7 @@ import {
import { createAttachmentMessage } from './attachments.js'
import { all } from './generators.js'
import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js'
import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
import { execPromptHook } from './hooks/execPromptHook.js'
import type { Message, AssistantMessage } from '../types/message.js'
import { execAgentHook } from './hooks/execAgentHook.js'
@@ -162,9 +165,147 @@ import type { AppState } from '../state/AppState.js'
import { jsonStringify, jsonParse } from './slowOperations.js'
import { isEnvTruthy } from './envUtils.js'
import { errorMessage, getErrnoCode } from './errors.js'
import { getAgentName, getTeamName, getTeammateColor } from './teammate.js'
import type {
HookChainOutcome,
HookChainRuntimeContext,
SpawnFallbackAgentRequest,
SpawnFallbackAgentResponse,
} from './hookChains.js'
const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000
function normalizeFallbackAgentModel(
model: string | undefined,
): 'sonnet' | 'opus' | 'haiku' | undefined {
if (model === 'sonnet' || model === 'opus' || model === 'haiku') {
return model
}
return undefined
}
async function launchFallbackAgentFromHookChains(
request: SpawnFallbackAgentRequest,
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
): Promise<SpawnFallbackAgentResponse> {
try {
const { AgentTool } = await import('../tools/AgentTool/AgentTool.js')
const normalizedModel = normalizeFallbackAgentModel(request.model)
const result = await AgentTool.call(
{
prompt: request.prompt,
description: request.description,
run_in_background: true,
...(request.agentType ? { subagent_type: request.agentType } : {}),
...(normalizedModel ? { model: normalizedModel } : {}),
},
toolUseContext,
canUseTool,
createAssistantMessage({ content: [] }),
)
const data = result.data as
| {
status?: string
agentId?: string
agent_id?: string
}
| undefined
const status = data?.status
if (
status === 'async_launched' ||
status === 'completed' ||
status === 'remote_launched' ||
status === 'teammate_spawned'
) {
return {
launched: true,
agentId: data?.agentId ?? data?.agent_id,
}
}
return {
launched: true,
reason:
status !== undefined
? `Fallback launched with status ${status}`
: undefined,
}
} catch (error) {
return {
launched: false,
reason: `Fallback launch failed: ${errorMessage(error)}`,
}
}
}
async function dispatchHookChainFromHookRuntime(args: {
eventName: 'PostToolUseFailure' | 'TaskCompleted'
outcome: HookChainOutcome
payload: Record<string, unknown>
signal?: AbortSignal
toolUseContext?: ToolUseContext
}): Promise<void> {
try {
if (!feature('HOOK_CHAINS')) {
return
}
const { dispatchHookChainsForEvent } = await import('./hookChains.js')
const runtime: HookChainRuntimeContext = {
signal: args.signal,
senderName: getAgentName() ?? undefined,
senderColor: getTeammateColor() ?? undefined,
teamName: getTeamName() ?? undefined,
}
const chainDepth = args.toolUseContext?.queryTracking?.depth
if (typeof chainDepth === 'number' && Number.isFinite(chainDepth)) {
runtime.chainDepth = chainDepth
}
const hookChainsCanUseTool = (
args.toolUseContext as
| (ToolUseContext & { hookChainsCanUseTool?: CanUseToolFn })
| undefined
)?.hookChainsCanUseTool
if (args.toolUseContext) {
runtime.onSpawnFallbackAgent = request => {
if (!hookChainsCanUseTool) {
return Promise.resolve({
launched: false,
reason:
'Fallback action requires canUseTool in this hook runtime context',
})
}
return launchFallbackAgentFromHookChains(
request,
args.toolUseContext!,
hookChainsCanUseTool,
)
}
}
await dispatchHookChainsForEvent({
event: {
eventName: args.eventName,
outcome: args.outcome,
payload: args.payload,
},
runtime,
})
} catch (error) {
logForDebugging(
`[hook-chains] Dispatch failed for ${args.eventName}: ${errorMessage(error)}`,
)
}
}
/**
* SessionEnd hooks run during shutdown/clear and need a much tighter bound
* than TOOL_HOOK_EXECUTION_TIMEOUT_MS. This value is used by callers as both
@@ -3502,9 +3643,11 @@ export async function* executePostToolUseFailureHooks<ToolInput>(
): AsyncGenerator<AggregatedHookResult> {
const appState = toolUseContext.getAppState()
const sessionId = toolUseContext.agentId ?? getSessionId()
if (!hasHookForEvent('PostToolUseFailure', appState, sessionId)) {
return
}
const hasPostToolFailureHooks = hasHookForEvent(
'PostToolUseFailure',
appState,
sessionId,
)
const hookInput: PostToolUseFailureHookInput = {
...createBaseHookInput(permissionMode, undefined, toolUseContext),
@@ -3516,12 +3659,33 @@ export async function* executePostToolUseFailureHooks<ToolInput>(
is_interrupt: isInterrupt,
}
yield* executeHooks({
hookInput,
toolUseID,
matchQuery: toolName,
let blockingHookCount = 0
if (hasPostToolFailureHooks) {
for await (const result of executeHooks({
hookInput,
toolUseID,
matchQuery: toolName,
signal,
timeoutMs,
toolUseContext,
})) {
if (result.blockingError) {
blockingHookCount++
}
yield result
}
}
await dispatchHookChainFromHookRuntime({
eventName: 'PostToolUseFailure',
outcome: 'failed',
payload: {
...hookInput,
hook_blocking_error_count: blockingHookCount,
hook_execution_skipped: !hasPostToolFailureHooks,
},
signal,
timeoutMs,
toolUseContext,
})
}
@@ -3807,12 +3971,36 @@ export async function* executeTaskCompletedHooks(
team_name: teamName,
}
yield* executeHooks({
let blockingHookCount = 0
let preventedContinuation = false
for await (const result of executeHooks({
hookInput,
toolUseID: randomUUID(),
signal,
timeoutMs,
toolUseContext,
})) {
if (result.blockingError) {
blockingHookCount++
}
if (result.preventContinuation) {
preventedContinuation = true
}
yield result
}
await dispatchHookChainFromHookRuntime({
eventName: 'TaskCompleted',
outcome:
blockingHookCount > 0 || preventedContinuation ? 'failed' : 'success',
payload: {
...hookInput,
hook_blocking_error_count: blockingHookCount,
hook_prevented_continuation: preventedContinuation,
},
signal,
toolUseContext,
})
}

View File

@@ -0,0 +1,86 @@
import { describe, expect, it, beforeEach } from 'bun:test'
import {
createCorrelationId,
logApiCallStart,
logApiCallEnd,
} from './requestLogging.js'
describe('requestLogging', () => {
describe('createCorrelationId', () => {
it('returns a non-empty string', () => {
const id = createCorrelationId()
expect(id).toBeTruthy()
expect(typeof id).toBe('string')
})
it('returns unique IDs', () => {
const id1 = createCorrelationId()
const id2 = createCorrelationId()
expect(id1).not.toBe(id2)
})
})
describe('logApiCallStart', () => {
it('returns correlation ID and start time', () => {
const result = logApiCallStart('openai', 'gpt-4o')
expect(result.correlationId).toBeTruthy()
expect(result.startTime).toBeGreaterThan(0)
})
it('logs without throwing', () => {
expect(() => logApiCallStart('ollama', 'llama3')).not.toThrow()
})
})
describe('logApiCallEnd', () => {
it('logs success without throwing', () => {
const { correlationId, startTime } = logApiCallStart('openai', 'gpt-4o')
expect(() =>
logApiCallEnd(
correlationId,
startTime,
'gpt-4o',
'success',
100,
50,
false,
),
).not.toThrow()
})
it('logs error without throwing', () => {
const { correlationId, startTime } = logApiCallStart('openai', 'gpt-4o')
expect(() =>
logApiCallEnd(
correlationId,
startTime,
'gpt-4o',
'error',
0,
0,
false,
undefined,
undefined,
'Network error',
),
).not.toThrow()
})
it('logs with all parameters without throwing', () => {
const { correlationId, startTime } = logApiCallStart('openai', 'gpt-4o')
expect(() =>
logApiCallEnd(
correlationId,
startTime,
'gpt-4o',
'success',
100,
50,
true,
'error message',
{ provider: 'openai' },
),
).not.toThrow()
})
})
})

View File

@@ -0,0 +1,89 @@
/**
* Structured Request Logging
*
* Uses existing logForDebugging for structured logging.
*/
import { randomUUID } from 'crypto'
import { logForDebugging } from './debug.js'
export interface RequestLog {
correlationId: string
timestamp: number
provider: string
model: string
duration: number
status: 'success' | 'error'
tokensIn: number
tokensOut: number
error?: string
streaming: boolean
}
export function createCorrelationId(): string {
return randomUUID()
}
export function logApiCallStart(
provider: string,
model: string,
): { correlationId: string; startTime: number } {
const correlationId = createCorrelationId()
const startTime = Date.now()
logForDebugging(
JSON.stringify({
type: 'api_call_start',
correlationId,
provider,
model,
timestamp: startTime,
}),
{ level: 'debug' },
)
return { correlationId, startTime }
}
export function logApiCallEnd(
correlationId: string,
startTime: number,
model: string,
status: 'success' | 'error',
tokensIn: number,
tokensOut: number,
streaming: boolean,
firstTokenMs?: number,
totalChunks?: number,
error?: string,
): void {
const duration = Date.now() - startTime
const logData: Record<string, unknown> = {
type: status === 'error' ? 'api_call_error' : 'api_call_end',
correlationId,
model,
duration_ms: duration,
status,
tokens_in: tokensIn,
tokens_out: tokensOut,
streaming,
}
if (firstTokenMs !== undefined) {
logData.first_token_ms = firstTokenMs
}
if (totalChunks !== undefined) {
logData.total_chunks = totalChunks
}
if (error) {
logData.error = error
}
logForDebugging(
JSON.stringify(logData),
{ level: status === 'error' ? 'error' : 'debug' },
)
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, beforeEach } from 'bun:test'
import {
createStreamState,
processStreamChunk,
flushStreamBuffer,
getStreamStats,
} from './streamingOptimizer.js'
describe('streamingOptimizer', () => {
let state: ReturnType<typeof createStreamState>
beforeEach(() => {
state = createStreamState()
})
describe('createStreamState', () => {
it('creates initial state with zero counts', () => {
expect(state.chunkCount).toBe(0)
expect(state.firstTokenTime).toBeNull()
expect(state.startTime).toBeGreaterThan(0)
})
})
describe('processStreamChunk', () => {
it('tracks first token time on first chunk', () => {
processStreamChunk(state, 'hello')
expect(state.firstTokenTime).not.toBeNull()
expect(state.chunkCount).toBe(1)
})
it('increments chunk count', () => {
processStreamChunk(state, 'chunk1')
processStreamChunk(state, 'chunk2')
expect(state.chunkCount).toBe(2)
})
})
describe('getStreamStats', () => {
it('returns zero values for empty stream', () => {
const stats = getStreamStats(state)
expect(stats.totalChunks).toBe(0)
expect(stats.firstTokenMs).toBeNull()
expect(stats.durationMs).toBeGreaterThanOrEqual(0)
})
it('returns correct stats after processing chunks', () => {
processStreamChunk(state, 'test')
const stats = getStreamStats(state)
expect(stats.totalChunks).toBe(1)
expect(stats.firstTokenMs).toBeGreaterThanOrEqual(0)
expect(stats.durationMs).toBeGreaterThanOrEqual(0)
})
})
describe('flushStreamBuffer', () => {
it('returns empty string (no-op)', () => {
const result = flushStreamBuffer(state)
expect(result).toBe('')
})
})
})

View File

@@ -0,0 +1,51 @@
/**
* Streaming Stats Tracker
*
* Observational stats tracking for streaming responses.
* No buffering - purely tracks metrics for monitoring.
*/
export interface StreamStats {
totalChunks: number
firstTokenMs: number | null
durationMs: number
}
export interface StreamState {
chunkCount: number
firstTokenTime: number | null
startTime: number
}
export function createStreamState(): StreamState {
return {
chunkCount: 0,
firstTokenTime: null,
startTime: Date.now(),
}
}
export function processStreamChunk(state: StreamState, _chunk: string): void {
if (state.firstTokenTime === null) {
state.firstTokenTime = Date.now()
}
state.chunkCount++
}
export function flushStreamBuffer(_state: StreamState): string {
return '' // No-op - kept for API compatibility
}
export function getStreamStats(state: StreamState): StreamStats {
const now = Date.now()
const firstTokenMs = state.firstTokenTime
? now - state.firstTokenTime
: null
const durationMs = now - state.startTime
return {
totalChunks: state.chunkCount,
firstTokenMs,
durationMs,
}
}