Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions api/server/controllers/agents/callbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { logger } = require('@librechat/data-schemas');
const {
Tools,
StepTypes,
StepEvents,
FileContext,
ErrorTypes,
UsageEvents,
Expand All @@ -21,6 +22,7 @@ const {
createToolExecuteHandler,
HOST_FILE_AUTHORING_ARTIFACT_KEY,
isCodeSessionToolName,
shouldSignalSandboxStart,
} = require('@librechat/api');
const { processFileCitations } = require('~/server/services/Files/Citations');
const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
Expand Down Expand Up @@ -228,6 +230,37 @@ async function emitEvent(res, streamId, eventData) {
}
}

/**
* Emits `on_sandbox_starting` for each code-execution tool call in the run
* step when the conversation's stateful sandbox is still cold-booting, so the
* UI can explain the first call's boot latency instead of showing a generic
* running state. Only signals while a fired prewarm remains unresolved
* ({@link shouldSignalSandboxStart}); stateless deployments never fire one
* and completed boots clear the marker, so both stay on the generic label.
* @param {ServerResponse} res - The server response object
* @param {string | null} streamId - The stream ID for resumable mode, or null for standard mode
* @param {StreamEventData} data - The `on_run_step` event data
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata
* @returns {Promise<void>}
*/
async function maybeEmitSandboxStarting(res, streamId, data, metadata) {
const conversationId = metadata?.thread_id;
if (!conversationId || !(await shouldSignalSandboxStart(conversationId))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail closed on sandbox-start cache errors

When Redis/Keyv is configured and the sandbox prewarm cache read rejects, this awaited shouldSignalSandboxStart call propagates out of the ON_RUN_STEP handler, so a transient cache issue in a nonessential UX signal can interrupt the tool-call stream after the run step is emitted. Since the sandbox-starting event is cosmetic, wrap this check in a try/catch and skip the signal on cache failure rather than letting the chat run fail.

Useful? React with 👍 / 👎.

return;
}
Comment on lines +246 to +250

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Surface sandbox starts from subagent updates

When the first stateful code call happens inside a subagent, the child run-step is forwarded as an ON_SUBAGENT_UPDATE envelope (handled later in this file), not through this top-level ON_RUN_STEP helper, so no on_sandbox_starting event is emitted for that tool call. Because the prewarm gate explicitly walks subagent configs, delegated code use can still hit the cold-boot path while the UI keeps showing only generic subagent/tool progress. Please inspect run_step subagent envelopes and forward equivalent sandbox-starting state for their code tool calls.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring (raised in the previous round as well): subagent code steps render via the SubagentCall ticker (folded content parts), which has no consumer for this atom — forwarding the event without that renderer integration is inert. Tracked as a follow-up together with the file-authoring renderer below.

const toolCalls = data?.stepDetails?.tool_calls ?? [];
for (const toolCall of toolCalls) {
const name = toolCall?.name ?? toolCall?.function?.name;
Comment on lines +251 to +253

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Forward sandbox-starting for subagent code calls

When the code tool runs inside a subagent, its run-step is delivered through the visible ON_SUBAGENT_UPDATE path (phase: 'run_step', with the nested step under data.data as handled by subagentPhaseToGraphEvent), but only top-level ON_RUN_STEP payloads call this helper. In conversations where the stateful code tool is reachable only from a subagent, prewarm still runs because the gate walks subagents, yet the nested ExecuteCode/BashCall card never receives on_sandbox_starting, so the new cold-boot UX feedback is missing exactly for those subagent tool calls.

Useful? React with 👍 / 👎.

if (!toolCall?.id || name == null || !isCodeSessionToolName(name)) {
continue;
Comment on lines +254 to +255

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Include sandbox file-authoring cold starts

For a stateful run whose first sandbox-bound action is create_file or edit_file, this filter never emits the cold-boot event because isCodeSessionToolName(name) is called without the host file-authoring tool set, so it only matches the Code execution tools. Those host tools are explicitly registered as codeSessionToolNames and refreshed with markSandboxReady after success, meaning they can pay the same first sandbox boot but keep showing only the generic creating/editing label. Please include the sandbox-routed file-authoring names in this signal path as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid nuance, deferring for the same renderer-consumer reason as the subagent thread: file-authoring calls render via FileAuthoringCall, which doesn't consume the sandbox-starting atom, so emitting for those names alone changes nothing visible. The right follow-up extends both the signal (pass the host file-authoring tool set to isCodeSessionToolName here) and the FileAuthoringCall/SubagentCall renderers together. Scoped v1 to the execute_code/bash_tool cards deliberately.

}
await emitEvent(res, streamId, {
event: StepEvents.ON_SANDBOX_STARTING,
data: { tool_call_id: toolCall.id, runId: metadata?.run_id },
Comment on lines +258 to +259

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Persist sandbox-starting events for resume

In resumable/Redis streams, this new UI-only event is not represented in aggregated message content, and GenerationJobManager.trackReplayEvent only persists OAuth replay events. If a client reconnects or refreshes while the sandbox is still cold-booting, the sync payload can replay the run step but drops on_sandbox_starting, so the code card falls back to the generic running label for exactly the cold-start gap this event is meant to explain; please add this event to the replay/pending-event path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, but leaving as-is intentionally: on_sandbox_starting is ephemeral status scoped to the live stream. Persisting it into the replay path would need expiry logic to avoid the opposite bug — replaying a stale "starting" label after the boot already finished — and the cost of dropping it on reconnect is exactly the pre-PR behavior (generic running label) for a few seconds in a rare race. Happy to revisit if resumable streams grow a TTL'd transient-event channel.

Comment on lines +257 to +259

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Persist sandbox-starting events for resume

This emits on_sandbox_starting only as a live stream event. In the resumable path, replay state is limited to persisted content plus explicit replay events, and this new event is neither content nor added to the replay set, so a tab reload/reconnect after the cold-boot event but before tool completion reconstructs the in-flight tool card without the sandbox-starting label.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same rationale as the round-1 thread on this line: the event is intentionally ephemeral live-stream status. Persisting it for replay needs TTL/invalidation to avoid replaying a stale 'starting' label after the boot completed, and the reconnect-during-boot gap degrades to the pre-PR generic label for a few seconds. Deferring until resumable streams have a transient-event channel.

Comment on lines +258 to +259

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Persist sandbox-starting state across resumes

In resumable/Redis streams, emitChunk only reconstructs run steps/content on sync; UI-only events have to be carried through the replay path (GenerationJobManager.trackReplayEvent, which currently only keeps OAuth replay events). Emitting on_sandbox_starting only as a normal chunk means that if the tab reloads or reconnects after this event but before on_run_step_completed, the tool card is rebuilt without setting the sandbox-starting atom, so the cold-boot feedback disappears during the long boot. Please add this event to the replay/resume path or derive the flag during sync.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring (third raise of this item — see the two earlier threads on this line for full rationale): the event is intentionally ephemeral live-stream status, and persisting it for replay without TTL/invalidation would replay stale 'starting' labels after boot completion. Reconnect-during-boot degrades to the pre-PR generic label. Will revisit when resumable streams have a transient-event channel.

Comment on lines +257 to +259

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Replay sandbox-starting events for resumable streams

When this event goes through GenerationJobManager (streamId set), it is not represented in runSteps or aggregated content, and the manager's replay path currently persists only OAuth replay events. In Redis resumable mode, a client that refreshes or reconnects after this emit but before the tool completes will never receive the sandbox-starting atom update, so the new cold-boot UX silently falls back to the generic running label. Please persist/replay this transient event or include equivalent state in the resume payload.

Useful? React with 👍 / 👎.

});
}
}

/**
* Maps a {@link SubagentUpdateEvent} phase to the corresponding
* {@link GraphEvents} name that the SDK's `createContentAggregator`
Expand Down Expand Up @@ -363,6 +396,7 @@ function getDefaultHandlers({
aggregateContent({ event, data });
if (data?.stepDetails.type === StepTypes.TOOL_CALLS) {
await emitEvent(res, streamId, { event, data });
await maybeEmitSandboxStarting(res, streamId, data, metadata);
Comment on lines 398 to +399

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Emit sandbox-starting for subagent code steps

This only adds the cold-boot event on top-level ON_RUN_STEP events. Code calls made inside a subagent arrive through the ON_SUBAGENT_UPDATE handler as phase: 'run_step' envelopes, so their execute_code/bash_tool cards never receive on_sandbox_starting and continue to show the generic running label during the first cold boot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this one: subagent code steps render through the SubagentCall ticker/dialog (folded content parts), not the atom-driven ExecuteCode/BashCall cards, so emitting the event for child steps has no consumer without a separate ticker integration. The cold-boot cost is also typically already paid by the parent conversation's prewarm before a subagent spawns. Happy to do the ticker integration as a follow-up if desired.

} else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) {
await emitEvent(res, streamId, { event, data });
} else if (!metadata?.hide_sequential_outputs) {
Expand Down
11 changes: 11 additions & 0 deletions api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const {
appendYouTubeVideoParts,
resolveYouTubeInjectionConfig,
decrementPendingRequest,
maybePrewarmCodeSandbox,
} = require('@librechat/api');
const {
Callback,
Expand Down Expand Up @@ -1386,6 +1387,16 @@ class AgentClient extends BaseClient {
abortController = new AbortController();
}

/** Fire-and-forget: boot the per-conversation stateful sandbox in
* parallel with generation so the first execute_code/bash call lands
* on a warm VM. No-op unless a reachable agent resolved
* `statefulCodeSessions`. */
maybePrewarmCodeSandbox({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Prewarm resumed HITL runs

This prewarm only runs on the fresh chatCompletion path; resumeCompletion rebuilds and resumes the graph without setting an in-flight marker or starting a new prewarm. If a run pauses for approval/ask before a code tool and the user resumes after the sandbox cold window, the approved tool cold-boots with only the generic running label, so the new UX does not cover a normal HITL resume path.

Useful? React with 👍 / 👎.

req: this.options.req,
conversationId: this.conversationId,
agents: [this.options.agent, ...(this.agentConfigs?.values() ?? [])],
});

/** @type {AppConfig['endpoints']['agents']} */
const agentsEConfig = appConfig.endpoints?.[EModelEndpoint.agents];

Expand Down
5 changes: 5 additions & 0 deletions client/src/components/Chat/Messages/Content/Part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ const Part = memo(function Part({
const isToolCall =
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
if (isToolCall) {
const toolCallId =
'id' in toolCall && typeof toolCall.id === 'string' ? toolCall.id : undefined;
const card = (() => {
if (isBashProgrammaticToolCall(toolCall.name, toolCall.args)) {
return (
Expand All @@ -163,6 +165,7 @@ const Part = memo(function Part({
commandField="code"
hideAttachments={hideAttachments}
onExpand={onToolExpand}
toolCallId={toolCallId}
/>
);
} else if (
Expand All @@ -179,6 +182,7 @@ const Part = memo(function Part({
args={toolCall.args}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
toolCallId={toolCallId}
/>
);
} else if (
Expand Down Expand Up @@ -279,6 +283,7 @@ const Part = memo(function Part({
attachments={attachments}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
toolCallId={toolCallId}
/>
);
} else if (toolCall.name === Tools.web_search) {
Expand Down
23 changes: 17 additions & 6 deletions client/src/components/Chat/Messages/Content/Parts/BashCall.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import copy from 'copy-to-clipboard';
import { useRecoilValue } from 'recoil';
import type { TAttachment } from 'librechat-data-provider';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import parseJsonField, { areToolCallArgsComplete } from './parseJsonField';
import CopyButton from '~/components/Messages/Content/CopyButton';
import LangIcon from '~/components/Messages/Content/LangIcon';
import { sandboxStartingByToolCallId } from '~/store';
import useToolCallState from './useToolCallState';
import useLazyHighlight from './useLazyHighlight';
import { ERROR_PATTERNS } from './ExecuteCode';
import { AttachmentGroup } from './Attachment';
import parseJsonField, { areToolCallArgsComplete } from './parseJsonField';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';

Expand All @@ -21,6 +23,7 @@ export default function BashCall({
commandField = 'command',
hideAttachments = false,
onExpand,
toolCallId,
}: {
initialProgress: number;
isSubmitting: boolean;
Expand All @@ -30,10 +33,12 @@ export default function BashCall({
commandField?: string;
hideAttachments?: boolean;
onExpand?: () => void;
toolCallId?: string;
}) {
const localize = useLocalize();
const command = useMemo(() => parseJsonField(args, commandField), [args, commandField]);
const isWritingCommand = !command || !areToolCallArgsComplete(args);
const sandboxStarting = useRecoilValue(sandboxStartingByToolCallId(toolCallId ?? ''));

const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
useToolCallState(initialProgress, isSubmitting, output, !!command, onExpand);
Expand All @@ -52,17 +57,23 @@ export default function BashCall({
timerRef.current = setTimeout(() => setIsCopied(false), 3000);
}, [command]);

const inProgressText = (() => {
if (isWritingCommand) {
return localize('com_ui_writing_command');
}
if (sandboxStarting) {
return localize('com_ui_sandbox_starting');
}
return localize('com_ui_running_command');
})();

return (
<>
<div className="relative my-1.5 flex size-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={toggleCode}
inProgressText={
isWritingCommand
? localize('com_ui_writing_command')
: localize('com_ui_running_command')
}
inProgressText={inProgressText}
finishedText={
cancelled ? localize('com_ui_cancelled') : localize('com_ui_command_finished')
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { SquareTerminal } from 'lucide-react';
import type { TAttachment } from 'librechat-data-provider';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import { sandboxStartingByToolCallId } from '~/store';
import useLazyHighlight from './useLazyHighlight';
import useToolCallState from './useToolCallState';
import CodeWindowHeader from './CodeWindowHeader';
Expand Down Expand Up @@ -58,6 +60,7 @@ export default function ExecuteCode({
attachments,
hideAttachments = false,
onExpand,
toolCallId,
}: {
initialProgress: number;
isSubmitting: boolean;
Expand All @@ -66,9 +69,11 @@ export default function ExecuteCode({
attachments?: TAttachment[];
hideAttachments?: boolean;
onExpand?: () => void;
toolCallId?: string;
}) {
const localize = useLocalize();
const { lang = 'py', code } = useParseArgs(args) ?? ({} as ParsedArgs);
const sandboxStarting = useRecoilValue(sandboxStartingByToolCallId(toolCallId ?? ''));

const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
useToolCallState(initialProgress, isSubmitting, output, !!code, onExpand);
Expand All @@ -82,7 +87,9 @@ export default function ExecuteCode({
<ProgressText
progress={progress}
onClick={toggleCode}
inProgressText={localize('com_ui_analyzing')}
inProgressText={
sandboxStarting ? localize('com_ui_sandbox_starting') : localize('com_ui_analyzing')
}
finishedText={
cancelled ? localize('com_ui_cancelled') : localize('com_ui_analyzing_finished')
}
Expand Down
53 changes: 50 additions & 3 deletions client/src/hooks/SSE/useStepHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
SummaryContentPart,
TMessageContentParts,
SubagentUpdateEvent,
SandboxStartingEvent,
} from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { AnnounceOptions } from '~/common';
Expand All @@ -26,8 +27,8 @@ import {
initSubagentAggregatorState,
initSubagentTickerState,
} from '~/utils/subagentContent';
import { subagentProgressByToolCallId, sandboxStartingByToolCallId } from '~/store';
import { isAskUserQuestionPart } from '~/utils/approval';
import { subagentProgressByToolCallId } from '~/store';
import { MESSAGE_UPDATE_INTERVAL } from '~/common';

type TUseStepHandler = {
Expand Down Expand Up @@ -55,7 +56,8 @@ type TStepEvent =
| { event: StepEvents.ON_SUMMARIZE_START; data: Agents.SummarizeStartEvent }
| { event: StepEvents.ON_SUMMARIZE_DELTA; data: Agents.SummarizeDeltaEvent }
| { event: StepEvents.ON_SUMMARIZE_COMPLETE; data: Agents.SummarizeCompleteEvent }
| { event: StepEvents.ON_SUBAGENT_UPDATE; data: SubagentUpdateEvent };
| { event: StepEvents.ON_SUBAGENT_UPDATE; data: SubagentUpdateEvent }
| { event: StepEvents.ON_SANDBOX_STARTING; data: SandboxStartingEvent };

type MessageDeltaUpdate = { type: ContentTypes.TEXT; text: string; tool_call_ids?: string[] };

Expand Down Expand Up @@ -284,6 +286,41 @@ export default function useStepHandler({
[],
);

/** Tool-call ids whose sandbox-starting atom is set, so completion can clear them. */
const knownSandboxAtomKeys = useRef(new Set<string>());

const setSandboxStarting = useRecoilCallback(
({ set }) =>
(toolCallId: string): void => {
knownSandboxAtomKeys.current.add(toolCallId);
set(sandboxStartingByToolCallId(toolCallId), true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Reset sandbox-starting atoms on cleanup

When a stream is cancelled, errors, or the user switches conversations before ON_RUN_STEP_COMPLETED, this setter has already created a sandboxStartingByToolCallId atom and recorded its key, but clearStepMaps and the conversation-switch cleanup never reset knownSandboxAtomKeys. That leaves one atom alive per interrupted code call and can show a stale “Starting sandbox environment” if a later provider reuses the same tool_call_id (for example call_0); please clear these sandbox keys at the same cleanup boundary used for the step maps.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 26f5665 — added resetSandboxAtoms and invoked it in clearStepMaps (the same cleanup boundary as the step maps). Unlike the subagent atoms deliberately preserved there for post-run auditing, these flags are transient status, so an interrupted cold boot can no longer leak a stale label onto a reused tool_call_id.

Comment on lines +293 to +296

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Clear sandbox-starting atoms on stream teardown

When a standard SSE stream is closed or the user navigates away while the sandbox-starting flag is set, this atom can survive because it is keyed only by tool_call_id and is reset only by tool completion or clearStepMaps; the non-resumable useSSE cleanup path dispatches cancel without calling clearStepMaps, and the hook unmount cleanup only resets subagent atoms. If a later tool call reuses a common id such as call_0, its card can incorrectly start with the stale “Starting sandbox environment” label even in another conversation.

Useful? React with 👍 / 👎.

},
[],
);

const clearSandboxStarting = useRecoilCallback(
({ reset }) =>
(toolCallId?: string | null): void => {
if (!toolCallId || !knownSandboxAtomKeys.current.has(toolCallId)) {
return;
}
knownSandboxAtomKeys.current.delete(toolCallId);
reset(sandboxStartingByToolCallId(toolCallId));
},
[],
);

const resetSandboxAtoms = useRecoilCallback(
({ reset }) =>
(): void => {
for (const toolCallId of knownSandboxAtomKeys.current) {
reset(sandboxStartingByToolCallId(toolCallId));
}
knownSandboxAtomKeys.current.clear();
},
[],
);

/**
* Calculate content index for a run step.
* For edited content scenarios, offset by initialContent length.
Expand Down Expand Up @@ -934,6 +971,7 @@ export default function useStepHandler({
const { result } = stepEvent.data;

const { id: stepId } = result;
clearSandboxStarting(result.tool_call?.id);

const runStep = stepMap.current.get(stepId);
let responseMessageId = runStep?.runId ?? '';
Expand Down Expand Up @@ -977,6 +1015,8 @@ export default function useStepHandler({
}),
);
}
} else if (stepEvent.event === StepEvents.ON_SANDBOX_STARTING) {
setSandboxStarting(stepEvent.data.tool_call_id);
Comment on lines +1018 to +1019

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Scope sandbox-starting state beyond tool IDs

Using only tool_call_id as the Recoil key lets independent tool calls collide when providers or parallel agents reuse IDs such as call_0; setting or clearing the flag for one call then affects every rendered code/bash card with the same id. This is especially visible when two parallel agent/tool steps overlap and the first completion clears the label for the second while its sandbox is still starting. Please include a step/message/run scoped component in the key (and in the backend event if needed) instead of keying solely by tool id.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Key sandbox-start state by run

In parallel or subagent runs where separate model calls can reuse per-run tool IDs such as call_0, this drops the runId that the backend sends and keys Recoil state only by tool_call_id. One on_sandbox_starting event can therefore flip every visible code card with the same id, and the first completion can clear the label for another still-booting tool; include the run/step identity in the atom key and pass that same key to the card.

Useful? React with 👍 / 👎.

} else if (stepEvent.event === StepEvents.ON_SUBAGENT_UPDATE) {
applySubagentUpdate(stepEvent.data);
} else if (stepEvent.event === StepEvents.ON_SUMMARIZE_START) {
Expand Down Expand Up @@ -1082,6 +1122,8 @@ export default function useStepHandler({
calculateContentIndex,
getCurrentMessages,
applySubagentUpdate,
setSandboxStarting,
clearSandboxStarting,
onSkillAuthoringComplete,
],
);
Expand All @@ -1098,6 +1140,11 @@ export default function useStepHandler({
subagentRunToToolCallId.current.clear();
claimedSubagentToolCallIds.current.clear();
pendingSubagentBuffer.current.clear();
/** Unlike subagent atoms below, sandbox-starting flags are transient
* status with no audit value — reset them at this boundary so an
* interrupted cold boot can't leak a stale "starting" label onto a
* later tool call that reuses the same id (e.g. `call_0`). */
resetSandboxAtoms();
/** Intentionally NOT calling `resetSubagentAtoms()` here — users need
* to be able to reopen the SubagentCall dialog after completion to
* audit what the child did. `resetSubagentAtoms` is returned below
Expand All @@ -1106,7 +1153,7 @@ export default function useStepHandler({
* persisted `subagent_content` takes over for historical messages
* once the conversation is saved, and we prevent unbounded
* atomFamily growth across multi-conversation sessions. */
}, []);
}, [resetSandboxAtoms]);

/**
* Sync a message into the step handler's messageMap.
Expand Down
1 change: 1 addition & 0 deletions client/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1639,6 +1639,7 @@
"com_ui_running": "Running...",
"com_ui_running_command": "Running command",
"com_ui_running_n_agents": "Running {{0}} agents",
"com_ui_sandbox_starting": "Starting sandbox environment",
"com_ui_save": "Save",
"com_ui_save_badge_changes": "Save badge changes?",
"com_ui_save_changes": "Save Changes",
Expand Down
1 change: 1 addition & 0 deletions client/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from './agents';
export * from './mcp';
export * from './favorites';
export * from './subagents';
export * from './sandbox';
export * from './usage';

export default {
Expand Down
13 changes: 13 additions & 0 deletions client/src/store/sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { atomFamily } from 'recoil';

/**
* True while the backend reported the stateful code sandbox is cold-booting
* for this tool call (`on_sandbox_starting` SSE event). Keyed by
* `tool_call_id`; `ExecuteCode`/`BashCall` swap their in-progress label to a
* "starting sandbox" message while set. Cleared when the tool call's run
* step completes.
*/
export const sandboxStartingByToolCallId = atomFamily<boolean, string>({
key: 'sandboxStartingByToolCallId',
default: false,
});
Loading
Loading