From b136fa032447b574543ce83caef1b5ce77eb6e80 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:30:51 +0530 Subject: [PATCH] fix(conversations): drive agentic task insights reset off turn lifecycle, not isRunning (#5010) --- .../components/flows/WorkflowCopilotPanel.tsx | 2 + .../features/conversations/Conversations.tsx | 9 ++ .../components/ToolTimelineBlock.tsx | 59 ++++++-- .../__tests__/ToolTimelineBlock.test.tsx | 142 ++++++++++++++++++ app/src/hooks/useWorkflowBuilderChat.test.ts | 87 ++++++++++- app/src/hooks/useWorkflowBuilderChat.ts | 64 +++++++- 6 files changed, 346 insertions(+), 17 deletions(-) diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index 67902ae68..657e6bbb6 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -157,6 +157,7 @@ export default function WorkflowCopilotPanel({ const { threadId, sending, + turnActive, proposal, capped, displayMessages, @@ -529,6 +530,7 @@ export default function WorkflowCopilotPanel({ )} diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index aa3e6ef24..a25ff26c8 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -1983,6 +1983,15 @@ const Conversations = ({ entries={selectedThreadToolTimeline} onViewDetails={openScopedDetail} onViewWholeRun={openWholeRunSource} + // Reuse `isSending` rather than a raw `in` membership check on + // `inferenceTurnLifecycleByThread`: that map also carries + // `'interrupted'` entries (a turn that crashed mid-flight in a + // PRIOR core process, written by `hydrateRuntimeFromSnapshot` on + // cold boot) which have no live driver and must NOT read as an + // active turn, or stale disclosure state leaks into a later + // retry. `isSending` already excludes it (only `'started'` / + // `'streaming'`, same as this component's own live-turn checks). + turnActive={isSending} /> ) : ( // Transcript-only turn: reasoning/narration was streamed but no tool diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx index 8c8d1484c..33249b07f 100644 --- a/app/src/features/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx @@ -1,5 +1,5 @@ import createDebug from 'debug'; -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import WorktreeActions from '../../../components/worktree/WorktreeActions'; import { useT } from '../../../lib/i18n/I18nContext'; @@ -454,6 +454,7 @@ export function ToolTimelineBlock({ onViewWholeRun, expandAllRows = false, liveResponse, + turnActive, }: { entries: ToolTimelineEntry[]; /** Opens the full-transcript drawer for a subagent row. When omitted, @@ -479,6 +480,16 @@ export function ToolTimelineBlock({ * Omitted/empty once the turn settles — the final answer is the message * bubble. */ liveResponse?: string; + /** Whether a turn is in flight on this thread's lifecycle + * (`inferenceTurnLifecycleByThread`), the same signal the chat threads page + * uses. When provided, the sticky `userOverrideOpen` reset fires on THIS + * value's true→false edge — once per USER TURN — instead of on `isRunning`'s + * edge, which flips once PER SUB-AGENT within a single turn (each + * subagent spawn→settle) and made the panel flicker open/closed as + * sub-agents ran (regression from #5008). Falls back to `isRunning` when + * omitted, which is correct for a settled/past-turn render (there is no + * turn left to track). */ + turnActive?: boolean; }) { const { t } = useT(); @@ -500,24 +511,44 @@ export function ToolTimelineBlock({ const [userOverrideOpen, setUserOverrideOpen] = useState(null); // Whether *any* entry is currently running — computed here (ahead of the - // `entries.length === 0` early return below) purely so the reset effect - // that follows is an unconditional hook call every render; order doesn't - // matter for this existence check, unlike `latestRunningEntryId` further - // down, which needs the seq-sorted order to pick a specific "latest" row. + // `entries.length === 0` early return below) purely so the render-time + // reset adjustment that follows runs every render; order doesn't matter for + // this existence check, unlike `latestRunningEntryId` further down, which + // needs the seq-sorted order to pick a specific "latest" row. const isRunning = entries.some(entry => entry.status === 'running'); - // Reset the user's manual open/close override on the running→settled edge - // (a turn just finished) so the auto-collapse applies to the just-settled - // turn. The override only sticks WITHIN a turn — preventing involuntary - // mid-feedback collapse (#4942) — not permanently across turns. - const prevIsRunningRef = useRef(false); - useEffect(() => { - if (prevIsRunningRef.current && !isRunning) { + // The signal the reset below watches for a "turn just settled" edge. Prefer + // the real TURN lifecycle (`turnActive`, sourced from + // `inferenceTurnLifecycleByThread` — the same signal the chat threads page + // uses) when the caller supplies it: it flips true→false exactly once per + // USER TURN. `isRunning` is only a fallback for callers with no turn + // lifecycle to hand (e.g. a settled/past-turn render, where entries never + // change again anyway) — used directly it flips once PER SUB-AGENT within a + // single turn (each subagent spawn→settle), which reset the override (and + // so auto-collapsed the panel) repeatedly within one turn and made it + // flicker open/closed as sub-agents ran (#5008 regression). + const settleSignal = turnActive ?? isRunning; + + // Reset the user's manual open/close override on the settleSignal's + // true→false edge (a turn just finished) so the auto-collapse applies to + // the just-settled turn. The override only sticks WITHIN a turn — + // preventing involuntary mid-feedback collapse (#4942) — not permanently + // across turns. + // + // Done as a render-time adjustment (comparing against `prevSettleSignal` + // state and calling both setters synchronously in the render body), not a + // `useEffect`, per React's documented pattern for resetting state on a prop + // transition: it bails out and re-renders with the reset applied before + // paint, instead of committing a stale (still-collapsed/expanded) frame + // and only correcting it a tick later once the effect runs. + const [prevSettleSignal, setPrevSettleSignal] = useState(settleSignal); + if (prevSettleSignal !== settleSignal) { + if (prevSettleSignal && !settleSignal) { log('agent-task-insights: turn settled (running→done), resetting user override'); setUserOverrideOpen(null); } - prevIsRunningRef.current = isRunning; - }, [isRunning]); + setPrevSettleSignal(settleSignal); + } if (entries.length === 0) return null; diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx index c8ddb1d6d..a054d60ff 100644 --- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -581,6 +581,148 @@ describe('ToolTimelineBlock — agentic task insights surface', () => { }); }); + // #5008 shipped the reset-on-settle fix using the `isRunning` true→false + // edge, but that edge fires once PER SUB-AGENT within a single turn (each + // subagent_spawned/subagent_completed pair toggles `isRunning`), not once + // per turn — so on a multi-sub-agent turn the panel's override reset (and + // its auto-collapse) fired repeatedly, flickering the panel open/closed + // as each sub-agent came and went. `turnActive` — sourced from + // `inferenceTurnLifecycleByThread`, the same lifecycle the chat threads + // page uses for `isSending` — transitions exactly once per USER TURN, so + // passing it in makes the reset track the turn instead of any single + // sub-agent. + describe('with turnActive prop', () => { + it('does not reset the user override while turnActive stays true across multiple isRunning toggles, only resetting (and auto-collapsing) when turnActive itself goes false', () => { + const subagentARunning: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'running' }, + ]; + const { rerender } = renderInStore( + + ); + // Sub-agent A running, turn active → auto-open (no override yet). + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // Sub-agent A settles: `isRunning` flips true→false, but the whole + // TURN is still active. Pre-fix this alone would have reset the + // (nonexistent, still null) override — here there's nothing to reset, + // but the auto rule alone already collapses it (autoOpen tracks + // `isRunning`, unchanged by this fix). + const subagentASettled: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // The user manually expands it while the turn is still in flight. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // Sub-agent B spawns: `isRunning` flips false→true again. Still one + // turn (`turnActive` unchanged) — the user's override must hold. + const subagentBRunning: ToolTimelineEntry[] = [ + ...subagentASettled, + { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // Sub-agent B settles: `isRunning` flips true→false a second time + // within the SAME turn. This is exactly the edge that used to reset + // the override and cause the flicker (#5008 regression) — with + // `turnActive` supplied it must NOT reset; the user's expand sticks. + const subagentBSettled: ToolTimelineEntry[] = [ + ...subagentASettled, + { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // Only when the TURN itself ends (`turnActive` true→false) does the + // override reset — the panel then auto-collapses since the run is done. + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + }); + + it('keeps a mid-turn manual collapse intact across a sub-agent settling, only reopening per the auto rule once turnActive ends', () => { + const subagentARunning: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'running' }, + ]; + const { rerender } = renderInStore( + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + + // The user explicitly collapses it while sub-agent A is still running. + fireEvent.click(screen.getByText('Agentic task insights')); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // Sub-agent A settles, sub-agent B spawns and settles too — all within + // the same turn (`turnActive` stays true throughout). None of these + // `isRunning` toggles may reopen the panel against the user's choice. + const afterSubagentB: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' }, + { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + const bothSettled: ToolTimelineEntry[] = [ + { id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' }, + { id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'success' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // The turn ends — override resets; auto rule (settled, not running) + // keeps it collapsed, same outcome but for the right reason now. + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open'); + + // Both sides of that transition read "collapsed", which a STALE `false` + // override would also produce — prove the override actually reset (not + // just that it happened to still agree with the auto rule) by starting + // a brand-new turn: the auto rule alone (isRunning) should now govern, + // reopening the panel with no further user interaction. + const newTurnRunning: ToolTimelineEntry[] = [ + { id: 'c', name: 'subagent:researcher', round: 2, seq: 0, status: 'running' }, + ]; + rerender( + + + + ); + expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open'); + }); + }); + it('renders the tool result output inside the expanded row', () => { const entries: ToolTimelineEntry[] = [ { diff --git a/app/src/hooks/useWorkflowBuilderChat.test.ts b/app/src/hooks/useWorkflowBuilderChat.test.ts index 20cf47c07..78caf651f 100644 --- a/app/src/hooks/useWorkflowBuilderChat.test.ts +++ b/app/src/hooks/useWorkflowBuilderChat.test.ts @@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { BuilderTurnResult } from '../services/api/flowsApi'; -import type { WorkflowProposal } from '../store/chatRuntimeSlice'; +import type { InferenceTurnLifecycle, WorkflowProposal } from '../store/chatRuntimeSlice'; import type { ThreadMessage } from '../types/thread'; import { useWorkflowBuilderChat, type WorkflowBuilderSendResult } from './useWorkflowBuilderChat'; @@ -21,6 +21,7 @@ const selectorState = vi.hoisted(() => ({ messagesByThreadId: {} as Record, toolTimelineByThread: {} as Record, streamingAssistantByThread: {} as Record, + inferenceTurnLifecycleByThread: {} as Record, })); vi.mock('../store/hooks', () => ({ useAppDispatch: () => dispatch, @@ -31,6 +32,7 @@ vi.mock('../store/hooks', () => ({ pendingWorkflowProposalsByThread: selectorState.proposals, toolTimelineByThread: selectorState.toolTimelineByThread, streamingAssistantByThread: selectorState.streamingAssistantByThread, + inferenceTurnLifecycleByThread: selectorState.inferenceTurnLifecycleByThread, }, }), })); @@ -43,6 +45,8 @@ vi.mock('../store/threadSlice', () => ({ THREAD_NOT_FOUND_MESSAGE, })); vi.mock('../store/chatRuntimeSlice', () => ({ + beginInferenceTurn: (p: unknown) => ({ type: 'beginInferenceTurn', p }), + endInferenceTurn: (p: unknown) => ({ type: 'endInferenceTurn', p }), clearWorkflowProposalForThread: (p: unknown) => ({ type: 'clearProposal', p }), setWorkflowProposalForThread: (p: unknown) => ({ type: 'setProposal', p }), fetchAndHydrateTurnState: (threadId: string) => ({ type: 'fetchAndHydrateTurnState', threadId }), @@ -68,6 +72,7 @@ describe('useWorkflowBuilderChat', () => { selectorState.messagesByThreadId = {}; selectorState.toolTimelineByThread = {}; selectorState.streamingAssistantByThread = {}; + selectorState.inferenceTurnLifecycleByThread = {}; dispatch.mockReset().mockImplementation((action: { type: string }) => { if (action.type === 'createNewThread') { return { unwrap: () => Promise.resolve({ id: 'builder-1' }) }; @@ -107,6 +112,86 @@ describe('useWorkflowBuilderChat', () => { await waitFor(() => expect(result.current.threadId).toBe('builder-1')); }); + it('seeds and clears the shared turn-lifecycle entry around the blocking flows_build call', async () => { + // Regression test: `turnActive` (derived from `inferenceTurnLifecycleByThread` + // below) must reflect a REAL turn in flight, or `ToolTimelineBlock`'s + // `turnActive ?? isRunning` fallback is defeated — a `false` `turnActive` + // (as opposed to `undefined`) short-circuits nullish coalescing and never + // falls back to `isRunning`, permanently disabling the panel's settle-edge + // override reset for the copilot surface this hook drives. + let sawActiveDuringCall = false; + buildWorkflow.mockImplementation(async () => { + // `beginInferenceTurn` must have been dispatched — and not yet cleared — + // by the time the blocking RPC is in flight. + sawActiveDuringCall = dispatch.mock.calls.some( + ([a]) => (a as { type: string }).type === 'beginInferenceTurn' + ); + return okResult(); + }); + + const { result } = renderHook(() => useWorkflowBuilderChat()); + await act(async () => { + await result.current.send({ + displayText: 'hi', + request: { mode: 'create', instruction: 'x' }, + }); + }); + + expect(sawActiveDuringCall).toBe(true); + const calls = dispatch.mock.calls.map(([a]) => a as { type: string; p?: unknown }); + const beginIndex = calls.findIndex(a => a.type === 'beginInferenceTurn'); + const endIndex = calls.findIndex(a => a.type === 'endInferenceTurn'); + expect(beginIndex).toBeGreaterThanOrEqual(0); + expect(endIndex).toBeGreaterThan(beginIndex); + expect(calls[beginIndex].p).toEqual({ threadId: 'builder-1' }); + expect(calls[endIndex].p).toEqual({ threadId: 'builder-1' }); + }); + + it('clears the turn-lifecycle entry even when the blocking flows_build call throws', async () => { + buildWorkflow.mockRejectedValue(new Error('network blip')); + + const { result } = renderHook(() => useWorkflowBuilderChat()); + await act(async () => { + await result.current.send({ + displayText: 'hi', + request: { mode: 'create', instruction: 'x' }, + }); + }); + + // A failed turn must not leak the lifecycle entry — otherwise `turnActive` + // would stay stuck `true` forever, since no `chat_done` ever arrives for a + // turn that never reached the server. + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'endInferenceTurn', p: { threadId: 'builder-1' } }) + ); + }); + + it("treats an 'interrupted' lifecycle entry as NOT active (no live driver behind it)", () => { + // 'interrupted' is written by `hydrateRuntimeFromSnapshot` for a turn that + // crashed mid-flight in a PRIOR core process (cold-boot rehydrate) — there + // is no live driver streaming for it, so `turnActive` must stay `false`, + // or stale disclosure state (from before the crash) leaks into whatever + // the user does next on this thread. + const interrupted: InferenceTurnLifecycle = 'interrupted'; + selectorState.inferenceTurnLifecycleByThread = { 'builder-1': interrupted }; + + const { result } = renderHook(() => useWorkflowBuilderChat('builder-1')); + + expect(result.current.turnActive).toBe(false); + }); + + it("treats 'started'/'streaming' lifecycle entries as active", () => { + const started: InferenceTurnLifecycle = 'started'; + selectorState.inferenceTurnLifecycleByThread = { 'builder-1': started }; + const { result: startedResult } = renderHook(() => useWorkflowBuilderChat('builder-1')); + expect(startedResult.current.turnActive).toBe(true); + + const streaming: InferenceTurnLifecycle = 'streaming'; + selectorState.inferenceTurnLifecycleByThread = { 'builder-1': streaming }; + const { result: streamingResult } = renderHook(() => useWorkflowBuilderChat('builder-1')); + expect(streamingResult.current.turnActive).toBe(true); + }); + it('surfaces the proposal the builder returned by dispatching it into the store', async () => { const proposal: WorkflowProposal = { name: 'Digest', diff --git a/app/src/hooks/useWorkflowBuilderChat.ts b/app/src/hooks/useWorkflowBuilderChat.ts index fa2710243..d17bce0f1 100644 --- a/app/src/hooks/useWorkflowBuilderChat.ts +++ b/app/src/hooks/useWorkflowBuilderChat.ts @@ -27,9 +27,15 @@ import createDebug from 'debug'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { type BuilderTurnRequest, buildWorkflow } from '../services/api/flowsApi'; import { + type BuilderTurnRequest, + type BuilderTurnResult, + buildWorkflow, +} from '../services/api/flowsApi'; +import { + beginInferenceTurn, clearWorkflowProposalForThread, + endInferenceTurn, fetchAndHydrateTurnHistory, fetchAndHydrateTurnState, setWorkflowProposalForThread, @@ -94,6 +100,15 @@ export interface UseWorkflowBuilderChat { threadId: string | null; /** True while a builder turn is in flight on this thread. */ sending: boolean; + /** + * Whether a turn is in flight on this thread per the runtime's + * `inferenceTurnLifecycleByThread` — the same turn-lifecycle signal the main + * chat threads page uses to derive `isSending`. Passed through as + * `ToolTimelineBlock`'s `turnActive` prop so the panel's sticky + * open/collapse override resets once per TURN instead of once per + * sub-agent (see that component's doc for the #5008 flicker this fixes). + */ + turnActive: boolean; /** The latest proposal the agent returned on this thread, or `null`. */ proposal: WorkflowProposal | null; /** @@ -196,6 +211,21 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo const streamingAssistantByThread = useAppSelector( state => state.chatRuntime.streamingAssistantByThread ); + const inferenceTurnLifecycleByThread = useAppSelector( + state => state.chatRuntime.inferenceTurnLifecycleByThread + ); + + // A turn is in flight on this thread iff its lifecycle entry is `'started'` + // or `'streaming'` — NOT `'interrupted'`, which `hydrateRuntimeFromSnapshot` + // (chatRuntimeSlice.ts) writes for a turn that crashed mid-flight in a PRIOR + // core process (cold-boot rehydrate): there is no live driver behind it, so + // treating it as "active" would leak stale disclosure state into a later, + // genuinely new turn on this same thread. Mirrors `Conversations.tsx`'s + // `isSending` derivation (same explicit two-state check, not a broad `in` + // membership test) so the copilot's `ToolTimelineBlock` resets its sticky + // override on the same real turn-settle edge the main chat uses. + const threadLifecycle = threadId != null ? inferenceTurnLifecycleByThread[threadId] : undefined; + const turnActive = threadLifecycle === 'started' || threadLifecycle === 'streaming'; // Prefer the runtime's streamed proposal (populated on this thread by // `ChatRuntimeProvider` as the builder's `propose_workflow`/`revise_workflow` @@ -343,8 +373,37 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo // slices as the turn runs, so this hook must NOT also append the agent // reply (doing so would double it — B26). We still await the blocking // result for its `proposal`/`error` signal. + // + // Seed the shared turn-lifecycle entry for this thread (mirrors + // `Conversations.tsx`'s `beginInferenceTurn` dispatch on send) so + // `turnActive` above (`threadId in inferenceTurnLifecycleByThread`) + // reflects a REAL turn in flight. Without this, nothing ever creates + // an entry for the builder's dedicated thread — `ChatRuntimeProvider` + // only *updates* an existing entry (`markInferenceTurnStreaming` is a + // no-op unless one is already present) — so `turnActive` would stay + // `false` for the entire life of every builder turn. Because it's a + // *boolean* `false` rather than `undefined`, that permanently defeats + // `ToolTimelineBlock`'s `turnActive ?? isRunning` fallback (nullish + // coalescing only falls back on null/undefined, never on `false`), + // so the panel's settle-edge override reset — the entire point of + // this fix — would never fire for the copilot surface it targets. + dispatch(beginInferenceTurn({ threadId: targetThreadId })); log('send: running flows_build thread=%s mode=%s', targetThreadId, request.mode); - const result = await buildWorkflow(request, targetThreadId); + let result: BuilderTurnResult; + try { + result = await buildWorkflow(request, targetThreadId); + } finally { + // The blocking RPC settling (success or error) IS the turn ending — + // clear eagerly here rather than relying solely on + // `ChatRuntimeProvider`'s generic `chat_done` listener (which also + // calls `endInferenceTurn` for this thread — redundant but + // harmless, since it's a plain delete). If the server-side turn + // never reaches `chat_done` (e.g. this call throws before the core + // ever starts one), that listener never fires and the lifecycle + // entry would otherwise leak, stranding `turnActive` — and the + // panel it drives — permanently `true`. + dispatch(endInferenceTurn({ threadId: targetThreadId })); + } // Surface the proposal via the same store slice the streamed path used, // so `WorkflowProposalCard` / the copilot preview render unchanged. This @@ -412,6 +471,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo return { threadId, sending, + turnActive, proposal, capped, messages,