mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* fix(web): drop "No response requested." assistant messages When Claude Code injects system messages (task notifications, system reminders) as user turns, Claude responds with "No response requested." In the HAPI web UI this appears as a reply to the user's message, making it look like Claude is ignoring their input. Filter these out in isSkippableAgentContent() (catches the fallback path in normalize.ts) and in normalizeAssistantOutput() (catches the primary path). Both checks verify the assistant message contains only the text "No response requested." with no tool calls. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): filter text block instead of dropping entire message Address bot review: dropping the whole normalized record breaks sidechain UUID threading (parentUUID chain orphans). Instead of returning null, suppress only the "No response requested." text block during content extraction. The message record (uuid, parentUUID, usage) is preserved so the tracer's sidechain grouping continues to work. Also remove the isSkippableAgentContent check since we no longer need to drop the message at that layer. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): move "No response requested." filter to reducer layer Address bot review: filtering in normalizeAssistantOutput() produced empty content arrays, breaking traceMessages() which reads uuid and parentUUID from content[0]. Sidechain child messages whose parentUUID pointed to the filtered message became orphaned. Fix: revert the normalizer to always emit the text block (preserving the UUID chain for the tracer), and filter the sentinel text in reducerTimeline.ts where text blocks become visible AgentTextBlocks. At this point tracing is already complete. Also adds reducer-level tests for the filter and updates the normalize test to verify the text block is preserved. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): scope sentinel filter to single-block assistant messages only Address bot review: the previous filter suppressed any text block matching "No response requested.", which could hide legitimate replies. Now the filter only triggers when the message has exactly one content block (msg.content.length === 1) — i.e., the assistant response is purely the sentinel text with no tool calls or reasoning blocks. This prevents false positives while still catching the system-injection auto-reply case. Add test for the multi-block case (text + tool call) to verify the sentinel text is preserved when other content exists. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): add parentUUID structural check to sentinel filter Address bot review: raw text match alone could theoretically suppress a legitimate reply. Add c.parentUUID !== null as a structural guard: - Sentinel auto-replies always follow a prior assistant turn, so their parentUUID is set (pointing to the previous message in the chain). - A first message in a conversation has parentUUID: null and will never be filtered. Combined conditions: msg.content.length === 1 (sole block, no tool calls) AND c.parentUUID !== null (not the first reply) AND exact text match. Add tests for the parentUUID=null escape hatch. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): use injected-turn UUID tracking for sentinel filter Address review: use structural markers instead of broad text matching. 1. Pre-scan collects UUIDs from sidechain content blocks (system- injected user turns). The sentinel filter now only triggers when parentUUID points to one of these known injected turns. 2. Move task-notification event extraction from normalizer to reducer. Previously, task-notifications with summary were normalized as role:'event', losing their uuid. Now they stay as sidechain (uuid preserved for pre-scan), and the reducer extracts the summary as an agent-event block. 3. Remove redundant 'uuid' in c guard (always present on sidechain type). False positive analysis: a legitimate reply is only suppressed when ALL of: (a) sole content block, (b) parentUUID matches a sidechain-injected turn, (c) exact sentinel text. This combination cannot occur for real user-facing content. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): add parentUUID to sidechain content type for tracer linkage The sidechain content block was missing parentUUID, so traceMessages() could not chain system-injected user turns (task notifications, system reminders) inside a Task sidechain back to their parent. This caused later sidechain messages pointing to the injected turn's UUID to become orphaned and disappear from the Task card. Add parentUUID to the sidechain type definition and propagate it from normalizeUserOutput() in both the isSidechain and non-sidechain paths. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): handle array-content sidechain user messages to prevent prompt leak Sidechain user messages can arrive with either string content or array content ([{type:'text', text:'...'}]) depending on how Claude Code serialises them. The previous fix only handled the string case, causing intermittent prompt leaks when array format was used. Now normalizeUserOutput extracts text from array-content sidechain messages and emits them as sidechain blocks, so the tracer can match them to their parent Task tool call. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * test(web): verify parentUUID propagation from assistant output data Add integration tests confirming normalizeAssistantOutput correctly maps data.parentUuid to text block parentUUID (used by the reducer's sentinel detection). Tests cover both present and absent parentUuid. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run>
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { normalizeDecryptedMessage } from './normalize'
|
|
import type { DecryptedMessage } from '@/types/api'
|
|
|
|
function makeMessage(content: unknown): DecryptedMessage {
|
|
return {
|
|
id: 'msg-1',
|
|
seq: 1,
|
|
localId: null,
|
|
content,
|
|
createdAt: 1_742_372_800_000
|
|
}
|
|
}
|
|
|
|
describe('normalizeDecryptedMessage', () => {
|
|
it('drops unsupported Claude system output records', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'system',
|
|
subtype: 'stop_hook_summary',
|
|
uuid: 'sys-1'
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(normalizeDecryptedMessage(message)).toBeNull()
|
|
})
|
|
|
|
it('drops Claude init system output records', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'system',
|
|
subtype: 'init',
|
|
uuid: 'sys-init',
|
|
session_id: 'session-1'
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(normalizeDecryptedMessage(message)).toBeNull()
|
|
})
|
|
|
|
it('keeps known Claude system subtypes as normalized events', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'system',
|
|
subtype: 'turn_duration',
|
|
uuid: 'sys-2',
|
|
durationMs: 1200
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(normalizeDecryptedMessage(message)).toMatchObject({
|
|
id: 'msg-1',
|
|
role: 'event',
|
|
isSidechain: false,
|
|
content: {
|
|
type: 'turn-duration',
|
|
durationMs: 1200
|
|
}
|
|
})
|
|
})
|
|
|
|
it('keeps the stringify fallback for unknown non-system agent payloads', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'assistant',
|
|
foo: 'bar'
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
|
|
expect(normalized).toMatchObject({
|
|
id: 'msg-1',
|
|
role: 'agent',
|
|
isSidechain: false
|
|
})
|
|
|
|
expect(normalized?.role).toBe('agent')
|
|
if (!normalized || normalized.role !== 'agent') {
|
|
throw new Error('Expected agent message')
|
|
}
|
|
const firstBlock = normalized.content[0]
|
|
expect(firstBlock).toMatchObject({
|
|
type: 'text',
|
|
})
|
|
if (firstBlock.type !== 'text') {
|
|
throw new Error('Expected fallback text block')
|
|
}
|
|
expect(firstBlock.text).toContain('"foo": "bar"')
|
|
})
|
|
|
|
it('normalizes <task-notification> user output as sidechain (event extracted by reducer)', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'user',
|
|
uuid: 'u-notif',
|
|
message: { content: '<task-notification> <summary>Background command stopped</summary> </task-notification>' }
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
|
|
// Normalizer emits as sidechain (preserving uuid for sentinel detection);
|
|
// the reducer extracts the summary as an event.
|
|
expect(normalized).toMatchObject({
|
|
role: 'agent',
|
|
isSidechain: true,
|
|
})
|
|
if (normalized?.role === 'agent') {
|
|
expect(normalized.content[0]).toMatchObject({
|
|
type: 'sidechain',
|
|
prompt: expect.stringContaining('<task-notification>')
|
|
})
|
|
}
|
|
})
|
|
|
|
it('treats <task-notification> without summary as sidechain (dropped by reducer)', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'user',
|
|
uuid: 'u3',
|
|
message: { content: '<task-notification> <status>killed</status> </task-notification>' }
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
|
|
expect(normalized).toMatchObject({
|
|
role: 'agent',
|
|
isSidechain: true,
|
|
})
|
|
})
|
|
|
|
it('treats non-sidechain string user output as sidechain', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'user',
|
|
isSidechain: false,
|
|
uuid: 'u1',
|
|
message: { content: 'This is a subagent prompt' }
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
|
|
expect(normalized).toMatchObject({
|
|
role: 'agent',
|
|
isSidechain: true,
|
|
})
|
|
if (normalized?.role !== 'agent') throw new Error('Expected agent')
|
|
expect(normalized.content[0]).toMatchObject({
|
|
type: 'sidechain',
|
|
prompt: 'This is a subagent prompt'
|
|
})
|
|
})
|
|
|
|
it('treats <system-reminder> user output as sidechain (dropped by reducer)', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'user',
|
|
uuid: 'u2',
|
|
message: { content: '<system-reminder>Some internal reminder</system-reminder>' }
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
|
|
expect(normalized).toMatchObject({
|
|
role: 'agent',
|
|
isSidechain: true,
|
|
})
|
|
})
|
|
|
|
it('treats sidechain user output with array content as sidechain', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'user',
|
|
uuid: 'u3',
|
|
isSidechain: true,
|
|
message: { content: [{ type: 'text', text: 'This is an agent prompt in array form' }] }
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
|
|
expect(normalized).toMatchObject({
|
|
role: 'agent',
|
|
isSidechain: true,
|
|
})
|
|
if (normalized?.role !== 'agent') throw new Error('Expected agent')
|
|
expect(normalized.content[0]).toMatchObject({
|
|
type: 'sidechain',
|
|
prompt: 'This is an agent prompt in array form'
|
|
})
|
|
})
|
|
|
|
it('keeps "No response requested." text in normalized output (filtered later by reducer)', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'assistant',
|
|
uuid: 'a-1',
|
|
message: { role: 'assistant', content: 'No response requested.' }
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
// Normalizer preserves the text (uuid/parentUUID needed by tracer);
|
|
// the reducer is responsible for suppressing it during rendering.
|
|
expect(normalized).not.toBeNull()
|
|
expect(normalized?.role).toBe('agent')
|
|
if (normalized?.role === 'agent') {
|
|
expect(normalized.content).toHaveLength(1)
|
|
expect(normalized.content[0]).toMatchObject({ type: 'text', text: 'No response requested.' })
|
|
}
|
|
})
|
|
|
|
it('keeps assistant messages with real content', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'assistant',
|
|
uuid: 'a-2',
|
|
message: { role: 'assistant', content: 'Here is the answer.' }
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
expect(normalized).not.toBeNull()
|
|
expect(normalized?.role).toBe('agent')
|
|
})
|
|
|
|
it('propagates parentUuid from assistant output data to text block parentUUID', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'assistant',
|
|
uuid: 'a-3',
|
|
parentUuid: 'parent-injected-uuid',
|
|
message: { role: 'assistant', content: 'No response requested.' }
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
expect(normalized).not.toBeNull()
|
|
if (normalized?.role !== 'agent') throw new Error('Expected agent')
|
|
expect(normalized.content).toHaveLength(1)
|
|
expect(normalized.content[0]).toMatchObject({
|
|
type: 'text',
|
|
text: 'No response requested.',
|
|
parentUUID: 'parent-injected-uuid'
|
|
})
|
|
})
|
|
|
|
it('sets parentUUID to null when parentUuid is absent in assistant output', () => {
|
|
const message = makeMessage({
|
|
role: 'agent',
|
|
content: {
|
|
type: 'output',
|
|
data: {
|
|
type: 'assistant',
|
|
uuid: 'a-4',
|
|
// No parentUuid field
|
|
message: { role: 'assistant', content: 'Hello.' }
|
|
}
|
|
}
|
|
})
|
|
|
|
const normalized = normalizeDecryptedMessage(message)
|
|
expect(normalized).not.toBeNull()
|
|
if (normalized?.role !== 'agent') throw new Error('Expected agent')
|
|
expect(normalized.content[0]).toMatchObject({
|
|
type: 'text',
|
|
parentUUID: null
|
|
})
|
|
})
|
|
})
|