豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 57056cc

Browse files
Done correctly based on docs and remove old logging hook
1 parent 9a94617 commit 57056cc

File tree

3 files changed

+470
-45
lines changed

3 files changed

+470
-45
lines changed

src/extension/chatSessions/claude/common/claudeMessageDispatch.ts

Lines changed: 180 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import type { SDKAssistantMessage, SDKCompactBoundaryMessage, SDKHookResponseMessage, SDKHookStartedMessage, SDKMessage, SDKResultMessage, SDKUserMessage, SDKUserMessageReplay } from '@anthropic-ai/claude-agent-sdk';
6+
import type { SDKAssistantMessage, SDKCompactBoundaryMessage, SDKHookProgressMessage, SDKHookResponseMessage, SDKHookStartedMessage, SDKMessage, SDKResultMessage, SDKUserMessage, SDKUserMessageReplay } from '@anthropic-ai/claude-agent-sdk';
77
import type { TodoWriteInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools';
88
import type Anthropic from '@anthropic-ai/sdk';
99
import * as l10n from '@vscode/l10n';
1010
import type * as vscode from 'vscode';
11+
import { vBoolean, vLiteral, vObj, vString, type ValidatorType } from '../../../../platform/configuration/common/validator';
1112
import { ILogService } from '../../../../platform/log/common/logService';
12-
import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, type ISpanHandle, SpanKind, SpanStatusCode, truncateForOTel } from '../../../../platform/otel/common/index';
13+
import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel, type ISpanHandle } from '../../../../platform/otel/common/index';
1314
import { ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';
14-
import { ChatResponseThinkingProgressPart } from '../../../../vscodeTypes';
15+
import { ChatResponseThinkingProgressPart, type ChatHookType } from '../../../../vscodeTypes';
1516
import { ToolName } from '../../../tools/common/toolNames';
1617
import { IToolsService } from '../../../tools/common/toolsService';
1718
import { ClaudeToolNames } from './claudeTools';
@@ -89,9 +90,7 @@ export const ALL_KNOWN_MESSAGE_KEYS = new Set([
8990
// TODO: Show `system:local_command_output` — has `content` text from local slash commands
9091
'system:local_command_output',
9192
'system:hook_started',
92-
// TODO: Show `system:hook_progress` — has `stdout`, `stderr`, `output` for streaming hook output
9393
'system:hook_progress',
94-
// TODO: Show `system:hook_response` — has `output`, `stderr`, `outcome` — surface errors to user
9594
'system:hook_response',
9695
// TODO: Show `system:task_notification` — has `summary` and `status` for subagent completion
9796
'system:task_notification',
@@ -311,13 +310,127 @@ export function handleHookStarted(
311310
state.otelHookSpans.set(message.hook_id, span);
312311
}
313312

313+
// #region Hook JSON output validator
314+
315+
/**
316+
* Validator for structured JSON output from hooks (exit code 0 only).
317+
*
318+
* Hooks can return JSON with these fields:
319+
* - `continue`: if false, stops processing entirely
320+
* - `stopReason`: message shown to user when `continue` is false
321+
* - `systemMessage`: warning shown to user
322+
* - `decision`: "block" to prevent the operation
323+
* - `reason`: explanation when `decision` is "block"
324+
*
325+
* @see https://code.claude.com/docs/en/hooks.md
326+
*/
327+
const vHookJsonOutput = vObj({
328+
continue: vBoolean(),
329+
stopReason: vString(),
330+
systemMessage: vString(),
331+
decision: vLiteral('block'),
332+
reason: vString(),
333+
});
334+
335+
export type HookJsonOutput = ValidatorType<typeof vHookJsonOutput>;
336+
337+
/**
338+
* Parses JSON output from a hook's stdout.
339+
* Returns the validated fields, or undefined if parsing/validation fails.
340+
* Fields that are missing from the JSON are simply absent from the result.
341+
*/
342+
export function parseHookJsonOutput(stdout: string): Partial<HookJsonOutput> | undefined {
343+
let raw: unknown;
344+
try {
345+
raw = JSON.parse(stdout);
346+
} catch {
347+
return undefined;
348+
}
349+
350+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
351+
return undefined;
352+
}
353+
354+
// Use the validator to extract known fields with type safety.
355+
// vObj skips missing optional fields, so partial results are expected.
356+
const result = vHookJsonOutput.validate(raw);
357+
if (result.error) {
358+
// Validation error means some present field had the wrong type —
359+
// extract what we can by validating each field individually.
360+
const obj = raw as Record<string, unknown>;
361+
const partial: Partial<HookJsonOutput> = {};
362+
363+
const continueResult = vBoolean().validate(obj['continue']);
364+
if (!continueResult.error) {
365+
partial.continue = continueResult.content;
366+
}
367+
const stopReasonResult = vString().validate(obj['stopReason']);
368+
if (!stopReasonResult.error) {
369+
partial.stopReason = stopReasonResult.content;
370+
}
371+
const systemMessageResult = vString().validate(obj['systemMessage']);
372+
if (!systemMessageResult.error) {
373+
partial.systemMessage = systemMessageResult.content;
374+
}
375+
const decisionResult = vLiteral('block').validate(obj['decision']);
376+
if (!decisionResult.error) {
377+
partial.decision = decisionResult.content;
378+
}
379+
const reasonResult = vString().validate(obj['reason']);
380+
if (!reasonResult.error) {
381+
partial.reason = reasonResult.content;
382+
}
383+
384+
return Object.keys(partial).length > 0 ? partial : undefined;
385+
}
386+
387+
return result.content;
388+
}
389+
390+
// #endregion
391+
392+
/**
393+
* Formats a localized error message for a failed hook.
394+
* @param errorMessage The error message from the hook
395+
* @returns A localized error message string
396+
* @todo use a common function with: https://github.com/microsoft/vscode-copilot-chat/blob/9a9461734da42f28e4e2d0b975ebeae6162e9b4c/src/extension/intents/node/hookResultProcessor.ts#L142
397+
*/
398+
function formatHookErrorMessage(errorMessage: string): string {
399+
if (errorMessage) {
400+
return l10n.t('A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details. \nError message: {0}', errorMessage);
401+
}
402+
return l10n.t('A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details.');
403+
}
404+
405+
406+
export function handleHookProgress(
407+
message: SDKHookProgressMessage,
408+
accessor: ServicesAccessor,
409+
request: MessageHandlerRequestContext,
410+
): void {
411+
const logService = accessor.get(ILogService);
412+
// TODO: can we map these types better
413+
const hookType = message.hook_event as ChatHookType;
414+
const progressText = message.stdout || message.stderr;
415+
416+
logService.trace(`[ClaudeMessageDispatch] Hook progress "${message.hook_name}" (${message.hook_event}): ${progressText}`);
417+
418+
if (progressText) {
419+
request.stream.hookProgress(hookType, undefined, progressText);
420+
}
421+
}
422+
314423
export function handleHookResponse(
315424
message: SDKHookResponseMessage,
316425
accessor: ServicesAccessor,
317426
request: MessageHandlerRequestContext,
318427
state: MessageHandlerState,
319428
): void {
320429
const logService = accessor.get(ILogService);
430+
// TODO: can we map these types better
431+
const hookType = message.hook_event as ChatHookType;
432+
433+
// #region OTel span
321434
const span = state.otelHookSpans.get(message.hook_id);
322435
if (span) {
323436
if (message.outcome === 'error') {
@@ -336,13 +449,66 @@ export function handleHookResponse(
336449
span.end();
337450
state.otelHookSpans.delete(message.hook_id);
338451
}
452+
// #endregion
339453

340-
if (message.outcome === 'error') {
341-
logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) failed: ${message.stderr || message.output}`);
342-
request.stream.markdown(`*${l10n.t('Hook "{0}" failed', message.hook_name)}*\n`);
343-
if (message.stderr) {
344-
request.stream.markdown(`\`\`\`\n${message.stderr}\n\`\`\`\n`);
454+
// Cancelled — log only, no user-facing output
455+
if (message.outcome === 'cancelled') {
456+
logService.trace(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) was cancelled`);
457+
return;
458+
}
459+
460+
// Exit code 2 — blocking error (stderr is the message, JSON ignored)
461+
if (message.exit_code === 2) {
462+
const errorMessage = message.stderr || message.output;
463+
logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) blocking error: ${errorMessage}`);
464+
request.stream.hookProgress(hookType, formatHookErrorMessage(errorMessage));
465+
return;
466+
}
467+
468+
// Other non-zero exit codes — non-blocking warning
469+
if (message.exit_code !== undefined && message.exit_code !== 0) {
470+
const warningMessage = message.stderr || message.output || (l10n.t('Exit Code: {0}', message.exit_code));
471+
logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) non-blocking error (exit ${message.exit_code}): ${warningMessage}`);
472+
if (warningMessage) {
473+
request.stream.hookProgress(hookType, undefined, warningMessage);
345474
}
475+
return;
476+
}
477+
478+
// Outcome 'error' without a specific exit code — treat as blocking error
479+
if (message.outcome === 'error') {
480+
const errorMessage = message.stderr || message.output;
481+
logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) failed: ${errorMessage}`);
482+
request.stream.hookProgress(hookType, formatHookErrorMessage(errorMessage));
483+
return;
484+
}
485+
486+
// Exit code 0 (or undefined with success outcome) — parse JSON from stdout
487+
if (!message.stdout) {
488+
return;
489+
}
490+
491+
const parsed = parseHookJsonOutput(message.stdout);
492+
if (!parsed) {
493+
logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" returned non-JSON output`);
494+
return;
495+
}
496+
497+
// Handle `decision: "block"` with `reason`
498+
if (parsed.decision === 'block') {
499+
request.stream.hookProgress(hookType, formatHookErrorMessage(parsed.reason ?? ''));
500+
return;
501+
}
502+
503+
// Handle `continue: false` with optional `stopReason`
504+
if (parsed.continue === false) {
505+
request.stream.hookProgress(hookType, formatHookErrorMessage(parsed.stopReason ?? ''));
506+
return;
507+
}
508+
509+
// Handle `systemMessage` — shown as a warning
510+
if (parsed.systemMessage) {
511+
request.stream.hookProgress(hookType, undefined, parsed.systemMessage);
346512
}
347513
}
348514

@@ -400,6 +566,10 @@ export function dispatchMessage(
400566
handleHookStarted(message, accessor, sessionId, state);
401567
return;
402568
}
569+
if (message.subtype === 'hook_progress') {
570+
handleHookProgress(message, accessor, request);
571+
return;
572+
}
403573
if (message.subtype === 'hook_response') {
404574
handleHookResponse(message, accessor, request, state);
405575
return;

0 commit comments

Comments
 (0)