mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(conversations): drive agentic task insights reset off turn lifecycle, not isRunning (#5010)
This commit is contained in:
@@ -157,6 +157,7 @@ export default function WorkflowCopilotPanel({
|
||||
const {
|
||||
threadId,
|
||||
sending,
|
||||
turnActive,
|
||||
proposal,
|
||||
capped,
|
||||
displayMessages,
|
||||
@@ -529,6 +530,7 @@ export default function WorkflowCopilotPanel({
|
||||
<ToolTimelineBlock
|
||||
entries={toolTimeline}
|
||||
liveResponse={hasLiveText ? liveResponseText : undefined}
|
||||
turnActive={turnActive}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean | null>(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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
<ToolTimelineBlock entries={subagentARunning} turnActive />
|
||||
);
|
||||
// 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(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock entries={subagentASettled} turnActive />
|
||||
</Provider>
|
||||
);
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock entries={subagentBRunning} turnActive />
|
||||
</Provider>
|
||||
);
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock entries={subagentBSettled} turnActive />
|
||||
</Provider>
|
||||
);
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock entries={subagentBSettled} turnActive={false} />
|
||||
</Provider>
|
||||
);
|
||||
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(
|
||||
<ToolTimelineBlock entries={subagentARunning} turnActive />
|
||||
);
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock entries={afterSubagentB} turnActive />
|
||||
</Provider>
|
||||
);
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock entries={bothSettled} turnActive />
|
||||
</Provider>
|
||||
);
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock entries={bothSettled} turnActive={false} />
|
||||
</Provider>
|
||||
);
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock entries={newTurnRunning} turnActive />
|
||||
</Provider>
|
||||
);
|
||||
expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the tool result output inside the expanded row', () => {
|
||||
const entries: ToolTimelineEntry[] = [
|
||||
{
|
||||
|
||||
@@ -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<string, unknown[]>,
|
||||
toolTimelineByThread: {} as Record<string, unknown[]>,
|
||||
streamingAssistantByThread: {} as Record<string, { content: string }>,
|
||||
inferenceTurnLifecycleByThread: {} as Record<string, InferenceTurnLifecycle>,
|
||||
}));
|
||||
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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user