fix(web): drop "No response requested." assistant messages (#402)

* 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>
This commit is contained in:
Haoqing Wang
2026-04-06 20:42:40 +08:00
committed by GitHub
co-authored by HAPI
parent 9eb0dacf75
commit 73fa846df3
5 changed files with 352 additions and 30 deletions
+129 -5
View File
@@ -105,13 +105,14 @@ describe('normalizeDecryptedMessage', () => {
expect(firstBlock.text).toContain('"foo": "bar"')
})
it('converts <task-notification> user output to event', () => {
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>' }
}
}
@@ -119,12 +120,18 @@ describe('normalizeDecryptedMessage', () => {
const normalized = normalizeDecryptedMessage(message)
// Normalizer emits as sidechain (preserving uuid for sentinel detection);
// the reducer extracts the summary as an event.
expect(normalized).toMatchObject({
id: 'msg-1',
role: 'event',
isSidechain: false,
content: { type: 'message', message: 'Background command stopped' }
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)', () => {
@@ -195,4 +202,121 @@ describe('normalizeDecryptedMessage', () => {
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
})
})
})
+28 -23
View File
@@ -116,41 +116,46 @@ function normalizeUserOutput(
createdAt,
role: 'agent',
isSidechain: true,
content: [{ type: 'sidechain', uuid, prompt: messageContent }]
content: [{ type: 'sidechain', uuid, parentUUID, prompt: messageContent }]
}
}
// Handle system-injected messages that arrive as type:'user' through
// the agent output path. Real user text goes through normalizeUserRecord.
//
// All string-content user messages here are system-injected (subagent
// prompts, task notifications, system reminders, etc.). Always emit as
// sidechain so the uuid/parentUUID chain is preserved — the reducer uses
// sidechain UUIDs to identify sentinel auto-replies. Task-notification
// summaries are extracted as events by the reducer, not here.
if (typeof messageContent === 'string') {
// Convert <task-notification> to a visible event
const trimmed = messageContent.trimStart()
if (trimmed.startsWith('<task-notification>')) {
const summary = trimmed.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
if (summary) {
return {
id: messageId,
localId,
createdAt,
role: 'event',
content: { type: 'message', message: summary },
isSidechain: false,
meta
}
}
}
// All other string-content user messages in this path are
// system-injected (subagent prompts, system reminders, etc.).
// Treat as sidechain so the tracer can match it to a parent Task
// tool call; unmatched ones are harmlessly skipped by the reducer.
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain: true,
content: [{ type: 'sidechain', uuid, prompt: messageContent }]
content: [{ type: 'sidechain', uuid, parentUUID, prompt: messageContent }]
}
}
// Sidechain user messages with array content (e.g. subagent prompts
// that Claude Code serialised as [{type:'text', text:'...'}] instead
// of a plain string). Extract the text and treat as sidechain so the
// tracer can match it to the parent Task tool call.
if (isSidechain && Array.isArray(messageContent)) {
const textParts = messageContent
.filter((b: unknown) => isObject(b) && b.type === 'text' && typeof b.text === 'string')
.map((b: Record<string, unknown>) => b.text as string)
if (textParts.length > 0) {
return {
id: messageId,
localId,
createdAt,
role: 'agent',
isSidechain: true,
content: [{ type: 'sidechain', uuid, parentUUID, prompt: textParts.join('\n\n') }]
}
}
}
+146
View File
@@ -24,6 +24,18 @@ function makeUserMessage(text: string, overrides?: Partial<TracedMessage>): Trac
} as TracedMessage
}
function makeAgentMessage(text: string, overrides?: Partial<TracedMessage>): TracedMessage {
return {
id: 'msg-agent-1',
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
content: [{ type: 'text', text, uuid: 'u-1', parentUUID: null }],
isSidechain: false,
...overrides
} as TracedMessage
}
describe('reduceTimeline', () => {
it('renders user text as user-text block', () => {
const text = 'Hello, this is a normal message'
@@ -40,4 +52,138 @@ describe('reduceTimeline', () => {
expect(blocks).toHaveLength(1)
expect(blocks[0].kind).toBe('user-text')
})
it('suppresses "No response requested." when parentUUID points to an injected turn', () => {
// Simulate: sidechain message with uuid 'injected-uuid', then sentinel reply pointing to it
const injectedMsg: TracedMessage = {
id: 'msg-injected',
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
content: [{ type: 'sidechain', uuid: 'injected-uuid', prompt: '<task-notification>...</task-notification>' }],
isSidechain: true
} as TracedMessage
const sentinelMsg: TracedMessage = {
id: 'msg-sentinel',
localId: null,
createdAt: 1_700_000_001_000,
role: 'agent',
content: [{ type: 'text', text: 'No response requested.', uuid: 'u-1', parentUUID: 'injected-uuid' }],
isSidechain: false
} as TracedMessage
const { blocks } = reduceTimeline([injectedMsg, sentinelMsg], makeContext())
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
expect(textBlocks).toHaveLength(0)
})
it('keeps "No response requested." when parentUUID points to a normal turn (not injected)', () => {
// parentUUID points to a normal assistant message, not an injected turn
const normalMsg: TracedMessage = {
id: 'msg-normal',
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
content: [{ type: 'text', text: 'Hello!', uuid: 'normal-uuid', parentUUID: null }],
isSidechain: false
} as TracedMessage
const replyMsg: TracedMessage = {
id: 'msg-reply',
localId: null,
createdAt: 1_700_000_001_000,
role: 'agent',
content: [{ type: 'text', text: 'No response requested.', uuid: 'u-2', parentUUID: 'normal-uuid' }],
isSidechain: false
} as TracedMessage
const { blocks } = reduceTimeline([normalMsg, replyMsg], makeContext())
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
// Should be 2: "Hello!" + "No response requested." (not filtered because parent is normal)
expect(textBlocks).toHaveLength(2)
})
it('keeps "No response requested." when parentUUID is null (first message)', () => {
const { blocks } = reduceTimeline([makeAgentMessage('No response requested.')], makeContext())
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
expect(textBlocks).toHaveLength(1)
})
it('keeps "No response requested." when message also has other blocks (e.g. tool calls)', () => {
const injectedMsg: TracedMessage = {
id: 'msg-injected',
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
content: [{ type: 'sidechain', uuid: 'injected-uuid', prompt: 'system content' }],
isSidechain: true
} as TracedMessage
const multiMsg: TracedMessage = {
id: 'msg-multi',
localId: null,
createdAt: 1_700_000_001_000,
role: 'agent',
content: [
{ type: 'text', text: 'No response requested.', uuid: 'u-1', parentUUID: 'injected-uuid' },
{ type: 'tool-call', id: 'tc-1', name: 'Bash', input: { command: 'ls' }, description: null, uuid: 'u-1', parentUUID: 'injected-uuid' }
],
isSidechain: false
} as TracedMessage
const { blocks } = reduceTimeline([injectedMsg, multiMsg], makeContext())
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
expect(textBlocks).toHaveLength(1)
})
it('keeps normal assistant text blocks', () => {
const { blocks } = reduceTimeline([makeAgentMessage('Here is the answer.')], makeContext())
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
expect(textBlocks).toHaveLength(1)
})
it('extracts task-notification summary as event from sidechain block', () => {
const msg: TracedMessage = {
id: 'msg-notif',
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
content: [{ type: 'sidechain', uuid: 'n-1', prompt: '<task-notification> <summary>Background command stopped</summary> </task-notification>' }],
isSidechain: true
} as TracedMessage
const { blocks } = reduceTimeline([msg], makeContext())
const events = blocks.filter(b => b.kind === 'agent-event')
expect(events).toHaveLength(1)
expect((events[0] as any).event.message).toBe('Background command stopped')
})
it('suppresses sentinel reply to task-notification (summary path)', () => {
const notifMsg: TracedMessage = {
id: 'msg-notif',
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
content: [{ type: 'sidechain', uuid: 'notif-uuid', prompt: '<task-notification> <summary>Done</summary> </task-notification>' }],
isSidechain: true
} as TracedMessage
const sentinelMsg: TracedMessage = {
id: 'msg-sentinel',
localId: null,
createdAt: 1_700_000_001_000,
role: 'agent',
content: [{ type: 'text', text: 'No response requested.', uuid: 'u-1', parentUUID: 'notif-uuid' }],
isSidechain: false
} as TracedMessage
const { blocks } = reduceTimeline([notifMsg, sentinelMsg], makeContext())
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
expect(textBlocks).toHaveLength(0)
// But the event should still be present
const events = blocks.filter(b => b.kind === 'agent-event')
expect(events).toHaveLength(1)
})
})
+48 -1
View File
@@ -18,6 +18,20 @@ export function reduceTimeline(
const toolBlocksById = new Map<string, ToolCallBlock>()
let hasReadyEvent = false
// Pre-scan: collect UUIDs of system-injected user turns (sidechain
// prompts, task notifications, system reminders). These are used below
// to identify sentinel auto-replies ("No response requested.") whose
// parentUUID points to one of these injected messages.
const injectedTurnUuids = new Set<string>()
for (const msg of messages) {
if (msg.role !== 'agent' || !msg.isSidechain) continue
for (const c of msg.content) {
if (c.type === 'sidechain') {
injectedTurnUuids.add(c.uuid)
}
}
}
for (const msg of messages) {
if (msg.role === 'event') {
if (msg.content.type === 'ready') {
@@ -92,6 +106,25 @@ export function reduceTimeline(
for (let idx = 0; idx < msg.content.length; idx += 1) {
const c = msg.content[idx]
if (c.type === 'text') {
// Skip "No response requested." — Claude's sentinel auto-response
// to system-injected messages (task notifications, system reminders).
//
// Structural checks to avoid false positives:
// 1. msg.content.length === 1 — no tool calls or reasoning alongside
// 2. c.parentUUID points to a known injected turn UUID (collected
// in pre-scan from sidechain content blocks)
// 3. Exact text match on the known sentinel phrase
if (
msg.content.length === 1 &&
c.parentUUID !== null &&
injectedTurnUuids.has(c.parentUUID)
) {
const trimmedText = c.text.trim()
if (trimmedText === 'No response requested.' || trimmedText === 'No response requested') {
continue
}
}
// Skip text blocks that are just the Task tool prompt (already shown in tool card)
if (taskPromptText && c.text.trim() === taskPromptText.trim()) continue
@@ -240,7 +273,21 @@ export function reduceTimeline(
}
if (c.type === 'sidechain') {
// Skip - the prompt is already visible in the parent Task tool call's input
// Extract task-notification summaries as visible events
const trimmedPrompt = c.prompt.trimStart()
if (trimmedPrompt.startsWith('<task-notification>')) {
const summary = trimmedPrompt.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
if (summary) {
blocks.push({
kind: 'agent-event',
id: `${msg.id}:${idx}`,
createdAt: msg.createdAt,
event: { type: 'message', message: summary },
meta: msg.meta
})
}
}
// Skip rendering prompt text (already in parent Task tool card or not user-visible)
continue
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ export type NormalizedAgentContent =
| ToolUse
| ToolResult
| { type: 'summary'; summary: string }
| { type: 'sidechain'; uuid: string; prompt: string }
| { type: 'sidechain'; uuid: string; parentUUID: string | null; prompt: string }
export type NormalizedMessage = ({
role: 'user'