fix(observer): exclude synthetic user-role messages from turn detection

Root cause (systematic-debugging): isRealUserPrompt treated skill-content
("Base directory for this skill:"), local-command output
(<local-command-stdout>), 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-19 13:22:01 +03:00
parent ad09db606a
commit 8550ba243d
2 changed files with 113 additions and 3 deletions
+86
View File
@@ -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: '<local-command-stdout>some output</local-command-stdout>' },
}),
].join('\n');
expect(extractLastUserPromptText(lines)).toBe('сделай X');
});
});