diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 0143033255..dd7960dced 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -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 diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 2d59c47874..7f76f7a945 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -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 diff --git a/apps/backend/internal/task/service/service_turns_output_test.go b/apps/backend/internal/task/service/service_turns_output_test.go index 98dfdf683d..d511930786 100644 --- a/apps/backend/internal/task/service/service_turns_output_test.go +++ b/apps/backend/internal/task/service/service_turns_output_test.go @@ -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")}, diff --git a/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx b/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx index 93526696db..41778c76fa 100644 --- a/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx +++ b/apps/web/hooks/domains/integrations/use-remote-repositories.test.tsx @@ -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"])); }); }); diff --git a/apps/web/hooks/processed-message-filtering.test.ts b/apps/web/hooks/processed-message-filtering.test.ts index 266ab913d5..5797b63fa5 100644 --- a/apps/web/hooks/processed-message-filtering.test.ts +++ b/apps/web/hooks/processed-message-filtering.test.ts @@ -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 { + 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(), new Set()).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(["call-1"]), + new Set(), + ).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( diff --git a/apps/web/hooks/processed-message-filtering.ts b/apps/web/hooks/processed-message-filtering.ts index f80813ddaf..d502c56ad8 100644 --- a/apps/web/hooks/processed-message-filtering.ts +++ b/apps/web/hooks/processed-message-filtering.ts @@ -260,6 +260,58 @@ export function collapseTodoSnapshotsPerTurn(messages: Message[]): Message[] { }); } +const EMPTY_TURN_SUPERSEDING_TYPES: Set = 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(); + 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, + ), ); } diff --git a/docs/specs/ui/requirements/empty-turn-notice.md b/docs/specs/ui/requirements/empty-turn-notice.md index 69f5d62eb3..010714a0fb 100644 --- a/docs/specs/ui/requirements/empty-turn-notice.md +++ b/docs/specs/ui/requirements/empty-turn-notice.md @@ -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.