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:
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user