fix(convex): stop researcher flicker, stream delegation sub-threads, gate on approval#2152
Merged
larryro merged 2 commits intoJun 25, 2026
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 returnedstreams: { value: null }— a shape the@convex-dev/agentclient can't read.useDeltaStreamsdoesstreamList.streams.messages.filter(...), so.messagesbeingundefinedthrew "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.kindvia a newemptyStreamsResulthelper ({ 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.tsx→useThreadMessages(subThreadId)). But delegated sub-threads are created through the Agent SDK component with nothreadMetadatarow (get_or_create_sub_thread.ts), socanAccessThreaddenied 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 upsummary.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_inputonly tells the model to stop; the chat agent loop had nostopWhen, so withmaxSteps: 40the researcher barrelled past its "确认研究计划 / Proceed?" card straight into web searches.Fix: add
stopWhen: [stepCountIs(maxSteps), hasToolCall('request_human_input')]at thestreamText/generateTextcall sites, scoped to agents that actually expose the tool, making the gate a hard stop. (SettingstopWhenmakes the SDK ignore the config'smaxSteps, so the step cap is folded back in viastepCountIs.)Blast radius
canAccessThread.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:emptyStreamsResultshape for list / deltas / undefined.tsc --noEmitclean;oxlint --type-awareclean 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).