Skip to content

fix(convex): stop researcher flicker, stream delegation sub-threads, gate on approval#2152

Merged
larryro merged 2 commits into
mainfrom
fix/researcher-delegation-stream-and-approval-gate
Jun 25, 2026
Merged

fix(convex): stop researcher flicker, stream delegation sub-threads, gate on approval#2152
larryro merged 2 commits into
mainfrom
fix/researcher-delegation-stream-and-approval-gate

Conversation

@larryro

@larryro larryro commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three related bugs that surface when the researcher agent runs a research plan (reported from demo.tale.dev, reproduced locally by creating a todo plan and starting research):

1. Crash / flicker loop (console TypeError: …reading 'filter')

getThreadMessagesStreaming's unauthenticated / no-access fallbacks returned streams: { value: null } — a shape the @convex-dev/agent client can't read. useDeltaStreams does streamList.streams.messages.filter(...), so .messages being undefined threw "Cannot read properties of undefined (reading 'filter')", crash-looping the chat-input error boundary (the blank↔visible flicker).

Fix: return a payload matching the requested streamArgs.kind via a new emptyStreamsResult helper ({ kind: 'list', messages: [] } / { kind: 'deltas', deltas: [] } / undefined).

2. Deterministic root cause of the flicker — sub-threads aren't accessible

The chat UI streams a delegate's sub-thread to render the nested delegation timeline (message-segments.tsxuseThreadMessages(subThreadId)). But delegated sub-threads are created through the Agent SDK component with no threadMetadata row (get_or_create_sub_thread.ts), so canAccessThread denied them on every tick → fallback fired deterministically (network-independent, hence reproducible locally).

Fix: add canAccessThreadOrSubThread, which authorizes a sub-thread via the parent thread it was spawned from (bounded walk up summary.parentThreadId), used only on the streaming read path so ordinary RLS is untouched. This restores the live timeline instead of merely not crashing.

3. Approval gate bypassed — researcher starts before the user confirms

request_human_input only tells the model to stop; the chat agent loop had no stopWhen, so with maxSteps: 40 the researcher barrelled past its "确认研究计划 / Proceed?" card straight into web searches.

Fix: add stopWhen: [stepCountIs(maxSteps), hasToolCall('request_human_input')] at the streamText/generateText call sites, scoped to agents that actually expose the tool, making the gate a hard stop. (Setting stopWhen makes the SDK ignore the config's maxSteps, so the step cap is folded back in via stepCountIs.)

Blast radius

  • (1) is a pure defensive correctness fix on the streaming query return shape.
  • (2) adds a new helper used only by the streaming query; hot RLS paths still call canAccessThread.
  • (3) only changes loop behaviour for agents that expose request_human_input (researcher and any future agent that asks for input).

Tests

  • can_access_thread.test.ts: sub-thread parent-resolution access — grant via owned parent, deny via inaccessible parent, deny orphan (no metadata + no parent), nested delegation walks to an accessible ancestor.
  • get_thread_messages_streaming.test.ts: emptyStreamsResult shape for list / deltas / undefined.
  • Full tsc --noEmit clean; oxlint --type-aware clean on changed files.

Verification notes

Not yet manually verified against a running researcher run end-to-end — recommend a quick /verify (start a deep-research plan, confirm: no flicker, the nested timeline renders live, and research does NOT begin until the plan-confirmation card is answered).

larryro and others added 2 commits June 25, 2026 15:03
…gate on approval

Three related bugs surfaced when the researcher agent runs a research plan:

1. Crash/flicker loop. getThreadMessagesStreaming's auth/no-access fallbacks
   returned `streams: { value: null }`, a shape the @convex-dev/agent client
   (useDeltaStreams) can't read — it does `streams.messages.filter(...)`, so
   `.messages` being undefined threw "Cannot read properties of undefined
   (reading 'filter')", crash-looping the chat-input error boundary. Return a
   payload matching the requested `streamArgs.kind` via `emptyStreamsResult`.

2. Deterministic root cause of the above: the chat UI streams a delegate's
   sub-thread to render the nested delegation timeline, but sub-threads are
   created through the Agent SDK component with NO threadMetadata row, so
   canAccessThread denied them on every tick. Add canAccessThreadOrSubThread,
   which authorizes a sub-thread via the parent thread it was spawned from
   (bounded walk up `summary.parentThreadId`), and use it on the streaming
   read path only. Restores the live timeline instead of just not crashing.

3. Approval gate bypass. request_human_input only *tells* the model to stop;
   the chat agent loop had no stopWhen, so with maxSteps:40 the researcher
   barrelled past its plan-confirmation card straight into web searches. Add
   `stopWhen: [stepCountIs(maxSteps), hasToolCall('request_human_input')]` at
   the streamText/generateText call sites, scoped to agents that expose the
   tool, making the gate a hard stop.

Tests: sub-thread parent-resolution access (grant/deny/orphan/nested) and
emptyStreamsResult shape (list/deltas/undefined).
The researcher's live plan flashed then vanished mid-run, the approval
gate was bypassed into the research, and a delegate's gate could strand a
pending approval that locked the composer. Three related fixes:

1. Research-plan todos disappear (the visible bug). `thread_todos.get`
   resolves the plan by walking the branch/delegate chain and broke on
   the FIRST threadTodos row found. But a thread that only made
   integration calls still owns a row — purely to track
   `integrationCallCount` — with an empty `todos` array. Once the parent
   agent ran its own web/integration calls, that empty parent row
   shadowed the researcher delegate's real plan and `get` returned
   `todos: []`, unregistering the plan pane. Require `todos.length > 0`
   for a hit so an empty row never shadows a delegate plan.

2. Approval gate bypass. `stopWhen: hasToolCall('request_human_input')`
   halts the loop with finishReason 'tool-calls', which
   `shouldRetryGeneration` read as an incomplete response and
   auto-continued — barrelling straight past the gate the stop exists to
   enforce. Treat a loop that ended on `request_human_input` as terminal
   (covers 'tool-calls' and 'stop'+empty-text).

3. Un-resumable delegate gate. A delegated sub-agent's
   `request_human_input` re-homes its approval to the parent thread, but
   submitting the response resumes the PARENT agent, never the delegate —
   stranding a pending approval that locks the composer. Strip
   `request_human_input` from delegated runs (in `applyDelegationStrip`);
   the gate still works when the agent runs as the primary.

Tests: empty-row-vs-delegate-plan resolution, human-input gate terminal
in retry policy, and delegation gate strip.
@larryro larryro merged commit 2535924 into main Jun 25, 2026
67 of 68 checks passed
@larryro larryro deleted the fix/researcher-delegation-stream-and-approval-gate branch June 25, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant