diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index 0ac9a91a..176411f0 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -201,6 +201,27 @@ export function buildEpisode({ state = null, transcriptText = null, ctx = {} } = return base; } +/** + * Resolve the user prompt for downstream consumers (self-assessment API, + * embedding). Bug fix 2026-05-26: Claude Code's Stop-event stdin contract is + * { session_id, transcript_path, stop_hook_active, hook_event_name } — it + * never includes `prompt`. The real text lives in the transcript file. Prior + * code blindly read `ctx.prompt`, so self-assessment always received "(пусто)" + * and embedding was silently skipped. This helper prefers `ctx.prompt` (test + * convenience) and falls back to extracting the last user message from the + * transcript. Returns null when neither source has content. + */ +export function derivePrompt(ctx, transcriptText) { + if (ctx && typeof ctx.prompt === 'string' && ctx.prompt.length > 0) { + return ctx.prompt; + } + if (typeof transcriptText === 'string' && transcriptText.length > 0) { + const text = extractLastUserPromptText(transcriptText); + return text || null; + } + return null; +} + /** * Build a self_assessment block (spec §4.5, Phase 3 Task 17). Pure. * @@ -372,6 +393,11 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s try { const ep = buildEpisodeFromContext(ctx, transcriptText); + // Bug fix 2026-05-26: resolve the real user prompt before calling + // downstream consumers. ctx.prompt is never set by Stop-event stdin — + // the prompt lives in the transcript. derivePrompt unifies the fallback. + const userPrompt = derivePrompt(ctx, transcriptText); + // Step 3.5: self-assessment API call (fail-quiet). // Only runs when the runtime flag is 'on' and ROUTER_LLM_KEY is set. const saMode = readRuntimeFlag('self-assessment-mode'); @@ -379,7 +405,7 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s if (saMode === 'on' && saApiKey) { const rat = ep.primary_rationale ?? {}; const apiResult = await callSelfAssessmentApi({ - prompt: ctx.prompt || null, + prompt: userPrompt, recommendedNode: rat.recommended_node || null, actualNode: rat.node_chosen || null, chainExecuted: rat.chain_executed || [], @@ -391,7 +417,7 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s // Step 3.6: embedding async wiring (fail-quiet, 2s timeout). // Trivial task types skipped via shouldEmbed. Mirrors Step 3.5 pattern. const embMode = readRuntimeFlag('embedding-mode'); - await computeEmbeddingForEpisode(ep, ctx, { embedMode: embMode }); + await computeEmbeddingForEpisode(ep, { ...ctx, prompt: userPrompt }, { embedMode: embMode }); // Always write the episode first — exit-0-safe (spec §5.1 step 1). appendEpisode(ep); diff --git a/tools/observer-stop-hook.test.mjs b/tools/observer-stop-hook.test.mjs index bbd51772..9e81a966 100644 --- a/tools/observer-stop-hook.test.mjs +++ b/tools/observer-stop-hook.test.mjs @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync, readdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision, buildExecutionTrace, buildEpisode, buildSelfAssessment, computeEmbeddingForEpisode } from './observer-stop-hook.mjs'; +import { appendEpisode, buildEpisodeFromContext, buildObserverError, routingGateDecision, buildExecutionTrace, buildEpisode, buildSelfAssessment, computeEmbeddingForEpisode, derivePrompt } from './observer-stop-hook.mjs'; let workdir; @@ -366,3 +366,49 @@ describe('Step 3.6 embedding async wiring', () => { expect(ep.environment.embedding_unavailable).toBe(true); }); }); + +// ----------------------------------------------------------------------------- +// derivePrompt — Bug fix 2026-05-26: ctx.prompt is never set by Claude Code Stop +// stdin (only session_id / transcript_path / stop_hook_active are sent). The +// real user prompt lives in the transcript file. Self-assessment and embedding +// both consumed ctx.prompt blindly → empty string passed to Sonnet ("(пусто)") +// and embedding was silently skipped. derivePrompt unifies the fallback: prefer +// ctx.prompt when present (e.g. tests), otherwise extract last user message +// from transcriptText. +// ----------------------------------------------------------------------------- + +describe('derivePrompt — Stop-event prompt resolution', () => { + const minimalTranscript = (text) => + JSON.stringify({ + type: 'user', + sessionId: 's1', + timestamp: '2026-05-26T03:00:00Z', + message: { role: 'user', content: text }, + }) + '\n'; + + it('returns ctx.prompt when explicitly provided (test path)', () => { + expect(derivePrompt({ prompt: 'explicit' }, null)).toBe('explicit'); + }); + + it('extracts last user prompt from transcript when ctx.prompt missing (real Stop-event path)', () => { + const transcript = minimalTranscript('реальный длинный запрос от заказчика'); + expect(derivePrompt({}, transcript)).toBe('реальный длинный запрос от заказчика'); + }); + + it('returns null when both ctx.prompt and transcriptText absent', () => { + expect(derivePrompt({}, null)).toBeNull(); + expect(derivePrompt({}, '')).toBeNull(); + }); + + it('prefers ctx.prompt over transcript when both present', () => { + const transcript = minimalTranscript('from transcript'); + expect(derivePrompt({ prompt: 'from ctx' }, transcript)).toBe('from ctx'); + }); + + it('handles ctx=null/undefined gracefully', () => { + const transcript = minimalTranscript('из транскрипта'); + expect(derivePrompt(null, transcript)).toBe('из транскрипта'); + expect(derivePrompt(undefined, transcript)).toBe('из транскрипта'); + expect(derivePrompt(null, null)).toBeNull(); + }); +});