fix(web): retract empty-turn notice once real agent output lands (#3499)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-09 02:49:56 +08:00
committed by GitHub
parent 77e3cfc979
commit 7435bc2e8b
7 changed files with 190 additions and 5 deletions
@@ -1237,6 +1237,8 @@ const (
MessageTypeToolEdit MessageType = "tool_edit"
// MessageTypeToolRead is for file read operations
MessageTypeToolRead MessageType = "tool_read"
// MessageTypeToolSearch is for code and file search operations
MessageTypeToolSearch MessageType = "tool_search"
// MessageTypeToolExecute is for command execution operations
MessageTypeToolExecute MessageType = "tool_execute"
// MessageTypeProgress is for progress updates
@@ -682,6 +682,7 @@ func turnHadAgentOutput(msgs []*models.Message, turnID string) bool {
}
switch m.Type {
case models.MessageTypeToolCall, models.MessageTypeToolEdit, models.MessageTypeToolRead,
models.MessageTypeToolSearch,
models.MessageTypeToolExecute, models.MessageTypeAgentPlan, models.MessageTypeTodo,
models.MessageTypePermissionRequest, models.MessageTypeClarificationRequest:
return true
@@ -175,6 +175,11 @@ func TestTurnHadAgentOutput(t *testing.T) {
msgs: []*models.Message{agentMsg(models.MessageTypeToolCall, "")},
want: true,
},
{
name: "agent search tool",
msgs: []*models.Message{agentMsg(models.MessageTypeToolSearch, "")},
want: true,
},
{
name: "agent content chunk",
msgs: []*models.Message{agentMsg(models.MessageTypeContent, "partial")},
@@ -436,7 +436,7 @@ describe("useRemoteRepositories provider eligibility changes", () => {
await waitFor(() => expect(mocks.fetchAccessibleRepos).toHaveBeenCalledTimes(2));
expect(mocks.listUserProjects).toHaveBeenCalledTimes(1);
expect(mocks.listAzureDevOpsProjects).toHaveBeenCalledTimes(1);
expect(result.current.availableProviders).toEqual(["github"]);
await waitFor(() => expect(result.current.availableProviders).toEqual(["github"]));
});
});
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
import {
dropSupersededEmptyTurnNotices,
filterVisibleMessages,
hasFailedAgentBootAfter,
hasSessionRecoveryResolutionAfter,
hasSuccessfulAgentBootAfter,
@@ -120,6 +122,125 @@ describe("isSuccessfulScriptExecutionMetadata", () => {
});
});
function baseMessage(overrides: Partial<Message>): Message {
return {
id: "m",
session_id: toSessionId("s1"),
task_id: toTaskId("t1"),
author_type: "agent",
content: "",
type: "message",
created_at: "2026-05-30T00:00:00Z",
...overrides,
} as Message;
}
function emptyTurnNotice(turnId: string): Message {
return baseMessage({
id: `empty-turn-${turnId}`,
turn_id: turnId,
type: "status",
content: "The agent finished without producing any output.",
metadata: { variant: "warning", empty_turn: true },
});
}
describe("dropSupersededEmptyTurnNotices", () => {
it("drops the notice once real agent text arrives on the same turn", () => {
const messages = [
baseMessage({ id: "u1", turn_id: "turn-1", author_type: "user", content: "hi" }),
emptyTurnNotice("turn-1"),
baseMessage({ id: "a1", turn_id: "turn-1", content: "here you go" }),
];
const result = dropSupersededEmptyTurnNotices(messages);
expect(result.map((m) => m.id)).toEqual(["u1", "a1"]);
});
it("keeps the notice when the turn never received output", () => {
const messages = [
baseMessage({ id: "u1", turn_id: "turn-1", author_type: "user", content: "hi" }),
emptyTurnNotice("turn-1"),
];
expect(dropSupersededEmptyTurnNotices(messages)).toHaveLength(2);
});
it("keeps the notice when the output belongs to a different turn", () => {
const messages = [
emptyTurnNotice("turn-1"),
baseMessage({ id: "a1", turn_id: "turn-2", content: "unrelated" }),
];
expect(dropSupersededEmptyTurnNotices(messages)).toHaveLength(2);
});
it("keeps the notice when only status/thinking rows follow on the same turn", () => {
const messages = [
emptyTurnNotice("turn-1"),
baseMessage({ id: "s1", turn_id: "turn-1", type: "status", content: "New session started" }),
baseMessage({ id: "th1", turn_id: "turn-1", type: "thinking", content: "pondering" }),
];
expect(dropSupersededEmptyTurnNotices(messages)).toHaveLength(3);
});
it("drops the notice when a tool call lands on the same turn", () => {
const messages = [
emptyTurnNotice("turn-1"),
baseMessage({
id: "tc1",
turn_id: "turn-1",
type: "tool_call",
metadata: { tool_call_id: "call-1" },
}),
];
const result = dropSupersededEmptyTurnNotices(messages);
expect(result.map((m) => m.id)).toEqual(["tc1"]);
});
it("drops the notice when a search tool lands on the same turn", () => {
const messages = [
emptyTurnNotice("turn-1"),
baseMessage({ id: "search-1", turn_id: "turn-1", type: "tool_search" }),
];
const result = dropSupersededEmptyTurnNotices(messages);
expect(result.map((m) => m.id)).toEqual(["search-1"]);
});
});
describe("filterVisibleMessages empty-turn notice supersession", () => {
it("drops an empty-turn notice once an approved permission_request lands on its turn, even though approval hides that request from the visible list", () => {
const notice = emptyTurnNotice("turn-1");
const approvedPermission = baseMessage({
id: "perm-1",
turn_id: "turn-1",
type: "permission_request",
metadata: { status: "approved" },
});
expect(
filterVisibleMessages([notice, approvedPermission], new Set<string>(), new Set<string>()).map(
(message) => message.id,
),
).toEqual([]);
});
it("drops an empty-turn notice once a permission_request tied to a visible tool call lands on its turn", () => {
const notice = emptyTurnNotice("turn-2");
const linkedPermission = baseMessage({
id: "perm-2",
turn_id: "turn-2",
type: "permission_request",
metadata: { tool_call_id: "call-1" },
});
expect(
filterVisibleMessages(
[notice, linkedPermission],
new Set<string>(["call-1"]),
new Set<string>(),
).map((message) => message.id),
).toEqual([]);
});
});
describe("hasFailedAgentBootAfter", () => {
it("returns true when a boot after the failure reports status failed", () => {
expect(hasFailedAgentBootAfter([bootMessage(AFTER, { status: "failed" })], ERROR_AT)).toBe(
+56 -1
View File
@@ -260,6 +260,58 @@ export function collapseTodoSnapshotsPerTurn(messages: Message[]): Message[] {
});
}
const EMPTY_TURN_SUPERSEDING_TYPES: Set<MessageType> = new Set([
"tool_call",
"tool_edit",
"tool_read",
"tool_search",
"tool_execute",
"agent_plan",
"todo",
"permission_request",
"clarification_request",
]);
/** Mirrors the backend's turnHadAgentOutput allowlist (service_turns.go): agent-authored
* output that counts as real, user-visible turn content. Lifecycle "status" /
* "script_execution" / "thinking" rows never count. */
function isRealAgentOutput(message: Message): boolean {
if (message.author_type !== "agent") return false;
if (!message.type || message.type === "message" || message.type === "content") {
return message.content.trim() !== "";
}
return EMPTY_TURN_SUPERSEDING_TYPES.has(message.type);
}
function isEmptyTurnNotice(message: Message): boolean {
return (message.metadata as { empty_turn?: boolean } | undefined)?.empty_turn === true;
}
/** Drops an empty-turn notice once its turn has since received real agent output —
* the notice is emitted once at turn-completion time and nothing else retracts it,
* so a late-arriving reply (e.g. a subagent race) would otherwise render alongside
* its own "no output" contradiction. `outputSourceMessages` defaults to `messages`
* for standalone use, but the visibility pipeline passes the pre-filter array: a
* superseding permission_request or clarification_request can already be hidden by
* `filterVisibleMessages` (approved/denied/cancelled, or not the active one) by the
* time it would otherwise reach this step. */
export function dropSupersededEmptyTurnNotices(
messages: Message[],
outputSourceMessages: Message[] = messages,
): Message[] {
const turnsWithOutput = new Set<string>();
for (const message of outputSourceMessages) {
if (message.turn_id && isRealAgentOutput(message)) {
turnsWithOutput.add(message.turn_id);
}
}
if (turnsWithOutput.size === 0) return messages;
return messages.filter((message) => {
if (!isEmptyTurnNotice(message)) return true;
return !(message.turn_id && turnsWithOutput.has(message.turn_id));
});
}
function findActiveClarification(
messages: Message[],
scope?: PendingClarificationScope,
@@ -305,6 +357,9 @@ export function filterVisibleMessages(
return false;
});
return collapseTodoSnapshotsPerTurn(
deduplicateAgentBootResumes(deduplicateRecoveryMessages(filtered)),
dropSupersededEmptyTurnNotices(
deduplicateAgentBootResumes(deduplicateRecoveryMessages(filtered)),
messages,
),
);
}
@@ -24,8 +24,8 @@ app is broken.
#### Acceptance criteria
- **AC-UI-EMPTY-TURN-NOTICE-001.1:** When an agent turn completes having produced **no agent output**, the chat shows an unobtrusive inline status notice instead of nothing.
- **AC-UI-EMPTY-TURN-NOTICE-001.2:** "Output" means at least one agent message that is a tool call (`tool_call`/`tool_edit`/`tool_read`/`tool_execute`), a native plan/todo (`agent_plan`/`todo`), a permission or clarification prompt (`permission_request`/`clarification_request`), or a non-empty text response (`message`/`content`). Incidental per-turn messages — lifecycle `status`/`script_execution` notices, `log`, `progress`, and `thinking` — do **not** count as output.
- **AC-UI-EMPTY-TURN-NOTICE-001.1:** When an agent turn completes having produced **no agent output**, the chat shows an unobtrusive inline status notice instead of nothing. If later output arrives for the same turn, the rendered transcript hides that notice.
- **AC-UI-EMPTY-TURN-NOTICE-001.2:** "Output" means at least one agent message that is a tool call (`tool_call`/`tool_edit`/`tool_read`/`tool_search`/`tool_execute`), a native plan/todo (`agent_plan`/`todo`), a permission or clarification prompt (`permission_request`/`clarification_request`), or a non-empty text response (`message`/`content`). Incidental per-turn messages — lifecycle `status`/`script_execution` notices, `log`, `progress`, and `thinking` — do **not** count as output.
- **AC-UI-EMPTY-TURN-NOTICE-001.3:** The backend is authoritative: the `session.turn.completed` event carries a transient `had_output` boolean computed from the turn's persisted messages at completion time (no DB column; the notice is live-only).
- **AC-UI-EMPTY-TURN-NOTICE-001.4:** The notice text adapts to the triggering user message:
- **AC-UI-EMPTY-TURN-NOTICE-001.5:** **No leading `/`** → "The agent finished without producing any output."
@@ -60,6 +60,7 @@ When a user sends a message to the agent chat and the turn completes with **no c
- **GIVEN** a user sends `/commit` (an advertised command) and the turn produces no output, **WHEN** the turn completes, **THEN** the notice says the command ran but produced no output and to resend without the leading slash.
- **GIVEN** a user sends a normal prompt with no leading slash and the turn is empty, **WHEN** the turn completes, **THEN** the notice says the agent finished without producing any output.
- **GIVEN** a turn that produces a text response or a tool call, **WHEN** the turn completes, **THEN** no notice appears.
- **GIVEN** a turn first reports no output and later receives agent text or a tool message, **WHEN** the transcript updates, **THEN** the notice is hidden for that turn.
- **GIVEN** the same empty turn's `turn.completed` is processed more than once, **WHEN** the handler runs again, **THEN** only one notice exists for that turn.
- **GIVEN** an orphan turn swept by resume cleanup, **WHEN** its `turn.completed` is published, **THEN** no notice appears.
- **GIVEN** an empty turn on a quick-chat or config-chat surface, **WHEN** it completes, **THEN** no notice appears.
@@ -74,5 +75,5 @@ When a user sends a message to the agent chat and the turn completes with **no c
## Notes
- Backend: `had_output` is computed in `Service.CompleteTurn` via `turnHadAgentOutput` over the turn's persisted messages (`apps/backend/internal/task/service/service_turns.go`). Messages are fetched via `ListMessagesByTurnID` (indexed by `turn_id`), so the read is O(turn_messages) rather than O(session_messages). The result is added to the `turn.completed` payload in `publishTurnEvent`.
- Frontend: pure decision logic in `apps/web/lib/ws/handlers/empty-turn-notice.ts` (`computeEmptyTurnNotice`), wired from the `session.turn.completed` handler in `turns.ts`.
- Frontend: `computeEmptyTurnNotice` in `apps/web/lib/ws/handlers/empty-turn-notice.ts` creates the notice. `filterVisibleMessages` in `apps/web/hooks/processed-message-filtering.ts` hides it after later output on the same turn. Both functions are wired to the existing session event and message state flow.
- E2E: the mock agent's `empty-turn` scenario (`apps/backend/cmd/mock-agent/scenarios.go`) emits a multi-second empty `end_turn`; specs in `apps/web/e2e/tests/chat/empty-turn.spec.ts` (desktop) and `mobile-empty-turn.spec.ts` seed it as the auto-started turn so the live completion is observed.