mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
🪟 fix: Keep the Subagent Panel Steady and Honest Across Event Deliveries (#15387)
* 🪟 fix: Keep the Subagent Panel Steady and Honest Across Event Deliveries * 🧮 fix: Budget the Activity Projection at the Database Seam and Scope Retained Views * 🧮 fix: Charge Label Associations to the Budget and Scope History Flags * 🧮 fix: Gate Mixed Activity Metadata and Keep the Budgeted Suffix Contiguous * 🧵 fix: Render Only the Active Thread's Retained Turns
This commit is contained in:
parent
c7f0a8f247
commit
c93bd7ec9d
6 changed files with 376 additions and 126 deletions
|
|
@ -107,7 +107,7 @@ describe('SubagentConversation', () => {
|
|||
);
|
||||
|
||||
expect(screen.getAllByText('com_ui_subagent_trigger_parent_dispatch')).toHaveLength(2);
|
||||
expect(screen.getAllByText('com_ui_subagent_trigger_external_event')).toHaveLength(1);
|
||||
expect(screen.getAllByText('com_ui_subagent_trigger_external_event')).toHaveLength(2);
|
||||
expect(screen.getByText('Investigate the release.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Checked the constraints.')).toBeInTheDocument();
|
||||
expect(screen.getByText('search')).toBeInTheDocument();
|
||||
|
|
@ -115,8 +115,8 @@ describe('SubagentConversation', () => {
|
|||
expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_subagent_thread_status_running')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.message-render')).toHaveLength(3);
|
||||
expect(container.querySelectorAll('.user-turn')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('.message-render')).toHaveLength(4);
|
||||
expect(container.querySelectorAll('.user-turn')).toHaveLength(2);
|
||||
expect(container.querySelectorAll('.agent-turn')).toHaveLength(2);
|
||||
expect(container.querySelector('[data-subagent-conversation]')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_prompt')).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -35,59 +35,82 @@ function TriggerIcon({ kind }: { kind: ChildConversationTurn['trigger']['kind']
|
|||
);
|
||||
}
|
||||
|
||||
function ExternalEventTrigger({ turn }: { turn: ChildConversationTurn }) {
|
||||
function ExternalEventTrigger({
|
||||
turn,
|
||||
fullWidth,
|
||||
}: {
|
||||
turn: ChildConversationTurn;
|
||||
fullWidth: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const details = turn.trigger.externalEvent;
|
||||
const label = localize('com_ui_subagent_trigger_external_event');
|
||||
let body: ReactNode;
|
||||
if (details == null) {
|
||||
return (
|
||||
<div className="flex min-h-9 items-center gap-2 text-sm text-text-secondary">
|
||||
body = (
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
|
||||
<TriggerIcon kind="external_event" />
|
||||
<span className="font-medium">{label}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto min-h-6 w-full justify-start gap-1.5 px-0 text-left text-xs font-medium text-text-secondary hover:bg-transparent hover:text-text-primary"
|
||||
>
|
||||
<TriggerIcon kind="external_event" />
|
||||
<span>{label}</span>
|
||||
<span className="min-w-0 truncate font-normal">
|
||||
{details.eventType} · {details.sourceType}
|
||||
</span>
|
||||
<span className="sr-only">{details.occurredAt}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden
|
||||
className={`ml-auto shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="ml-8 border-l border-border-light py-1 pl-3 text-xs text-text-secondary">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
<dt>{localize('com_ui_subagent_event_type')}</dt>
|
||||
<dd className="break-words text-text-primary">{details.eventType}</dd>
|
||||
<dt>{localize('com_ui_subagent_event_source')}</dt>
|
||||
<dd className="break-words text-text-primary">{details.sourceType}</dd>
|
||||
<dt>{localize('com_ui_subagent_event_received')}</dt>
|
||||
<dd className="break-words text-text-primary">
|
||||
{new Date(details.occurredAt).toLocaleString()}
|
||||
</dd>
|
||||
{details.expectedActionToolName != null && (
|
||||
<>
|
||||
<dt>{localize('com_ui_subagent_event_expected_action')}</dt>
|
||||
<dd className="break-words text-text-primary">{details.expectedActionToolName}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto min-h-9 w-full justify-start gap-2 px-0 text-left text-sm text-text-secondary hover:bg-transparent hover:text-text-primary"
|
||||
>
|
||||
<TriggerIcon kind="external_event" />
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{details.eventType} · {details.sourceType}
|
||||
</span>
|
||||
<span className="sr-only">{details.occurredAt}</span>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
aria-hidden
|
||||
className={`ml-auto shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="ml-8 border-l border-border-light py-1 pl-3 text-xs text-text-secondary">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
<dt>{localize('com_ui_subagent_event_type')}</dt>
|
||||
<dd className="break-words text-text-primary">{details.eventType}</dd>
|
||||
<dt>{localize('com_ui_subagent_event_source')}</dt>
|
||||
<dd className="break-words text-text-primary">{details.sourceType}</dd>
|
||||
<dt>{localize('com_ui_subagent_event_received')}</dt>
|
||||
<dd className="break-words text-text-primary">
|
||||
{new Date(details.occurredAt).toLocaleString()}
|
||||
</dd>
|
||||
{details.expectedActionToolName != null && (
|
||||
<>
|
||||
<dt>{localize('com_ui_subagent_event_expected_action')}</dt>
|
||||
<dd className="break-words text-text-primary">{details.expectedActionToolName}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<MessageRow
|
||||
id={`${turn.taskId}:trigger`}
|
||||
icon={<TriggerIcon kind="external_event" />}
|
||||
label={label}
|
||||
footer={null}
|
||||
timestamp={turn.trigger.createdAt ?? details?.occurredAt}
|
||||
ariaLabel={label}
|
||||
headerPrefix=""
|
||||
isCreatedByUser={true}
|
||||
fullWidth={fullWidth}
|
||||
>
|
||||
{body}
|
||||
</MessageRow>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +130,7 @@ function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; full
|
|||
[turn.trigger.summary],
|
||||
);
|
||||
if (turn.trigger.kind === 'external_event') {
|
||||
return <ExternalEventTrigger turn={turn} />;
|
||||
return <ExternalEventTrigger turn={turn} fullWidth={fullWidth} />;
|
||||
}
|
||||
return (
|
||||
<MessageRow
|
||||
|
|
|
|||
|
|
@ -359,7 +359,7 @@ describe('SubagentThreadPanel', () => {
|
|||
'parent-conversation',
|
||||
'child-thread',
|
||||
'task',
|
||||
undefined,
|
||||
{ keepPreviousData: true },
|
||||
);
|
||||
expect(mockUseSubagentActivityStream).toHaveBeenCalledWith(selection, false);
|
||||
expect(screen.getByText('Research child')).toBeInTheDocument();
|
||||
|
|
@ -1264,7 +1264,7 @@ describe('SubagentThreadPanel', () => {
|
|||
'parent-conversation',
|
||||
'child-thread',
|
||||
'task',
|
||||
{ refetchInterval: 2000 },
|
||||
{ keepPreviousData: true, refetchInterval: 2000 },
|
||||
);
|
||||
expect(mockUseSubagentActivityStream).toHaveBeenLastCalledWith(eventSelection, true);
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -190,24 +190,33 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
selectedEventActor?.actorId ??
|
||||
eventSummary?.actorId ??
|
||||
foregroundTitle;
|
||||
const { data, isLoading, isError, isReadinessPending, refetch } = useSubagentThreadQuery(
|
||||
selection.parentConversationId,
|
||||
threadId,
|
||||
taskId,
|
||||
eventTaskRunning ? { refetchInterval: ACTIVE_THREAD_REFRESH_MS } : undefined,
|
||||
);
|
||||
const { data, isLoading, isError, isPreviousData, isReadinessPending, refetch } =
|
||||
useSubagentThreadQuery(selection.parentConversationId, threadId, taskId, {
|
||||
/** A new delivery re-keys this query to its task. Keeping the previous
|
||||
* thread view mounted while the fresh one loads stops the whole panel
|
||||
* from flashing back to a loading dot on every incoming event. */
|
||||
keepPreviousData: true,
|
||||
...(eventTaskRunning ? { refetchInterval: ACTIVE_THREAD_REFRESH_MS } : {}),
|
||||
});
|
||||
/** Retention crosses every key change, including actor switches. A retained
|
||||
* view is only meaningful while it describes the SAME child thread; within
|
||||
* that thread its turn/history rows stay valid across task re-keys, while
|
||||
* task-scoped fields (selected activity, status, control receipts) must not
|
||||
* be attributed to the newly selected task. */
|
||||
const threadView = data?.threadId === threadId ? data : undefined;
|
||||
const taskView = isPreviousData ? undefined : threadView;
|
||||
const latestHistoryGeneration = JSON.stringify([
|
||||
data?.nextCursor ?? null,
|
||||
...(data?.turns?.map((turn) => turn.taskId) ?? []),
|
||||
threadView?.nextCursor ?? null,
|
||||
...(threadView?.turns?.map((turn) => turn.taskId) ?? []),
|
||||
]);
|
||||
const latestHistoryGenerationRef = useRef(latestHistoryGeneration);
|
||||
latestHistoryGenerationRef.current = latestHistoryGeneration;
|
||||
const durableTerminal =
|
||||
subagentThreadHasTaskEvidence(data, taskId) &&
|
||||
(data?.status === 'completed' ||
|
||||
data?.status === 'failed' ||
|
||||
data?.status === 'interrupted' ||
|
||||
data?.status === 'cancelled');
|
||||
subagentThreadHasTaskEvidence(taskView, taskId) &&
|
||||
(taskView?.status === 'completed' ||
|
||||
taskView?.status === 'failed' ||
|
||||
taskView?.status === 'interrupted' ||
|
||||
taskView?.status === 'cancelled');
|
||||
const priorTerminalRef = useRef(false);
|
||||
useSubagentActivityStream(selection, !durableTerminal || eventTaskRunning);
|
||||
|
||||
|
|
@ -290,6 +299,10 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
const activeThreadRef = useRef(threadId);
|
||||
const selectionThreadRef = useRef(threadId);
|
||||
const selectionGenerationRef = useRef(0);
|
||||
/** Locally retained turn buffers reset in a passive effect; until it has run
|
||||
* for the current thread they still hold the previous thread's turns and
|
||||
* must not render. Stamped inside that reset effect. */
|
||||
const retainedTurnsGenerationRef = useRef(0);
|
||||
const turnDetailRequestsRef = useRef(new Set<string>());
|
||||
const historyRequestRef = useRef<string | null>(null);
|
||||
const historyHasLoadedRef = useRef(false);
|
||||
|
|
@ -320,6 +333,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
setMovingWindowTurns([]);
|
||||
setRebaseTurns([]);
|
||||
setPostRebaseTurns([]);
|
||||
retainedTurnsGenerationRef.current = selectionGenerationRef.current;
|
||||
postRebaseTurnsRef.current = [];
|
||||
setHistoryRebaseActive(false);
|
||||
historyRebaseActiveRef.current = false;
|
||||
|
|
@ -334,19 +348,13 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
|
||||
useEffect(() => {
|
||||
if (
|
||||
data?.threadId === threadId &&
|
||||
(data.historyUnavailable === true ||
|
||||
(data.historyTruncated === true && data.nextCursor == null))
|
||||
threadView != null &&
|
||||
(threadView.historyUnavailable === true ||
|
||||
(threadView.historyTruncated === true && threadView.nextCursor == null))
|
||||
) {
|
||||
setHistoryBoundaryUnavailable(true);
|
||||
}
|
||||
}, [
|
||||
data?.historyTruncated,
|
||||
data?.historyUnavailable,
|
||||
data?.nextCursor,
|
||||
data?.threadId,
|
||||
threadId,
|
||||
]);
|
||||
}, [threadView]);
|
||||
|
||||
const loadTurnDetails = useCallback(
|
||||
async (detailTaskId: string) => {
|
||||
|
|
@ -410,7 +418,8 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
historyCursor !== undefined &&
|
||||
historyCursorGeneration !== requestedGeneration;
|
||||
const recoveringRebase = startsRebase || historyRebaseActive;
|
||||
const cursor = historyCursor === undefined || startsRebase ? data?.nextCursor : historyCursor;
|
||||
const cursor =
|
||||
historyCursor === undefined || startsRebase ? threadView?.nextCursor : historyCursor;
|
||||
const requestKey = `${requestedSelectionGeneration}\u0000${requestedThreadId}\u0000${cursor ?? ''}\u0000${requestedGeneration}`;
|
||||
if (cursor == null || historyState === 'loading' || historyRequestRef.current != null) {
|
||||
return;
|
||||
|
|
@ -501,7 +510,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
if (historyRequestRef.current === requestKey) historyRequestRef.current = null;
|
||||
}
|
||||
}, [
|
||||
data?.nextCursor,
|
||||
threadView?.nextCursor,
|
||||
historyCursor,
|
||||
historyCursorGeneration,
|
||||
historyRebaseActive,
|
||||
|
|
@ -566,7 +575,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
|
||||
useEffect(() => {
|
||||
if (transientControl == null) return;
|
||||
const durableReceipt = data?.controlReceipts?.find(
|
||||
const durableReceipt = taskView?.controlReceipts?.find(
|
||||
(receipt) => receipt.invocationId === transientControl.invocationId,
|
||||
);
|
||||
if (durableReceipt == null) return;
|
||||
|
|
@ -583,13 +592,13 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
}
|
||||
if (closesTaskControls(durableReceipt)) setControlsClosed(true);
|
||||
setControlState(null);
|
||||
}, [data?.controlReceipts, retryControl, setControlState, transientControl]);
|
||||
}, [retryControl, setControlState, taskView?.controlReceipts, transientControl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.controlReceipts?.some(closesTaskControls)) {
|
||||
if (taskView?.controlReceipts?.some(closesTaskControls)) {
|
||||
setControlsClosed(true);
|
||||
}
|
||||
}, [data?.controlReceipts]);
|
||||
}, [taskView?.controlReceipts]);
|
||||
|
||||
const submitControl = useCallback(
|
||||
(action: SubagentControlAction, controlId?: string, retry?: SubagentControlRequest) => {
|
||||
|
|
@ -707,14 +716,14 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
);
|
||||
const activity = useMemo(() => {
|
||||
if (selection.durable == null) return liveActivity;
|
||||
if (data == null) {
|
||||
if (taskView == null) {
|
||||
const activityWithoutData =
|
||||
progress == null ? { ...liveActivity, status: 'dispatched' as const } : liveActivity;
|
||||
return transientControl == null
|
||||
? activityWithoutData
|
||||
: { ...activityWithoutData, controls: [transientControl] };
|
||||
}
|
||||
const durable = adaptDurableThreadActivity(data, selection.durable.taskId);
|
||||
const durable = adaptDurableThreadActivity(taskView, selection.durable.taskId);
|
||||
const useLiveItems =
|
||||
(durable.status === 'running' || durable.status === 'dispatched') &&
|
||||
liveActivity.items.length > 0;
|
||||
|
|
@ -734,11 +743,11 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
return merged;
|
||||
}
|
||||
return { ...merged, controls: [...(merged.controls ?? []), transientControl] };
|
||||
}, [data, liveActivity, progress, selection.durable, transientControl]);
|
||||
}, [liveActivity, progress, selection.durable, taskView, transientControl]);
|
||||
const panelTitle = selection.event == null ? activity.title : selectedEventActorName;
|
||||
const latestConversationTurns = useMemo(
|
||||
() => (data == null ? [] : adaptDurableThreadConversation(data)),
|
||||
[data],
|
||||
() => (threadView == null ? [] : adaptDurableThreadConversation(threadView)),
|
||||
[threadView],
|
||||
);
|
||||
const previousLatestTurnsRef = useRef({
|
||||
threadId,
|
||||
|
|
@ -746,7 +755,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
turns: latestConversationTurns,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (threadView == null) return;
|
||||
const previous = previousLatestTurnsRef.current;
|
||||
if (previous.threadId === threadId) {
|
||||
const latestTaskIds = new Set(latestConversationTurns.map((turn) => turn.taskId));
|
||||
|
|
@ -766,15 +775,18 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
generation: latestHistoryGeneration,
|
||||
turns: latestConversationTurns,
|
||||
};
|
||||
}, [data, historyRebaseActive, latestConversationTurns, latestHistoryGeneration, threadId]);
|
||||
}, [historyRebaseActive, latestConversationTurns, latestHistoryGeneration, threadId, threadView]);
|
||||
const retainedTurnsValid = retainedTurnsGenerationRef.current === selectionGenerationRef.current;
|
||||
const conversationTurns = useMemo(() => {
|
||||
const durableTurns = mergeChildConversationTurns(
|
||||
olderTurns,
|
||||
movingWindowTurns,
|
||||
rebaseTurns,
|
||||
postRebaseTurns,
|
||||
latestConversationTurns,
|
||||
);
|
||||
const durableTurns = retainedTurnsValid
|
||||
? mergeChildConversationTurns(
|
||||
olderTurns,
|
||||
movingWindowTurns,
|
||||
rebaseTurns,
|
||||
postRebaseTurns,
|
||||
latestConversationTurns,
|
||||
)
|
||||
: mergeChildConversationTurns(latestConversationTurns);
|
||||
if (durableTurns.length > 0) {
|
||||
const selectedTurnIndex = durableTurns.findIndex((turn) => turn.taskId === taskId);
|
||||
if (selectedTurnIndex >= 0) {
|
||||
|
|
@ -824,6 +836,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
olderTurns,
|
||||
postRebaseTurns,
|
||||
rebaseTurns,
|
||||
retainedTurnsValid,
|
||||
selection,
|
||||
taskId,
|
||||
turnDetailOverrides,
|
||||
|
|
@ -836,19 +849,24 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
const historyCursorUsesLatest =
|
||||
!historyRebaseActive &&
|
||||
(historyCursor === undefined || historyCursorGeneration !== latestHistoryGeneration);
|
||||
const effectiveHistoryCursor = historyCursorUsesLatest ? data?.nextCursor : historyCursor;
|
||||
const effectiveHistoryCursor = historyCursorUsesLatest ? threadView?.nextCursor : historyCursor;
|
||||
const showUnavailableHistoryBoundary =
|
||||
historyBoundaryUnavailable ||
|
||||
data?.historyUnavailable === true ||
|
||||
(historyCursorUsesLatest && data?.historyTruncated === true && data.nextCursor == null);
|
||||
threadView?.historyUnavailable === true ||
|
||||
(historyCursorUsesLatest &&
|
||||
threadView?.historyTruncated === true &&
|
||||
threadView.nextCursor == null);
|
||||
/** During a rolling deployment an older API replica can omit `turns`. Keep
|
||||
* that response readable through the same deep activity renderer; every
|
||||
* current host otherwise enters the conversation-native rendering seam. */
|
||||
const hasConversationProjection =
|
||||
selection.durable == null || data == null || Array.isArray(data.turns);
|
||||
selection.durable == null || threadView == null || Array.isArray(threadView.turns);
|
||||
const taskInaccessible = controlInaccessible || transientControl?.reason === 'task_inaccessible';
|
||||
const controlAvailable =
|
||||
selection.durable != null && data?.status === 'running' && !taskInaccessible && !controlsClosed;
|
||||
selection.durable != null &&
|
||||
taskView?.status === 'running' &&
|
||||
!taskInaccessible &&
|
||||
!controlsClosed;
|
||||
const controlPending =
|
||||
controlTask.isLoading || transientControl?.status === 'submitted' || retryControl != null;
|
||||
const showControlFooter =
|
||||
|
|
@ -856,11 +874,11 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
const canContinueAsChat =
|
||||
selection.host === 'conversation' &&
|
||||
selection.durable != null &&
|
||||
data?.subagentKind === 'agent' &&
|
||||
data.agentId != null &&
|
||||
data.status === 'completed' &&
|
||||
subagentThreadHasTaskEvidence(data, taskId) &&
|
||||
data.messages.some((message) => message.messageId === `${taskId}:assistant`);
|
||||
taskView?.subagentKind === 'agent' &&
|
||||
taskView.agentId != null &&
|
||||
taskView.status === 'completed' &&
|
||||
subagentThreadHasTaskEvidence(taskView, taskId) &&
|
||||
taskView.messages.some((message) => message.messageId === `${taskId}:assistant`);
|
||||
|
||||
const continueAsChat = useCallback(() => {
|
||||
if (!canContinueAsChat || selection.durable == null) return;
|
||||
|
|
@ -998,7 +1016,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
)}
|
||||
<SubagentConversation
|
||||
turns={conversationTurns}
|
||||
agentId={data?.agentId}
|
||||
agentId={threadView?.agentId}
|
||||
conversationId={threadId || selection.parentConversationId}
|
||||
stateByTask={conversationStateByTask}
|
||||
controllableTaskId={
|
||||
|
|
|
|||
|
|
@ -1932,6 +1932,71 @@ describe('Message Operations', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps the budgeted timeline a contiguous newest suffix past an oversized entry', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await saveMessage(mockCtx, {
|
||||
messageId: 'task-suffix:assistant',
|
||||
conversationId,
|
||||
text: '',
|
||||
user: 'user123',
|
||||
content: [
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
type: 'text',
|
||||
text: `older-${index}`,
|
||||
})),
|
||||
...Array.from({ length: 8 }, (_, index) => ({
|
||||
type: 'text',
|
||||
text: `newest-${index}-${'b'.repeat(8_400)}`,
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
const messages = await getMessagesForSubagentThreadView({
|
||||
user: 'user123',
|
||||
conversationId,
|
||||
limit: 1,
|
||||
textCodePointLimit: 8_192,
|
||||
});
|
||||
|
||||
const activity = messages[0].subagentActivity as Array<{ type: string; text: string }>;
|
||||
expect(activity.length).toBeGreaterThan(0);
|
||||
expect(activity.length).toBeLessThan(8);
|
||||
expect(activity.every((item) => item.text.startsWith('newest-'))).toBe(true);
|
||||
expect(activity[activity.length - 1].text).toContain('newest-7-');
|
||||
expect(JSON.stringify(activity)).not.toContain('older-');
|
||||
expect(messages[0].subagentActivityProjectionTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it('fits ordinary persisted child activity into the aggregate byte budget, newest first', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await saveMessage(mockCtx, {
|
||||
messageId: 'task-budget:assistant',
|
||||
conversationId,
|
||||
text: '',
|
||||
user: 'user123',
|
||||
content: Array.from({ length: 12 }, (_, index) => ({
|
||||
type: 'text',
|
||||
text: `chunk-${index}-${'z'.repeat(8_000)}`,
|
||||
})),
|
||||
});
|
||||
|
||||
const messages = await getMessagesForSubagentThreadView({
|
||||
user: 'user123',
|
||||
conversationId,
|
||||
limit: 1,
|
||||
textCodePointLimit: 8_192,
|
||||
});
|
||||
|
||||
const activity = messages[0].subagentActivity as Array<{ type: string; text: string }>;
|
||||
expect(activity.length).toBeGreaterThan(0);
|
||||
expect(activity.length).toBeLessThan(12);
|
||||
expect(activity[activity.length - 1].text).toContain('chunk-11-');
|
||||
expect(activity[0].text).toContain(`chunk-${12 - activity.length}-`);
|
||||
const totalBytes = activity.reduce((sum, item) => sum + item.text.length, 0);
|
||||
expect(totalBytes).toBeLessThanOrEqual(64 * 1024);
|
||||
expect(messages[0].subagentActivityProjectionTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it('bounds ordinary persisted child activity before returning it to the API', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await saveMessage(mockCtx, {
|
||||
|
|
@ -1949,8 +2014,8 @@ describe('Message Operations', () => {
|
|||
tool_call: {
|
||||
id: 'move-1',
|
||||
name: 'submit_move',
|
||||
args: 'x'.repeat(2_000),
|
||||
output: 'y'.repeat(4_000),
|
||||
args: 'x'.repeat(3_000),
|
||||
output: 'y'.repeat(5_000),
|
||||
progress: 1,
|
||||
inputValidationError: true,
|
||||
},
|
||||
|
|
@ -1990,8 +2055,8 @@ describe('Message Operations', () => {
|
|||
outputTruncated: true,
|
||||
}),
|
||||
);
|
||||
expect(retainedTool.input.length).toBeLessThan(2_000);
|
||||
expect(retainedTool.output.length).toBeLessThan(4_000);
|
||||
expect(retainedTool.input.length).toBeLessThan(3_000);
|
||||
expect(retainedTool.output.length).toBeLessThan(5_000);
|
||||
const retainedLabel = messages[0].subagentActivity?.find(
|
||||
(activity) => (activity as { type?: string }).type === 'activity_label',
|
||||
) as { agentIds: string[]; labelTruncated: boolean; toolCallIds: string[] };
|
||||
|
|
@ -1999,8 +2064,8 @@ describe('Message Operations', () => {
|
|||
expect(retainedLabel.agentIds).toHaveLength(8);
|
||||
expect(retainedLabel.labelTruncated).toBe(true);
|
||||
expect(JSON.stringify(messages[0])).not.toContain('activity-0');
|
||||
expect(JSON.stringify(messages[0])).not.toContain('x'.repeat(1_000));
|
||||
expect(JSON.stringify(messages[0])).not.toContain('y'.repeat(2_000));
|
||||
expect(JSON.stringify(messages[0])).not.toContain('x'.repeat(2_049));
|
||||
expect(JSON.stringify(messages[0])).not.toContain('y'.repeat(4_097));
|
||||
});
|
||||
|
||||
it('bounds public control receipts before materializing the message page', async () => {
|
||||
|
|
|
|||
|
|
@ -347,13 +347,21 @@ const SUBAGENT_ACTIVITY_SOURCE_CANDIDATE_LIMIT = SUBAGENT_TRANSCRIPT_PAGE_LIMIT
|
|||
* an execution did not write a private subagent transcript. Project only the
|
||||
* visible activity vocabulary and bound it before MongoDB returns the row.
|
||||
*/
|
||||
export const SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT: number = 16;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_TEXT_CODE_POINT_LIMIT = 2048;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT = 512;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT = 1024;
|
||||
/**
|
||||
* Per-item bounds mirror `SUBAGENT_ACTIVITY_LIMITS` in `packages/api`
|
||||
* (`src/agents/activity.ts`) at worst-case 4-byte UTF-8, so activity projected
|
||||
* from ordinary message content is clipped no harder than activity projected
|
||||
* from a private transcript. The whole view stays bounded downstream by the
|
||||
* 64KB serialized-activity budget and the 256KB response trim.
|
||||
*/
|
||||
export const SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT: number = 100;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_TEXT_CODE_POINT_LIMIT = 8192;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT = 2048;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT = 4096;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_ID_CODE_POINT_LIMIT = 128;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_LABEL_CODE_POINT_LIMIT = 512;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_LABEL_IDS_LIMIT = 8;
|
||||
const SUBAGENT_MESSAGE_ACTIVITY_TOTAL_BYTE_LIMIT = 64 * 1024;
|
||||
const SUBAGENT_VIEW_CONTROL_RECEIPT_LIMIT = 32;
|
||||
const SUBAGENT_VIEW_CONTROL_STRING_CODE_POINT_LIMIT = 128;
|
||||
|
||||
|
|
@ -2290,7 +2298,130 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
null,
|
||||
],
|
||||
};
|
||||
const boundedActivityContent = {
|
||||
/** Mixed-typed content metadata must be type-gated before projection, or a
|
||||
* malformed part could tunnel arbitrarily large values past the byte cap
|
||||
* through a passthrough field. `$type`-based checks stay DocumentDB-safe. */
|
||||
const boundedNumber = (path: string) => ({
|
||||
$cond: [{ $in: [{ $type: path }, ['int', 'long', 'double', 'decimal']] }, path, null],
|
||||
});
|
||||
const boundedBoolean = (path: string) => ({
|
||||
$cond: [{ $eq: [{ $type: path }, 'bool'] }, path, null],
|
||||
});
|
||||
const boundedEnum = (path: string, allowed: string[]) => ({
|
||||
$cond: [{ $in: [path, allowed] }, path, null],
|
||||
});
|
||||
/** Estimated serialized bytes for one already-clipped activity item; the
|
||||
* constants cover the non-string JSON envelope per item type. This is a
|
||||
* BSON-transfer budget: `$strLenBytes` measures exactly what MongoDB
|
||||
* ships to the API. JSON escaping can expand strings after transfer,
|
||||
* which the API bounds precisely — `boundActivity` in
|
||||
* `packages/api/src/agents/activity.ts` re-fits the same array to 64KB
|
||||
* of `JSON.stringify` output before anything reaches a response. */
|
||||
const activityItemBytes = (item: string) => ({
|
||||
$switch: {
|
||||
branches: [
|
||||
{
|
||||
case: { $eq: [`${item}.type`, 'writing'] },
|
||||
then: { $add: [{ $strLenBytes: `${item}.text` }, 64] },
|
||||
},
|
||||
{
|
||||
case: { $eq: [`${item}.type`, 'activity_label'] },
|
||||
then: {
|
||||
$add: [
|
||||
{ $strLenBytes: `${item}.label` },
|
||||
{
|
||||
$reduce: {
|
||||
input: {
|
||||
$concatArrays: [
|
||||
{
|
||||
$cond: [{ $isArray: `${item}.toolCallIds` }, `${item}.toolCallIds`, []],
|
||||
},
|
||||
{ $cond: [{ $isArray: `${item}.agentIds` }, `${item}.agentIds`, []] },
|
||||
],
|
||||
},
|
||||
initialValue: 0,
|
||||
in: { $add: ['$$value', { $strLenBytes: '$$this' }, 8] },
|
||||
},
|
||||
},
|
||||
512,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
case: { $eq: [`${item}.type`, 'tool'] },
|
||||
then: {
|
||||
$add: [
|
||||
{ $strLenBytes: `${item}.input` },
|
||||
{ $strLenBytes: `${item}.output` },
|
||||
{ $strLenBytes: `${item}.name` },
|
||||
{ $strLenBytes: `${item}.toolCallId` },
|
||||
192,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
default: 32,
|
||||
},
|
||||
});
|
||||
/** Newest-first fit into the aggregate budget, so raising per-item limits
|
||||
* cannot multiply into megabytes per row before the API's own trims run. */
|
||||
const budgetedActivity = (clipped: Record<string, unknown>) => ({
|
||||
$reverseArray: {
|
||||
$let: {
|
||||
vars: {
|
||||
fitted: {
|
||||
$reduce: {
|
||||
input: { $reverseArray: clipped },
|
||||
initialValue: { items: [] as never[], bytes: 0, done: false },
|
||||
in: {
|
||||
$let: {
|
||||
vars: { size: activityItemBytes('$$this') },
|
||||
in: {
|
||||
$cond: [
|
||||
{
|
||||
$or: [
|
||||
'$$value.done',
|
||||
{
|
||||
$gt: [
|
||||
{ $add: ['$$value.bytes', '$$size'] },
|
||||
SUBAGENT_MESSAGE_ACTIVITY_TOTAL_BYTE_LIMIT,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
/* The retained timeline must be a contiguous newest
|
||||
suffix: once one entry does not fit, older entries
|
||||
are not allowed to fill the gap around it. */
|
||||
{ items: '$$value.items', bytes: '$$value.bytes', done: true },
|
||||
{
|
||||
items: { $concatArrays: ['$$value.items', ['$$this']] },
|
||||
bytes: { $add: ['$$value.bytes', '$$size'] },
|
||||
done: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
in: '$$fitted.items',
|
||||
},
|
||||
},
|
||||
});
|
||||
const activityBytesOverBudget = (clipped: Record<string, unknown>) => ({
|
||||
$gt: [
|
||||
{
|
||||
$reduce: {
|
||||
input: clipped,
|
||||
initialValue: 0,
|
||||
in: { $add: ['$$value', activityItemBytes('$$this')] },
|
||||
},
|
||||
},
|
||||
SUBAGENT_MESSAGE_ACTIVITY_TOTAL_BYTE_LIMIT,
|
||||
],
|
||||
});
|
||||
const clippedActivityContent = {
|
||||
$filter: {
|
||||
input: {
|
||||
$map: {
|
||||
|
|
@ -2330,14 +2461,14 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
'$$part.activity_label',
|
||||
SUBAGENT_MESSAGE_ACTIVITY_LABEL_CODE_POINT_LIMIT,
|
||||
),
|
||||
labelType: '$$part.activity_label_type',
|
||||
labelType: boundedEnum('$$part.activity_label_type', ['phase']),
|
||||
toolCallIds: boundedStringArray('$$part.tool_call_ids'),
|
||||
activityStartIndex: '$$part.activity_start_index',
|
||||
activityEndIndex: '$$part.activity_end_index',
|
||||
activityCount: '$$part.activity_count',
|
||||
activityStartIndex: boundedNumber('$$part.activity_start_index'),
|
||||
activityEndIndex: boundedNumber('$$part.activity_end_index'),
|
||||
activityCount: boundedNumber('$$part.activity_count'),
|
||||
agentIds: boundedStringArray('$$part.agent_ids'),
|
||||
status: '$$part.status',
|
||||
pending: '$$part.pending',
|
||||
status: boundedEnum('$$part.status', ['ok', 'partial', 'failed']),
|
||||
pending: boundedBoolean('$$part.pending'),
|
||||
labelTruncated: {
|
||||
$or: [
|
||||
stringProjectionTruncated(
|
||||
|
|
@ -2396,9 +2527,16 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
'$$part.tool_call.output',
|
||||
SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT,
|
||||
),
|
||||
progress: '$$part.tool_call.progress',
|
||||
runStepStatus: '$$part.tool_call.runStepStatus',
|
||||
inputValidationError: '$$part.tool_call.inputValidationError',
|
||||
progress: boundedNumber('$$part.tool_call.progress'),
|
||||
runStepStatus: boundedEnum('$$part.tool_call.runStepStatus', [
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]),
|
||||
inputValidationError: boundedBoolean(
|
||||
'$$part.tool_call.inputValidationError',
|
||||
),
|
||||
inputTruncated: stringProjectionTruncated(
|
||||
'$$part.tool_call.args',
|
||||
SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT,
|
||||
|
|
@ -2419,6 +2557,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
cond: { $ne: ['$$activity', null] },
|
||||
},
|
||||
};
|
||||
const boundedActivityContent = budgetedActivity(clippedActivityContent);
|
||||
type ActivitySourceProjection = WithNullSentinels<
|
||||
Pick<
|
||||
SubagentThreadViewMessageRecord,
|
||||
|
|
@ -2450,11 +2589,16 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
},
|
||||
subagentActivity: boundedActivityContent,
|
||||
subagentActivityProjectionTruncated: {
|
||||
$gt: [
|
||||
$or: [
|
||||
{
|
||||
$size: { $cond: [{ $isArray: '$content' }, '$content', []] },
|
||||
$gt: [
|
||||
{
|
||||
$size: { $cond: [{ $isArray: '$content' }, '$content', []] },
|
||||
},
|
||||
SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT,
|
||||
],
|
||||
},
|
||||
SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT,
|
||||
activityBytesOverBudget(clippedActivityContent),
|
||||
],
|
||||
},
|
||||
subagentTask: boundedSubagentTask,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue