Skip to content

[Bug]: Thread find counts hidden empty replies as matches #34983

Closed

Description

@coygeek

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/web

Steps to reproduce

Use the commit under Version or commit, with lockfile dependencies and the repo's Chromium browser-test binary installed. Save the script below outside the checkout as repro.sh and run sh /path/to/repro.sh from the checkout root. The script sets a private HOME, so set PLAYWRIGHT_BROWSERS_PATH to the existing browser installation if it lives outside that default cache. VERIFY_SCRATCH optionally chooses where its temporary directory goes.

  1. A server test reuses the fixture builders from ThreadFind.test.ts, persists one completed html_render tool result and one completed empty assistant reply (suppressed) in the same run, and one completed empty assistant reply (control) in a different run, to in-memory SQLite. It calls production ProjectionStore.searchThread with query empty (first, Next, and Next again), and captures the projected rows and results.
  2. A Chromium browser test mounts the real MessagesTimeline, useThreadFind, ThreadFindBar and find navigation over those rows. Its only search substitution is the network result atom, which returns the captured server result; the HTML render uses the timeline's existing renderHtmlRender callback. It opens Find, types empty, clicks Next twice, and checks the counter, the selected row, each body's text, the matching DOM ranges and the list's scrollToIndex call.
  3. Both tests assert the current behavior, so a pass means the defect and the correct control were both observed. The script removes its temporary tests and fixture on exit. No live WebSocket server, dev server or provider is involved.
Reproduction script: repro.sh
#!/bin/sh
set -eu
# Run in an installed checkout at the documented commit. Browser binaries must already exist.
checkout=$(pwd)
tmp=$(mktemp -d "${VERIFY_SCRATCH:-${TMPDIR:-/tmp}}/thread-find-empty.XXXXXX")
export HOME="$tmp/fake-home" TMPDIR="$tmp/tmp" T3CODE_HOME="$tmp/t3-home"
mkdir -p "$HOME" "$TMPDIR" "$T3CODE_HOME"
export PATH="$checkout/node_modules/.bin:$PATH"
export VERIFY_FIXTURE="$checkout/apps/web/src/components/chat/zz-verify-empty.fixture.json"
server="$checkout/apps/server/src/orchestration-v2/zz-verify-empty.test.ts"
web="$checkout/apps/web/src/components/chat/zz-verify-empty.browser.test.tsx"
cleanup() { rm -f "$server" "$web" "$VERIFY_FIXTURE"; rm -rf "$tmp"; }
trap cleanup EXIT INT TERM
# Reuse only the baseline test's valid event/SQLite fixture builders.
sed '/^describe("V2 thread find"/,$d' "$checkout/apps/server/src/orchestration-v2/ThreadFind.test.ts" > "$server"
cat >> "$server" <<'SERVER'

import * as FS from "node:fs";
import * as Schema from "effect/Schema";
import { OrchestrationV2ProjectedTurnItemJson } from "@t3tools/contracts";
it.effect("verifies suppressed empty reply through production ThreadFind", () =>
  Effect.gen(function* () {
    const projection = yield* setup;
    const tool: OrchestrationV2TurnItem = {
      ...item("render", 0, ""), type: "dynamic_tool", toolName: "html_render", input: {},
      output: { htmlRender: { attachmentId: "visual", title: "Result", height: 120 } },
    };
    const controlRunId = RunId.make("run:control");
    yield* commit([run(threadId, controlRunId, 2)]);
    yield* putItems([tool, item("suppressed", 1, ""), { ...item("control", 2, ""), runId: controlRunId }]);
    const first = yield* projection.searchThread({ threadId, query: "empty" });
    const next = yield* projection.searchThread({ threadId, query: "empty", start: { entryId: "suppressed", occurrence: 0 }, offset: 1 });
    const wrapped = yield* projection.searchThread({ threadId, query: "empty", start: { entryId: "control", occurrence: 0 }, offset: 1 });
    assert.equal(first.totalMatches, 2);
    assert.equal(first.match?.entryId, "suppressed");
    assert.equal(next.totalMatches, 2);
    assert.equal(next.match?.entryId, "control");
    assert.equal(wrapped.match?.entryId, "suppressed");
    const snapshot = yield* projection.getThreadSnapshot(threadId);
    const rows = snapshot.projection.visibleTurnItems.map(row => ({ ...row, item: projectTurnItemForWire(row.item) }));
    const fixture = { first, next, wrapped, rows: Schema.encodeSync(Schema.Array(OrchestrationV2ProjectedTurnItemJson))(rows) };
    FS.writeFileSync(process.env.VERIFY_FIXTURE!, JSON.stringify(fixture, null, 2));
    console.log("SERVER " + JSON.stringify({ first, next, wrapped }));
  }).pipe(Effect.provide(layerTest)),
);

SERVER
cat > "$web" <<'WEB'

import { createRef, type ComponentProps } from "react";
import { EnvironmentId, ThreadId, OrchestrationV2ProjectedTurnItemJson } from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { Atom, AsyncResult } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";
import { render, act } from "../../../test/render";
import { MessagesTimeline } from "./MessagesTimeline";
import { ThreadFindBar } from "./ThreadFindBar";
import { useThreadFind } from "./useThreadFind";
import { openThreadFind, closeThreadFind } from "./threadFindStore";
import { collectThreadFindRanges } from "./threadFindHighlights";
import { deriveMessagesTimelineRows } from "./MessagesTimeline.logic";
import { deriveTimelineEntriesFromVisibleTurnItems } from "../../session-logic";
import fixture from "./zz-verify-empty.fixture.json";
import "~/index.css";

// Replace only the network result atom with the actual result captured from SQLite/ThreadFind.
vi.mock(import("~/state/orchestration"), async (original) => {
  const actual = await original();
  const { Atom, AsyncResult } = await import("effect/reactivity");
  const { default: captured } = await import("./zz-verify-empty.fixture.json");
  const data = Atom.make(AsyncResult.success(captured.first));
  return { ...actual, orchestrationEnvironment: { ...actual.orchestrationEnvironment, threadFind: () => data } };
});

const environmentId = EnvironmentId.make("verify-environment");
const thread = { environmentId, threadId: ThreadId.make("thread:find") };
const key = "verify-environment:thread:find";
const rows = Schema.decodeUnknownSync(Schema.Array(OrchestrationV2ProjectedTurnItemJson))(fixture.rows);
const entries = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: rows, optimisticMessages: [] });
const listRef = createRef<any>();
function Harness() {
  const find = useThreadFind({ thread, enabled: true });
  const props: Omit<ComponentProps<typeof MessagesTimeline>, "timelineEntries"> = {
    isWorking: false, listRef, latestRun: null, turnDiffSummaries: [], runs: [], routeThreadKey: key,
    onOpenThread: () => {}, supportsConversationRollback: false, onRevertToTurnCount: () => {},
    isRevertingCheckpoint: false, onImageExpand: () => {}, activeThreadEnvironmentId: environmentId,
    markdownCwd: undefined, resolvedTheme: "light", timestampFormat: "locale", workspaceRoot: undefined,
    anchorMessageId: null, onAnchorReady: () => {}, onAnchorSizeChanged: () => {},
    contentInsetEndAdjustment: 0, emptyPlaceholderInset: 0, liveFollowEnabled: true, onIsAtEndChange: () => {},
    renderHtmlRender: () => <div style={{height: 120}}>Rendered result</div>,
  };
  return <>
    <ThreadFindBar {...find.barProps} />
    <output data-selection>{find.timelineProps.activeFindMatch?.entryId ?? "none"}</output>
    <MessagesTimeline {...props} {...find.timelineProps} timelineEntries={entries} />
  </>;
}

it("real web find UI counts a suppressed empty reply and navigates to its blank body", async () => {
  closeThreadFind();
  const derived = deriveMessagesTimelineRows({ timelineEntries: entries, latestRun: null, isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false });
  expect(derived.find(row => row.id === "suppressed")).toMatchObject({ suppressEmptyResponse: true });
  expect(derived.find(row => row.id === "control")).toMatchObject({ suppressEmptyResponse: false });
  const container = document.createElement("div");
  container.style.cssText = "height: 700px; width: 900px; display: flex; flex-direction: column; position: relative";
  await using screen = await render(<Harness />, { container });
  await act(() => openThreadFind(key));
  await screen.getByRole("searchbox", { name: "Find in thread" }).fill("empty");
  await expect.element(screen.getByText("1/2", { exact: true })).toBeVisible();
  await vi.waitFor(() => expect(screen.container.querySelector("[data-selection]")?.textContent).toBe("suppressed"));
  const suppressedBody = () => screen.container.querySelector('[data-timeline-row-id="suppressed"] [data-thread-find-text]');
  const controlBody = () => screen.container.querySelector('[data-timeline-row-id="control"] [data-thread-find-text]');
  await vi.waitFor(() => expect(suppressedBody()).not.toBeNull());
  expect(suppressedBody()!.textContent).toBe("");
  await vi.waitFor(() => expect(controlBody()?.textContent).toBe("(empty response)"));
  expect(collectThreadFindRanges(screen.container, "empty").map(match => match.rowId)).toEqual(["control"]);
  // Observe the actual list method called by production useThreadFindNavigation.
  using scroll = vi.spyOn(listRef.current, "scrollToIndex");
  await screen.getByRole("button", { name: "Next match", exact: true }).click();
  await expect.element(screen.getByText("2/2", { exact: true })).toBeVisible();
  await vi.waitFor(() => expect(screen.container.querySelector("[data-selection]")?.textContent).toBe("control"));
  await screen.getByRole("button", { name: "Next match", exact: true }).click();
  await expect.element(screen.getByText("1/2", { exact: true })).toBeVisible();
  await vi.waitFor(() => expect(screen.container.querySelector("[data-selection]")?.textContent).toBe("suppressed"));
  const rowIndex = derived.findIndex(row => row.id === "suppressed");
  await vi.waitFor(() => expect(scroll).toHaveBeenCalledWith(expect.objectContaining({ index: rowIndex, animated: false })));
  expect(suppressedBody()!.textContent).toBe("");
  console.log("BROWSER environment: " + navigator.userAgent);
  console.log("BROWSER suppressed: count=1/2 selected=suppressed body=" + JSON.stringify(suppressedBody()!.textContent) + " matchingRanges=0 navigatedRowIndex=" + rowIndex);
  console.log("BROWSER control: count=2/2 selected=control body=" + JSON.stringify(controlBody()!.textContent) + " matchingRanges=1");
  closeThreadFind();
});

WEB
vp test run apps/server/src/orchestration-v2/zz-verify-empty.test.ts --maxWorkers=1 --silent=false --reporter=verbose
node -e 'const f=JSON.parse(require("node:fs").readFileSync(process.env.VERIFY_FIXTURE,"utf8")); console.log("SERVER observed: "+JSON.stringify({first:f.first,next:f.next,wrapped:f.wrapped}));'
cd "$checkout/apps/web"
vp test run --project browser src/components/chat/zz-verify-empty.browser.test.tsx --maxWorkers=1 --silent=false --reporter=verbose

Expected behavior

Find counts only text the thread shows. The shared find text builder states this rule for hidden user prompts ("Clients hide this prompt, so find must not match text nobody can see", threadFindText.ts), and #34182 made the assistant row hide the (empty response) fallback when its run already has a completed render (TimelineAssistantRows.tsx). For this input, searching empty should find one match, the visible control reply, and the suppressed reply should contribute none.

Actual behavior

Summary. When an assistant reply is empty and its run also produced a completed HTML render, the timeline hides the reply's (empty response) fallback, but thread find still indexes that fallback. Searching empty counts the hidden reply as a match, selects it first, and navigates to a row whose message body is blank. A visible (empty response) reply in another run is counted and highlighted correctly. The effect is one extra count and one extra navigation stop with nothing to highlight; no message text is lost. Merged #34182 (#34182) added the timeline suppression; thread find was outside its changed files, and no statement was found that find should keep matching hidden fallback text.

Observed in the server test and in Chromium. The server returns totalMatches=2, selects suppressed first, then control, then wraps back to suppressed. The browser find bar shows 1/2 with suppressed selected, while that reply's searchable body is "" and has no matching range. Next shows 2/2 on control, whose body is (empty response) with one matching range. Next again wraps to 1/2 on suppressed, and find navigation scrolls the list to that row (index 1), whose body stays blank. The row keeps its author heading and metadata; only the message body is empty.

The server path receives only message text, role, streaming state and context (ThreadFind.ts), and the shared builder substitutes (empty response) for every completed empty assistant message (threadFindText.ts). The timeline derives suppression from a sibling render entry with the same run ID, which the find index does not see. The input is ordinary: the production html_render handler returns the render reference that session-logic.ts turns into the timeline entry, and #34182 describes this render-without-text reply as the case it fixes. A live provider turn was not run, and how often this occurs was not measured.

On the earlier main build under Version or commit, the same server test still returns the same two matches, and ThreadFind.ts, threadFindText.ts, useThreadFind.ts and useThreadFindNavigation.ts are unchanged from the tested commit. The browser test could not load on that build because the tested commit's dependency copy lacks gt-react, which main now imports, so the browser half on main is confirmed from source only.

Related, not duplicates: merged #34182 changed only the timeline display. Open #34402 fixes large-row highlight crashes, open #29310 changes Find document caching, open #29874 changes message search after rollback or cancellation, and open #27934 changes quoting of thinking replies; none changes what find indexes for a suppressed empty reply.

Evidence

  • Expected source: the hidden-text rule at packages/shared/src/threadFindText.ts lines 233-235 and the fallback suppression from fix(web): hide empty replies when renders show the result #34182 at apps/web/src/components/chat/TimelineAssistantRows.tsx lines 34-36.
  • Failure source: the observed server and Chromium output under Logs or stack traces, and the unconditional fallback at packages/shared/src/threadFindText.ts lines 238-246.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

Restoration check

With the script's input and query empty, the server reports totalMatches=1 with control as the only match, the find bar shows 1/1, and Next never selects suppressed. As a control, the control reply in a run without a render still shows (empty response), still counts as one match, and still gets a highlight range, and a non-empty reply in a run with a render stays searchable. The script's assertions need updating to the restored counts for this check.

Impact

Cosmetic issue

Version or commit

d9805b2 (server half also reproduced on main at b85a21e; the four find files named under Actual behavior are unchanged from the tested commit through main at dc032d7)

Environment

macOS arm64, Node v26.12.1, Vitest v5.0.5, HeadlessChrome/156.0.8078.4 through the repo's browser test project; no dev server, desktop shell, mobile client, provider or remote transport

Logs or stack traces

SERVER: totalMatches=2; first=suppressed; next=control; wrapped=suppressed
BROWSER suppressed: count=1/2 selected=suppressed body="" matchingRanges=0 navigatedRowIndex=1
BROWSER control: count=2/2 selected=control body="(empty response)" matchingRanges=1
Server focused test: 1 passed
Chromium focused test: 1 passed

Screenshots, recordings, or supporting files

No response

Workaround

Clicking Next again skips the phantom stop and selects the visible match; the count stays wrong.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions