From 8550ba243d5838c2b5c0ebc6e4cc9358d97f41d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 19 May 2026 13:22:01 +0300 Subject: [PATCH] fix(observer): exclude synthetic user-role messages from turn detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (systematic-debugging): isRealUserPrompt treated skill-content ("Base directory for this skill:"), local-command output (), and interrupt markers as genuine prompts. findTurnStart then anchored a turn on the synthetic message — the turn slice missed the genuine prompt's UserPromptSubmit hook_additional_context attachment → economy_level: null, wrong prompt_signal/task_classification. Same cause made extractLastUserPromptText return skill content, so the Stop-hook routing-gate false-positive-blocked autonomous §12 skill invocations (detectMethodDirected saw the node name in skill text). Fix: SYNTHETIC_PROMPT_MARKERS + isSyntheticPrompt — isRealUserPrompt returns false for synthetic messages. One fix closes both the economy_level capture gap and the 2nd routing-gate FP class. 160/160 tools tests GREEN (+3 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/observer-transcript-parser.mjs | 30 +++++++- tools/observer-transcript-parser.test.mjs | 86 +++++++++++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 32a70651..7a2a9506 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -36,16 +36,40 @@ function parseLines(text) { return { entries, broken, total }; } -// A genuine user prompt (turn boundary) — not a tool_result carrier message. +// Synthetic user-role messages — NOT genuine prompts, must not be turn boundaries. +// Skill invocation content, local slash-command output/invocation, interrupt markers +// are recorded with role:'user' but carry no UserPromptSubmit hook context. +const SYNTHETIC_PROMPT_MARKERS = [ + 'Base directory for this skill:', + '', + '', + '', + '[Request interrupted by user]', +]; + +function isSyntheticPrompt(text) { + const t = String(text || '').trimStart(); + return SYNTHETIC_PROMPT_MARKERS.some((m) => t.startsWith(m)); +} + +// A genuine user prompt (turn boundary) — not a tool_result carrier nor a +// synthetic skill/command/interrupt message. function isRealUserPrompt(entry) { const msg = entry && entry.message; if (!msg || msg.role !== 'user') return false; const c = msg.content; - if (typeof c === 'string') return c.trim().length > 0; + if (typeof c === 'string') { + return c.trim().length > 0 && !isSyntheticPrompt(c); + } if (Array.isArray(c)) { const hasToolResult = c.some((b) => b && b.type === 'tool_result'); const hasText = c.some((b) => b && b.type === 'text'); - return hasText && !hasToolResult; + if (!hasText || hasToolResult) return false; + const text = c + .filter((b) => b && b.type === 'text') + .map((b) => b.text || '') + .join(' '); + return !isSyntheticPrompt(text); } return false; } diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index 9e51342a..e55d5d52 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -502,3 +502,89 @@ describe('parseTranscript — user_chose_from_options', () => { expect(ep.decision_provenance.kind).toBe('user_directed_method'); }); }); + +describe('parseTranscript — synthetic user messages skipped', () => { + it('captures economy_level from genuine prompt even when skill-content message follows', () => { + const lines = [ + JSON.stringify({ + type: 'user', + timestamp: '2026-05-19T10:00:00.000Z', + sessionId: 's1', + message: { role: 'user', content: 'почини баг в парсере' }, + }), + JSON.stringify({ + type: 'attachment', + timestamp: '2026-05-19T10:00:00.500Z', + sessionId: 's1', + attachment: { + type: 'hook_additional_context', + hookName: 'UserPromptSubmit', + content: ['=== ECONOMY MODE: 5% (тест) ===\nинструкции режима...'], + }, + }), + JSON.stringify({ + type: 'assistant', + timestamp: '2026-05-19T10:00:01.000Z', + sessionId: 's1', + message: { role: 'assistant', content: [{ type: 'text', text: 'смотрю' }] }, + }), + JSON.stringify({ + type: 'user', + timestamp: '2026-05-19T10:00:02.000Z', + sessionId: 's1', + message: { role: 'user', content: 'Base directory for this skill: C:\\path\\skill\n\n# Some Skill\nbody' }, + }), + JSON.stringify({ + type: 'assistant', + timestamp: '2026-05-19T10:00:03.000Z', + sessionId: 's1', + message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] }, + }), + ].join('\n'); + const ep = parseTranscript(lines, 'fallback'); + expect(ep.environment.economy_level).toBe(5); + expect(ep.primary_rationale.task_classification).toBe('bugfix'); + }); + + it('extractLastUserPromptText skips skill-content and returns genuine prompt', () => { + const lines = [ + JSON.stringify({ + type: 'user', + timestamp: '2026-05-19T10:00:00.000Z', + sessionId: 's1', + message: { role: 'user', content: 'добавь колонку Город' }, + }), + JSON.stringify({ + type: 'user', + timestamp: '2026-05-19T10:00:02.000Z', + sessionId: 's1', + message: { role: 'user', content: 'Base directory for this skill: C:\\x\n# Skill body' }, + }), + ].join('\n'); + expect(extractLastUserPromptText(lines)).toBe('добавь колонку Город'); + }); + + it('extractLastUserPromptText skips local-command output and interrupt markers', () => { + const lines = [ + JSON.stringify({ + type: 'user', + timestamp: '2026-05-19T10:00:00.000Z', + sessionId: 's1', + message: { role: 'user', content: 'сделай X' }, + }), + JSON.stringify({ + type: 'user', + timestamp: '2026-05-19T10:00:01.000Z', + sessionId: 's1', + message: { role: 'user', content: '[Request interrupted by user]' }, + }), + JSON.stringify({ + type: 'user', + timestamp: '2026-05-19T10:00:02.000Z', + sessionId: 's1', + message: { role: 'user', content: 'some output' }, + }), + ].join('\n'); + expect(extractLastUserPromptText(lines)).toBe('сделай X'); + }); +});