🧵 feat: Native Background Execution for Code Interpreter Tools (#14386)

* 🧵 feat: Native Background Execution for Code Interpreter Tools

* 🩹 fix: Address Codex Round 1 (fallback dedupe, harvest failure, handle parsing)

* 🩹 fix: Live Completion Marker + Unkeyed Attachment Dedupe (Codex Round 2)

* 🎨 chore: Sort Imports + Widen Marker Type Comparison (CI)

* 🩹 fix: Stale-Harvest Guard, Error Marker Status, Faster Anchor Retry (Codex Round 3)

* 🧹 refactor: TS Harvest Module, Claim-Neutral Timestamps, Error Parity (Codex Round 4)

* 🩹 fix: Dispatch-Ordered Stale Guard, Foreground Downgrade, Error Wrapper Parity (Codex Round 5)

* 🩹 fix: Retry Past Unfinished Rows + Per-Call Attachment Dedupe (Codex Round 6)

* 🩹 fix: Writer-Dispatch Ordering, Scoped Live Upserts, Reaped-Task Wrapper (Codex Round 7)

* 🩹 fix: Wildcard toolCallId Matching for Bare Attachment Updates (CI)

* 🩹 fix: Claim-Insert Dispatch Stamp (Schema-Backed) + Scoped Status Markers (Codex Round 8)

* 🩹 fix: Pre-Write Ownership CAS + Agent-Scoped Part Patching (Codex Round 9)

* 🩹 fix: Insert-Path Ownership CAS + Agent-Routed Attachments (Codex Round 10)

* 🩹 fix: Agent-Scoped Marker Ids and Attachment Dedupe (Codex Round 11)

* 🩹 fix: Atomic File Commit and Sibling Preview Fan-Out (Codex Round 12)

- Replace the two-step claim-confirm CAS with an atomic conditional updateFile: the ownership predicate (no sourceDispatchedAt, or <= this write's dispatch order) moves into the update filter, removing confirmCodeFileOwnership and the lost-update window between check and write
- Thread agentId through createDownloadFallback so fallback download rows scope to the emitting agent like primary rows
- Fan terminal preview overlays out to every live attachment sharing the file_id in useAttachmentPreviewSync (sibling tool calls no longer stick on pending)
- Restore background artifacts through toStoredArtifact so the size bound applies on re-anchor
- Apply filterAttachmentsForPart to grouped tool-call attachments in ContentParts so handoff agents with colliding provider call ids do not cross-contaminate groups

* 🩹 fix: Agent-Scoped Live Upserts and Monotonic Dispatch Stamps (Codex Round 13)

- Scope the SSE attachment upsert and the useAttachments DB/live merge by agentId with the same wildcard semantics as toolCallId: distinct non-null agentIds stay separate entries, so handoff agents sharing a claimed file_id and a repeated provider tool id (call_0) no longer merge over each other's cards
- Extend the attachment identity key to fileKey::toolCallId::agentId and register less-specific key variants so bare and agent-less live records still dedupe after overlay
- Stamp background task createdAt from a strictly-increasing per-process dispatch counter: raw Date.now() can tie for same-millisecond dispatches and the stale-output guard accepts equal stamps (needed for idempotent re-commits), which would let an older task overwrite a newer task's committed file
This commit is contained in:
Danny Avila 2026-07-22 22:13:15 -04:00 committed by GitHub
parent 5af12c722e
commit 00c5a747e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3278 additions and 194 deletions

View file

@ -0,0 +1,194 @@
jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
runPreviewFinalize: jest.fn(),
}));
jest.mock('~/server/services/Files/Citations', () => ({ processFileCitations: jest.fn() }));
jest.mock('~/server/services/Files/process', () => ({ saveBase64Image: jest.fn() }));
const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
const { createBackgroundCodeResultHandler } = require('./callbacks');
const req = { user: { id: 'user-1' } };
const baseParams = {
toolName: 'execute_code',
toolCallId: 'call_code',
messageId: 'msg-dispatch',
conversationId: 'convo-1',
agentId: 'agent_a',
output: 'stdout:\nhello',
artifact: {
session_id: 'exec-sess',
files: [
{ id: 'f1', name: 'plot.png', storage_session_id: 'store-1' },
{ id: 'f2', name: 'input.csv', inherited: true },
],
},
};
describe('createBackgroundCodeResultHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('persists non-inherited files with the original identity and patches the message row', async () => {
processCodeOutput.mockResolvedValue({
file: { file_id: 'f1', filename: 'plot.png', toolCallId: 'call_code' },
finalize: undefined,
});
const updateToolCallResult = jest.fn().mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const result = await handler(baseParams);
expect(processCodeOutput).toHaveBeenCalledTimes(1);
expect(processCodeOutput).toHaveBeenCalledWith(
expect.objectContaining({
req,
id: 'f1',
name: 'plot.png',
messageId: 'msg-dispatch',
toolCallId: 'call_code',
conversationId: 'convo-1',
agentId: 'agent_a',
session_id: 'store-1',
freshClaimAfter: expect.any(Number),
}),
);
expect(updateToolCallResult).toHaveBeenCalledTimes(1);
expect(updateToolCallResult).toHaveBeenCalledWith({
userId: 'user-1',
messageId: 'msg-dispatch',
conversationId: 'convo-1',
toolCallId: 'call_code',
agentId: 'agent_a',
output: 'stdout:\nhello',
attachments: [{ file_id: 'f1', filename: 'plot.png', toolCallId: 'call_code' }],
});
expect(result).toEqual({
attachments: [{ file_id: 'f1', filename: 'plot.png', toolCallId: 'call_code' }],
});
});
it('anchors the stale-output guard to dispatch time when provided', async () => {
processCodeOutput.mockResolvedValue({ file: { file_id: 'f1' } });
const handler = createBackgroundCodeResultHandler({
req,
updateToolCallResult: jest.fn().mockResolvedValue({ matched: true, unfinished: false }),
});
await handler({ ...baseParams, dispatchedAt: 12345 });
expect(processCodeOutput).toHaveBeenCalledWith(
expect.objectContaining({ freshClaimAfter: 12345 }),
);
});
it('runs deferred preview finalization without a live stream callback', async () => {
const finalize = jest.fn();
processCodeOutput.mockResolvedValue({
file: { file_id: 'f1' },
finalize,
previewRevision: 3,
});
const handler = createBackgroundCodeResultHandler({
req,
updateToolCallResult: jest.fn().mockResolvedValue({ matched: true, unfinished: false }),
});
await handler(baseParams);
expect(runPreviewFinalize).toHaveBeenCalledWith({ finalize, fileId: 'f1', previewRevision: 3 });
});
it('retries the row patch until the dispatch turn persists', async () => {
jest.useFakeTimers();
try {
processCodeOutput.mockResolvedValue({ file: { file_id: 'f1' } });
const updateToolCallResult = jest
.fn()
.mockResolvedValueOnce({ matched: false, unfinished: false })
.mockResolvedValueOnce({ matched: false, unfinished: false })
.mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const promise = handler(baseParams);
await jest.advanceTimersByTimeAsync(250);
await jest.advanceTimersByTimeAsync(500);
const result = await promise;
expect(updateToolCallResult).toHaveBeenCalledTimes(3);
expect(result?.attachments).toHaveLength(1);
} finally {
jest.useRealTimers();
}
});
it('keeps re-applying past unfinished partial rows until a finalized row is patched', async () => {
jest.useFakeTimers();
try {
processCodeOutput.mockResolvedValue({ file: { file_id: 'f1' } });
/* A disconnect mid-turn persists an unfinished partial row; the later
* finalize save overwrites it with the in-memory handle JSON, so a
* patch that settled on the partial row must not stop the loop. */
const updateToolCallResult = jest
.fn()
.mockResolvedValueOnce({ matched: true, unfinished: true })
.mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const promise = handler(baseParams);
await jest.advanceTimersByTimeAsync(250);
const result = await promise;
expect(updateToolCallResult).toHaveBeenCalledTimes(2);
expect(result?.attachments).toHaveLength(1);
} finally {
jest.useRealTimers();
}
});
it('still patches output when a file download fails (files are best-effort)', async () => {
processCodeOutput.mockRejectedValue(new Error('download failed'));
const updateToolCallResult = jest.fn().mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const result = await handler(baseParams);
expect(updateToolCallResult).toHaveBeenCalledWith(
expect.objectContaining({ output: 'stdout:\nhello', attachments: [] }),
);
expect(result).toEqual({ attachments: [] });
});
it('reapply mode re-applies the row patch without reprocessing files', async () => {
const updateToolCallResult = jest.fn().mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const result = await handler({
...baseParams,
artifact: undefined,
attachments: [{ file_id: 'f1' }],
reapply: true,
});
expect(processCodeOutput).not.toHaveBeenCalled();
expect(updateToolCallResult).toHaveBeenCalledTimes(1);
expect(updateToolCallResult).toHaveBeenCalledWith(
expect.objectContaining({
messageId: 'msg-dispatch',
toolCallId: 'call_code',
output: 'stdout:\nhello',
attachments: [{ file_id: 'f1' }],
}),
);
expect(result).toEqual({ attachments: [{ file_id: 'f1' }] });
});
it('returns null without identity to anchor to', async () => {
const updateToolCallResult = jest.fn();
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
expect(await handler({ ...baseParams, messageId: undefined })).toBeNull();
expect(updateToolCallResult).not.toHaveBeenCalled();
});
});

View file

@ -20,6 +20,7 @@ const {
GenerationJobManager,
writeAttachmentEvent,
createToolExecuteHandler,
createBackgroundCodeResultHandler: createCodeHarvestHandler,
HOST_FILE_AUTHORING_ARTIFACT_KEY,
isCodeSessionToolName,
shouldSignalSandboxStart,
@ -974,6 +975,55 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null })
};
}
/**
* Emitter for `attachment` SSE events on the current request's live stream,
* for re-emitting background-harvested attachments on a poll turn. Safe to
* call after the stream closes (silently dropped).
*
* @param {Object} params
* @param {ServerResponse} params.res
* @param {string | null} [params.streamId]
* @returns {(attachment: Object) => void}
*/
function createAttachmentEmitter({ res, streamId = null }) {
return (attachment) => {
if (!attachment || !isStreamWritable(res, streamId)) {
return;
}
writeAttachment(res, streamId, attachment);
};
}
/**
* Leading sub-second retries cover the common case of a fast background task
* settling moments before the dispatch turn finalizes its message row an
* immediate follow-up turn should find the attachments already anchored.
* The long tail covers dispatch turns that keep running for minutes.
*/
/**
* Thin wrapper binding the host file services into the TS harvest
* implementation (`@librechat/api` `createBackgroundCodeResultHandler`).
*
* @param {Object} params
* @param {ServerRequest} params.req
* @param {(params: {
* userId: string;
* messageId: string;
* conversationId: string;
* toolCallId: string;
* output?: string;
* attachments?: Object[];
* }) => Promise<boolean>} params.updateToolCallResult
*/
function createBackgroundCodeResultHandler({ req, updateToolCallResult }) {
return createCodeHarvestHandler({
req,
updateToolCallResult,
processCodeOutput,
runPreviewFinalize,
});
}
/**
* Helper to write attachment events in Open Responses format (librechat:attachment)
* @param {ServerResponse} res - The server response object
@ -1310,6 +1360,8 @@ module.exports = {
agentLogHandlerObj,
getDefaultHandlers,
createToolEndCallback,
createAttachmentEmitter,
createBackgroundCodeResultHandler,
isStreamWritable,
markSummarizationUsage,
buildSummarizationHandlers,

View file

@ -31,6 +31,8 @@ const {
} = require('librechat-data-provider');
const {
createToolEndCallback,
createAttachmentEmitter,
createBackgroundCodeResultHandler,
getDefaultHandlers,
} = require('~/server/controllers/agents/callbacks');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
@ -254,6 +256,11 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
});
},
toolEndCallback,
persistBackgroundCodeResult: createBackgroundCodeResultHandler({
req,
updateToolCallResult: db.updateToolCallResult,
}),
emitAttachment: createAttachmentEmitter({ res, streamId }),
...getSkillToolDeps(),
};

View file

@ -36,6 +36,8 @@ jest.mock('@librechat/api', () => ({
let capturedToolExecuteOptions;
jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn(() => jest.fn()),
createAttachmentEmitter: jest.fn(() => jest.fn()),
createBackgroundCodeResultHandler: jest.fn(() => jest.fn()),
getDefaultHandlers: jest.fn((opts) => {
capturedToolExecuteOptions = opts?.toolExecuteOptions;
return {};

View file

@ -57,6 +57,7 @@ const axios = createAxiosInstance();
const createDownloadFallback = ({
id,
name,
agentId,
messageId,
expiresAt,
session_id,
@ -71,6 +72,7 @@ const createDownloadFallback = ({
conversationId,
toolCallId,
messageId,
agentId,
};
};
@ -321,6 +323,8 @@ const processCodeOutput = async ({
conversationId,
messageId,
session_id,
agentId,
freshClaimAfter,
}) => {
const appConfig = req.config;
const currentDate = new Date();
@ -368,6 +372,7 @@ const processCodeOutput = async ({
file: createDownloadFallback({
id,
name,
agentId,
messageId,
toolCallId,
session_id,
@ -410,6 +415,16 @@ const processCodeOutput = async ({
* (e.g. `"proj name/file@v1.txt"`) would claim under the raw name and
* then write under the sanitized one, leaving the claim row orphaned.
*/
/**
* Dispatch-order stamp persisted with every write AND every claim insert
* (foreground writes dispatch now): the out-of-order guard below
* compares WRITER dispatch order, not wall-clock write time an older
* task writing late must not make a newer task's harvest look stale, and
* a freshly claimed row must carry its claimant's stamp before the
* content write lands.
*/
const sourceDispatchedAt = freshClaimAfter ?? Date.now();
const newFileId = v4();
const claimed = await claimCodeFile({
filename: safeName,
@ -417,16 +432,68 @@ const processCodeOutput = async ({
file_id: newFileId,
user: req.user.id,
tenantId: req.user.tenantId,
sourceDispatchedAt,
});
const file_id = claimed.file_id;
const isUpdate = file_id !== newFileId;
/**
* Out-of-order guard for detached (background) harvests: when the claimed
* row's last writer was dispatched AFTER this task (`freshClaimAfter` =
* this task's dispatch time), a newer run owns this filename slot. The
* `(filename, conversationId)` unique index means the stale bytes have
* nowhere else to live, so skip this file rather than overwrite fresh
* content the harvest's stdout patch still lands, only the superseded
* attachment is omitted. Falls back to `updatedAt` for rows written
* before the stamp existed (the claim itself is timestamp-neutral).
*/
const lastWriterDispatchedAt =
claimed.metadata?.sourceDispatchedAt ??
(claimed.updatedAt != null ? new Date(claimed.updatedAt).getTime() : null);
if (isUpdate && freshClaimAfter != null && lastWriterDispatchedAt > freshClaimAfter) {
logger.warn(
`[processCodeOutput] Skipping stale background output "${safeName}" (${file_id}): a newer run owns this filename`,
);
return null;
}
if (isUpdate) {
logger.debug(
`[processCodeOutput] Updating existing file "${safeName}" (${file_id}) instead of creating duplicate`,
);
}
/**
* Background harvests commit through a CONDITIONAL write: the ownership
* predicate (last writer's dispatch stamp not newer than ours) is part of
* the update's filter, so check and write are one atomic operation a
* stale harvest's commit simply misses and its attachment is skipped.
* The row always exists here (the claim inserted it), so the non-upsert
* `updateFile` matches `createFile(data, true)` semantics ($set + TTL
* unset). Bytes a loser may have already uploaded to the shared storage
* key are a narrow residual that per-file locking would be needed to
* close. Foreground writes keep the unconditional `createFile` path.
*/
const commitCodeFile = async (fileData) => {
if (freshClaimAfter == null) {
await createFile(fileData, true);
return true;
}
const committed = await updateFile(fileData, {
$or: [
{ 'metadata.sourceDispatchedAt': { $exists: false } },
{ 'metadata.sourceDispatchedAt': { $lte: sourceDispatchedAt } },
],
});
if (!committed) {
logger.warn(
`[processCodeOutput] Skipping stale background output "${safeName}" (${file_id}): a newer run owns this filename`,
);
return false;
}
return true;
};
/**
* Preserve the original `messageId` on update. Each `processCodeOutput`
* call would otherwise overwrite it with the current run's run id, which
@ -463,11 +530,13 @@ const processCodeOutput = async ({
updatedAt: formattedDate,
source: appConfig.fileStrategy,
context: FileContext.execute_code,
metadata: { codeEnvRef },
metadata: { codeEnvRef, sourceDispatchedAt },
...(await getRetentionExpiry(req)),
};
await createFile(file, true);
return { file: Object.assign(file, { messageId, toolCallId }) };
if (!(await commitCodeFile(file))) {
return null;
}
return { file: Object.assign(file, { messageId, toolCallId, agentId }) };
}
const { saveBuffer } = getStrategyFunctions(appConfig.fileStrategy);
@ -479,6 +548,7 @@ const processCodeOutput = async ({
file: createDownloadFallback({
id,
name,
agentId,
messageId,
toolCallId,
session_id,
@ -562,7 +632,7 @@ const processCodeOutput = async ({
tenantId: req.user.tenantId,
bytes: buffer.length,
updatedAt: formattedDate,
metadata: { codeEnvRef },
metadata: { codeEnvRef, sourceDispatchedAt },
source: appConfig.fileStrategy,
context: FileContext.execute_code,
usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1,
@ -592,9 +662,11 @@ const processCodeOutput = async ({
previewError: null,
previewRevision,
};
await createFile(file, true);
if (!(await commitCodeFile(file))) {
return null;
}
return {
file: Object.assign(file, { messageId, toolCallId }),
file: Object.assign(file, { messageId, toolCallId, agentId }),
finalize: () =>
finalizePreview({ buffer, leafName, mimeType, category, file_id, previewRevision }),
previewRevision,
@ -630,8 +702,10 @@ const processCodeOutput = async ({
previewRevision: null,
};
await createFile(file, true);
return { file: Object.assign(file, { messageId, toolCallId }) };
if (!(await commitCodeFile(file))) {
return null;
}
return { file: Object.assign(file, { messageId, toolCallId, agentId }) };
} catch (error) {
if (error?.message === 'Path traversal detected in filename') {
logger.warn(
@ -651,6 +725,7 @@ const processCodeOutput = async ({
file: createDownloadFallback({
id,
name,
agentId,
messageId,
toolCallId,
session_id,

View file

@ -115,10 +115,11 @@ jest.mock('@librechat/agents', () => ({
// Mock models
const mockClaimCodeFile = jest.fn();
const mockUpdateFile = jest.fn();
jest.mock('~/models', () => ({
createFile: jest.fn().mockResolvedValue({}),
getFiles: jest.fn(),
updateFile: jest.fn(),
updateFile: mockUpdateFile,
claimCodeFile: (...args) => mockClaimCodeFile(...args),
}));
@ -225,6 +226,7 @@ describe('Code Process', () => {
conversationId: 'conv-123',
file_id: 'mock-uuid-1234',
user: 'user-123',
sourceDispatchedAt: expect.any(Number),
});
expect(result.file_id).toBe('existing-file-id');
@ -247,6 +249,138 @@ describe('Code Process', () => {
expect(result.usage).toBe(1);
expect(getRetentionExpiry).toHaveBeenCalledWith(baseParams.req);
});
it('skips the file when the claim is newer than the background run (stale-harvest guard)', async () => {
/* A newer run owns the filename slot and the (filename, conversationId)
* unique index leaves stale bytes nowhere to live the detached
* harvest must not overwrite fresh content. */
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-file-id',
filename: 'test-file.txt',
messageId: 'newer-run-msg',
updatedAt: '2024-01-02T00:00:00.000Z',
});
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const result = await processCodeOutput({
...baseParams,
freshClaimAfter: new Date('2024-01-01T00:00:00.000Z').getTime(),
});
expect(result).toBeNull();
});
it('still reuses the claim when it predates the background run', async () => {
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-file-id',
filename: 'test-file.txt',
updatedAt: '2024-01-01T00:00:00.000Z',
});
mockUpdateFile.mockResolvedValue({ file_id: 'existing-file-id' });
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const { file: result } = await processCodeOutput({
...baseParams,
freshClaimAfter: new Date('2024-01-02T00:00:00.000Z').getTime(),
});
expect(result.file_id).toBe('existing-file-id');
});
it('skips when a newer task holds an unwritten claim (insert stamp, no updatedAt yet)', async () => {
/* A newer task claimed the filename but its content write is still in
* flight: the claim-insert stamp alone must trip the guard. */
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-file-id',
filename: 'test-file.txt',
metadata: {
sourceDispatchedAt: new Date('2024-01-02T00:00:00.000Z').getTime(),
},
});
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const result = await processCodeOutput({
...baseParams,
freshClaimAfter: new Date('2024-01-01T00:00:00.000Z').getTime(),
});
expect(result).toBeNull();
});
it('commits background writes conditionally: an inserter overtaken mid-flight misses', async () => {
/* This task INSERTED the claim, then a newer task stamped and wrote
* while this one was still downloading the ownership predicate is in
* the write's own filter, so the commit atomically misses. */
mockClaimCodeFile.mockResolvedValue({
file_id: 'mock-uuid-1234',
user: 'user-123',
});
mockUpdateFile.mockResolvedValueOnce(null);
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const result = await processCodeOutput({
...baseParams,
freshClaimAfter: new Date('2024-01-01T00:00:00.000Z').getTime(),
});
expect(result).toBeNull();
expect(mockUpdateFile).toHaveBeenCalledWith(
expect.objectContaining({ file_id: 'mock-uuid-1234' }),
{
$or: [
{ 'metadata.sourceDispatchedAt': { $exists: false } },
{
'metadata.sourceDispatchedAt': {
$lte: new Date('2024-01-01T00:00:00.000Z').getTime(),
},
},
],
},
);
});
it('commits when the conditional write matches (ownership held through the write)', async () => {
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-file-id',
filename: 'test-file.txt',
updatedAt: '2024-01-01T00:00:00.000Z',
});
mockUpdateFile.mockResolvedValueOnce({ file_id: 'existing-file-id' });
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const { file: result } = await processCodeOutput({
...baseParams,
freshClaimAfter: new Date('2024-01-02T00:00:00.000Z').getTime(),
});
expect(result.file_id).toBe('existing-file-id');
});
it('lets a newer task overwrite an OLDER task that wrote late (writer dispatch order wins)', async () => {
/* Old task (dispatched Jan 1) settled late and wrote at Jan 3;
* this task was dispatched Jan 2. Wall-clock updatedAt is newer
* than our dispatch, but the WRITER is older overwrite. */
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-file-id',
filename: 'test-file.txt',
updatedAt: '2024-01-03T00:00:00.000Z',
metadata: {
sourceDispatchedAt: new Date('2024-01-01T00:00:00.000Z').getTime(),
},
});
mockUpdateFile.mockResolvedValue({ file_id: 'existing-file-id' });
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const { file: result } = await processCodeOutput({
...baseParams,
freshClaimAfter: new Date('2024-01-02T00:00:00.000Z').getTime(),
});
expect(result.file_id).toBe('existing-file-id');
expect(result.metadata.sourceDispatchedAt).toBe(
new Date('2024-01-02T00:00:00.000Z').getTime(),
);
});
});
describe('processCodeOutput', () => {
@ -768,6 +902,7 @@ describe('Code Process', () => {
storage_session_id: 'session-123',
file_id: 'file-id-123',
},
sourceDispatchedAt: expect.any(Number),
});
});

View file

@ -7,8 +7,8 @@ import type {
Agents,
} from 'librechat-data-provider';
import type { ToolCallGroupExpansionState } from './ToolCallGroup';
import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils';
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
import { mapAttachments, groupSequentialToolCalls } from '~/utils';
import { MessageContext, SearchContext } from '~/Providers';
import PendingSkillCall from './Parts/PendingSkillCall';
import { EditTextPart, EmptyText } from './Parts';
@ -21,6 +21,10 @@ import Part from './Part';
const getToolCallId = (part: TMessageContentParts): string =>
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const getPartAgentId = (part: TMessageContentParts): string | undefined =>
(part as { agentId?: string })?.agentId ??
(part?.[ContentTypes.TOOL_CALL] as { agentId?: string } | undefined)?.agentId;
const getToolGroupId = (parts: PartWithIndex[], fallbackScope: number): string => {
const firstPart = parts[0];
if (!firstPart) {
@ -243,7 +247,10 @@ const ContentParts = memo(function ContentParts({
isCreatedByUser={isCreatedByUser}
nextType={content?.[idx + 1]?.type}
isSubmitting={effectiveIsSubmitting}
partAttachments={attachmentMap[getToolCallId(part)]}
partAttachments={filterAttachmentsForPart(
attachmentMap[getToolCallId(part)],
getPartAgentId(part),
)}
/>
);
},
@ -274,7 +281,10 @@ const ContentParts = memo(function ContentParts({
isCreatedByUser={isCreatedByUser}
nextType={content?.[idx + 1]?.type}
isSubmitting={effectiveIsSubmitting}
partAttachments={attachmentMap[getToolCallId(part)]}
partAttachments={filterAttachmentsForPart(
attachmentMap[getToolCallId(part)],
getPartAgentId(part),
)}
hideAttachments
onToolExpand={onToolExpand}
/>
@ -313,7 +323,9 @@ const ContentParts = memo(function ContentParts({
}
const groupId = getToolGroupId(group.parts, fallbackScope);
const groupAttachments = group.parts.flatMap(
({ part }) => attachmentMap[getToolCallId(part)] ?? [],
({ part }) =>
filterAttachmentsForPart(attachmentMap[getToolCallId(part)], getPartAgentId(part)) ??
[],
);
return { ...group, groupId, groupAttachments };
}),

View file

@ -2,6 +2,7 @@ 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 { parseBackgroundHandle, splitBackgroundAttachments } from './handle';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import parseJsonField, { areToolCallArgsComplete } from './parseJsonField';
import CopyButton from '~/components/Messages/Content/CopyButton';
@ -45,6 +46,23 @@ export default function BashCall({
const highlighted = useLazyHighlight(command || undefined, 'bash');
const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]);
/** A backgrounded call's persisted output stays the dispatch handle until
* the detached run settles and patches it; render a background state
* instead of the handle JSON. Completion arrives live as the status marker
* attachment (also covers stdout-only runs) or as harvested files. */
const backgroundHandle = useMemo(() => parseBackgroundHandle(output), [output]);
const { fileAttachments, backgroundStatus } = useMemo(
() => splitBackgroundAttachments(attachments, toolCallId),
[attachments, toolCallId],
);
const backgroundFailed = backgroundHandle != null && backgroundStatus === 'error';
const backgroundFinishedText = backgroundHandle
? localize(
backgroundStatus != null || (fileAttachments?.length ?? 0) > 0
? 'com_ui_background_finished'
: 'com_ui_background_running',
)
: null;
const [isCopied, setIsCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
@ -75,9 +93,15 @@ export default function BashCall({
onClick={toggleCode}
inProgressText={inProgressText}
finishedText={
cancelled ? localize('com_ui_cancelled') : localize('com_ui_command_finished')
cancelled
? localize('com_ui_cancelled')
: (backgroundFinishedText ?? localize('com_ui_command_finished'))
}
errorSuffix={
(hasError && !cancelled) || backgroundFailed
? localize('com_ui_tool_failed')
: undefined
}
errorSuffix={hasError && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
<LangIcon
lang="bash"
@ -112,7 +136,7 @@ export default function BashCall({
</pre>
</div>
)}
{hasOutput && (
{hasOutput && backgroundHandle == null && (
<div className={cn(command && 'border-t border-border-light')}>
<pre
className={cn(
@ -127,8 +151,8 @@ export default function BashCall({
</div>
</div>
</div>
{!hideAttachments && attachments && attachments.length > 0 && (
<AttachmentGroup attachments={attachments} />
{!hideAttachments && fileAttachments && fileAttachments.length > 0 && (
<AttachmentGroup attachments={fileAttachments} />
)}
</>
);

View file

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { SquareTerminal } from 'lucide-react';
import type { TAttachment } from 'librechat-data-provider';
import { parseBackgroundHandle, splitBackgroundAttachments } from './handle';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import { sandboxStartingByToolCallId } from '~/store';
import useLazyHighlight from './useLazyHighlight';
@ -80,6 +81,23 @@ export default function ExecuteCode({
const highlighted = useLazyHighlight(code, lang);
const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]);
/** A backgrounded call's persisted output stays the dispatch handle until
* the detached run settles and patches it; render a background state
* instead of the handle JSON. Completion arrives live as the status marker
* attachment (also covers stdout-only runs) or as harvested files. */
const backgroundHandle = useMemo(() => parseBackgroundHandle(output), [output]);
const { fileAttachments, backgroundStatus } = useMemo(
() => splitBackgroundAttachments(attachments, toolCallId),
[attachments, toolCallId],
);
const backgroundFailed = backgroundHandle != null && backgroundStatus === 'error';
const backgroundFinishedText = backgroundHandle
? localize(
backgroundStatus != null || (fileAttachments?.length ?? 0) > 0
? 'com_ui_background_finished'
: 'com_ui_background_running',
)
: null;
return (
<>
@ -91,9 +109,15 @@ export default function ExecuteCode({
sandboxStarting ? localize('com_ui_sandbox_starting') : localize('com_ui_analyzing')
}
finishedText={
cancelled ? localize('com_ui_cancelled') : localize('com_ui_analyzing_finished')
cancelled
? localize('com_ui_cancelled')
: (backgroundFinishedText ?? localize('com_ui_analyzing_finished'))
}
errorSuffix={
(hasError && !cancelled) || backgroundFailed
? localize('com_ui_tool_failed')
: undefined
}
errorSuffix={hasError && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
<SquareTerminal
className={cn(
@ -117,7 +141,7 @@ export default function ExecuteCode({
<code className={`hljs language-${lang} !whitespace-pre`}>{highlighted}</code>
</pre>
)}
{hasOutput && (
{hasOutput && backgroundHandle == null && (
<div
className={cn(
'bg-surface-primary-alt p-4 text-xs dark:bg-transparent',
@ -140,8 +164,8 @@ export default function ExecuteCode({
</div>
</div>
</div>
{!hideAttachments && attachments && attachments.length > 0 && (
<AttachmentGroup attachments={attachments} />
{!hideAttachments && fileAttachments && fileAttachments.length > 0 && (
<AttachmentGroup attachments={fileAttachments} />
)}
</>
);

View file

@ -13,6 +13,9 @@ jest.mock('~/hooks', () => ({
com_ui_command_finished: 'Finished running',
com_ui_cancelled: 'Cancelled',
com_ui_copy_code: 'Copy code',
com_ui_background_running: 'Running in background',
com_ui_background_finished: 'Finished in background',
com_ui_tool_failed: 'tool failed',
};
return translations[key] ?? key;
},
@ -33,11 +36,18 @@ jest.mock('~/components/Chat/Messages/Content/ProgressText', () => ({
progress,
inProgressText,
finishedText,
errorSuffix,
}: {
progress: number;
inProgressText: string;
finishedText: string;
}) => <div data-testid="progress-text">{progress < 1 ? inProgressText : finishedText}</div>,
errorSuffix?: string;
}) => (
<div data-testid="progress-text">
{progress < 1 ? inProgressText : finishedText}
{errorSuffix != null ? `${errorSuffix}` : ''}
</div>
),
}));
jest.mock('~/components/Messages/Content/CopyButton', () => ({
@ -111,3 +121,68 @@ describe('BashCall status text', () => {
},
);
});
describe('BashCall backgrounded calls', () => {
const HANDLE_OUTPUT = JSON.stringify({
background_task_id: 'task-1',
tool: 'bash_tool',
status: 'running',
message:
'Started "bash_tool" in the background. Call check_background_task with background_task_id "task-1" to check progress and retrieve the result.',
});
const renderBackgrounded = (attachments?: Array<Record<string, unknown>>) =>
render(
<RecoilRoot>
<BashCall
initialProgress={1}
isSubmitting={false}
args={{ command: 'sleep 600' }}
output={HANDLE_OUTPUT}
attachments={attachments as never}
/>
</RecoilRoot>,
);
it('shows a background-running state instead of rendering the handle JSON as stdout', () => {
renderBackgrounded();
expect(screen.getByTestId('progress-text')).toHaveTextContent('Running in background');
expect(screen.queryByText(/background_task_id/)).not.toBeInTheDocument();
});
it('flips to finished once attachments arrive for the call', () => {
renderBackgrounded([{ file_id: 'f1' }]);
expect(screen.getByTestId('progress-text')).toHaveTextContent('Finished in background');
});
it('flips to finished on the status marker alone (stdout-only completion)', () => {
renderBackgrounded([
{ type: 'background_task_status', file_id: 'bg-tc-1', toolCallId: 'tc-1' },
]);
expect(screen.getByTestId('progress-text')).toHaveTextContent('Finished in background');
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
});
it('surfaces failure when the marker carries an error status', () => {
renderBackgrounded([
{ type: 'background_task_status', file_id: 'bg-tc-1', toolCallId: 'tc-1', status: 'error' },
]);
expect(screen.getByTestId('progress-text')).toHaveTextContent('Finished in background');
expect(screen.getByTestId('progress-text')).toHaveTextContent('tool failed');
});
it('renders real stdout normally after the background result patches the output', () => {
render(
<RecoilRoot>
<BashCall
initialProgress={1}
isSubmitting={false}
args={{ command: 'echo hi' }}
output="hi"
/>
</RecoilRoot>,
);
expect(screen.getByTestId('progress-text')).toHaveTextContent('Finished running');
expect(screen.getByText('hi')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,82 @@
import { parseBackgroundHandle, splitBackgroundAttachments } from '../handle';
describe('parseBackgroundHandle', () => {
const handle = JSON.stringify({
background_task_id: 'task-1',
tool: 'execute_code',
status: 'running',
message:
'Started "execute_code" in the background. Call check_background_task with background_task_id "task-1" to check progress and retrieve the result.',
});
it('parses a dispatch handle', () => {
expect(parseBackgroundHandle(handle)).toEqual(
expect.objectContaining({ background_task_id: 'task-1', tool: 'execute_code' }),
);
});
it('tolerates surrounding whitespace', () => {
expect(parseBackgroundHandle(`\n ${handle} \n`)).not.toBeNull();
});
it.each([
undefined,
'',
'stdout:\nhello',
'Traceback (most recent call last): ...',
'{"result": "background_task_id mentioned in output"}',
'{"background_task_id": 42, "tool": "x"}',
'{"background_task_id": "t"',
])('returns null for non-handle output: %s', (output) => {
expect(parseBackgroundHandle(output)).toBeNull();
});
it('returns null for real stdout that mimics the handle without the poll instruction', () => {
expect(
parseBackgroundHandle(
JSON.stringify({
background_task_id: 'task-1',
tool: 'execute_code',
status: 'running',
message: 'user code printed this',
}),
),
).toBeNull();
});
it('returns null when extra keys are present (patched real output)', () => {
expect(
parseBackgroundHandle(
JSON.stringify({
background_task_id: 'task-1',
tool: 'execute_code',
status: 'done',
message: 'mentions check_background_task',
data: [1, 2, 3],
}),
),
).toBeNull();
});
it('ignores a sibling calls status marker (defense in depth)', () => {
const marker = {
type: 'background_task_status',
file_id: 'bg-tc-other',
toolCallId: 'tc-other',
status: 'error',
} as never;
const { backgroundStatus, fileAttachments } = splitBackgroundAttachments([marker], 'tc-mine');
expect(backgroundStatus).toBeUndefined();
/** Still filtered out of file rendering regardless of ownership. */
expect(fileAttachments).toHaveLength(0);
});
it('returns null for oversized payloads (real output, not a handle)', () => {
const big = JSON.stringify({
background_task_id: 'task-1',
tool: 'execute_code',
padding: 'x'.repeat(2000),
});
expect(parseBackgroundHandle(big)).toBeNull();
});
});

View file

@ -0,0 +1,93 @@
import type { TAttachment } from 'librechat-data-provider';
/**
* `type` of the synthetic attachment the server emits on a poll turn when a
* backgrounded code task settles the live completion signal for the original
* card (stdout-only runs produce no file attachments). Never persisted;
* mirrored in `packages/api/src/agents/background.ts`.
*/
export const BACKGROUND_STATUS_ATTACHMENT_TYPE = 'background_task_status';
/**
* Separates real file attachments from the synthetic background status marker.
* `backgroundStatus` carries the settled task's status (`completed` | `error`)
* when the marker is present the backgrounded call has finished even if it
* generated no files, and failures must not render as clean completions.
*
* Attachments reach each card pre-routed by `toolCallId` (`ContentParts`'
* `mapAttachments`), but the marker is additionally scoped to `toolCallId`
* here as defense in depth a sibling call's marker must never flip this
* card's status. A missing id on either side is a wildcard.
*/
export function splitBackgroundAttachments(
attachments?: TAttachment[],
toolCallId?: string,
): {
fileAttachments?: TAttachment[];
backgroundStatus?: string;
} {
if (!attachments || attachments.length === 0) {
return { fileAttachments: attachments };
}
/** The marker's `type`/`status` are host-defined, outside the `Tools` union. */
let backgroundStatus: string | undefined;
const fileAttachments = attachments.filter((attachment) => {
const marker = attachment as
| { type?: string; status?: string; toolCallId?: string }
| undefined;
if (marker?.type !== BACKGROUND_STATUS_ATTACHMENT_TYPE) {
return true;
}
if (toolCallId == null || marker.toolCallId == null || marker.toolCallId === toolCallId) {
backgroundStatus = typeof marker.status === 'string' ? marker.status : 'completed';
}
return false;
});
return { fileAttachments, backgroundStatus };
}
export interface BackgroundHandle {
background_task_id: string;
tool: string;
status: string;
message: string;
}
const HANDLE_KEYS: ReadonlyArray<keyof BackgroundHandle> = [
'background_task_id',
'tool',
'status',
'message',
];
/**
* Detects the synthetic "dispatched in background" handle a backgrounded tool
* call returns as its output, so code cards can show a background state
* instead of rendering the handle JSON as stdout. Requires the handle's exact
* shape including the poll-tool instruction in `message` so real stdout
* that happens to be a small JSON object naming `background_task_id` is not
* suppressed.
*/
export function parseBackgroundHandle(output?: string): BackgroundHandle | null {
if (!output || output.length > 1000) {
return null;
}
const trimmed = output.trim();
if (!trimmed.startsWith('{') || !trimmed.includes('"background_task_id"')) {
return null;
}
try {
const parsed = JSON.parse(trimmed) as Partial<BackgroundHandle> | null;
if (
parsed != null &&
Object.keys(parsed).length === HANDLE_KEYS.length &&
HANDLE_KEYS.every((key) => typeof parsed[key] === 'string') &&
(parsed.message as string).includes('check_background_task')
) {
return parsed as BackgroundHandle;
}
} catch {
return null;
}
return null;
}

View file

@ -1,10 +1,11 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ContentTypes } from 'librechat-data-provider';
import { render, screen } from '@testing-library/react';
import type { TMessageContentParts } from 'librechat-data-provider';
jest.mock('~/utils', () => ({
mapAttachments: () => ({}),
filterAttachmentsForPart: (attachments: unknown) => attachments,
groupSequentialToolCalls: (parts: Array<{ part: unknown; idx: number }>) =>
parts.map((p) => ({ type: 'single' as const, part: p })),
}));

View file

@ -0,0 +1,77 @@
import { useCallback } from 'react';
import { Tools } from 'librechat-data-provider';
import { useFormContext, useWatch } from 'react-hook-form';
import {
Switch,
HoverCard,
HoverCardPortal,
HoverCardContent,
HoverCardTrigger,
CircleHelpIcon,
} from '@librechat/client';
import type { AgentForm } from '~/common';
import { useAgentCapabilities, useGetAgentsConfig, useLocalize } from '~/hooks';
import { withBooleanOption } from '~/hooks/Agents/useMCPToolOptions';
import { ESide } from '~/common';
/** Tools sharing the code-execution sandbox; the single builder toggle opts
* the whole pair into background dispatch. */
const CODE_BACKGROUND_TOOL_IDS: string[] = [Tools.execute_code, Tools.bash_tool];
export default function Background() {
const localize = useLocalize();
const { agentsConfig } = useGetAgentsConfig();
const { backgroundToolsEnabled } = useAgentCapabilities(agentsConfig?.capabilities);
const { control, getValues, setValue } = useFormContext<AgentForm>();
const toolOptions = useWatch({ control, name: 'tool_options' });
/** Either key enables the pair at runtime (the backend expands the code
* opt-in across both), so the switch must reflect either. */
const enabled = CODE_BACKGROUND_TOOL_IDS.some(
(toolId) => toolOptions?.[toolId]?.run_in_background === true,
);
const handleChange = useCallback(
(value: boolean) => {
let updated = getValues('tool_options') || {};
for (const toolId of CODE_BACKGROUND_TOOL_IDS) {
updated = withBooleanOption(updated, toolId, 'run_in_background', value);
}
setValue('tool_options', updated, { shouldDirty: true });
},
[getValues, setValue],
);
if (!backgroundToolsEnabled) {
return null;
}
return (
<HoverCard openDelay={50}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="text-sm">{localize('com_ui_code_background')}</div>
<HoverCardTrigger>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</HoverCardTrigger>
</div>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{localize('com_nav_info_code_background')}
</p>
</div>
</HoverCardContent>
</HoverCardPortal>
<Switch
id="code-background-tools"
checked={enabled}
onCheckedChange={handleChange}
className="ml-4"
data-testid="code-background-tools"
aria-label={localize('com_ui_code_background')}
/>
</div>
</HoverCard>
);
}

View file

@ -6,7 +6,11 @@ import type { ReactNode } from 'react';
import type { AgentForm } from '~/common';
import BuiltinSection from '../sections/BuiltinSection';
jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key }));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useGetAgentsConfig: () => ({ agentsConfig: undefined }),
useAgentCapabilities: () => ({ backgroundToolsEnabled: false }),
}));
jest.mock('~/data-provider', () => ({ useVerifyAgentToolAuth: () => ({ data: undefined }) }));
jest.mock('../../../Search/Action', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../../../FileContext', () => ({ __esModule: true, default: () => <div /> }));

View file

@ -5,6 +5,7 @@ import type { TranslationKeys } from '~/hooks/useLocalize';
import type { AgentForm, ExtendedFile } from '~/common';
import type { BuiltinId } from '../../items/types';
import { useVerifyAgentToolAuth } from '~/data-provider';
import CodeBackground from '../../../Code/Background';
import SearchAction from '../../../Search/Action';
import FileContext from '../../../FileContext';
import FileSearch from '../../../FileSearch';
@ -133,7 +134,12 @@ export default function BuiltinSection({
let body: React.ReactNode = null;
if (builtinId === 'execute_code') {
body = <CodeFiles agent_id={agentId} files={codeFiles} />;
body = (
<div className="flex flex-col gap-4">
<CodeBackground />
<CodeFiles agent_id={agentId} files={codeFiles} />
</div>
);
} else if (builtinId === 'web_search') {
body = <WebSearchConfig />;
} else if (builtinId === 'file_search') {

View file

@ -40,7 +40,7 @@ interface UseMCPToolOptionsReturn {
* previous objects (react-hook-form still holds them); dropping the last flag
* removes the tool's entry entirely.
*/
function withBooleanOption(
export function withBooleanOption(
options: AgentToolOptions,
toolId: string,
key: BooleanToolOptionKey,

View file

@ -69,6 +69,7 @@ function setup({
preview,
isFetching = false,
seedLiveMap = true,
seedEntries,
}: {
attachment: TAttachment;
isSubmitting: boolean;
@ -80,6 +81,9 @@ function setup({
* upsert must INSERT (not just update) the resolved record into the
* live map so the parent's `useAttachments` merge picks it up. */
seedLiveMap?: boolean;
/* Overrides the seeded live entries (defaults to `[attachment]`)
* used to simulate sibling tool calls sharing the same file_id. */
seedEntries?: TAttachment[];
}) {
mockUseFilePreview.mockReset();
mockUseFilePreview.mockReturnValue({ data: preview, isFetching });
@ -102,7 +106,7 @@ function setup({
const setSubmitting = useSetRecoilState(store.isSubmittingFamily(0));
useEffect(() => {
if (seedLiveMap) {
setMap({ [messageId]: [attachment] });
setMap({ [messageId]: seedEntries ?? [attachment] });
}
setKeys([0]);
setSubmitting(isSubmitting);
@ -297,6 +301,32 @@ describe('useAttachmentPreviewSync', () => {
expect(ctx.result.current.previewError).toBe('parser-error');
});
it('fans the resolved preview out to EVERY sibling entry sharing the file_id', () => {
const first = makeAttachment({ status: 'pending', toolCallId: 'tc-1' });
const sibling = makeAttachment({ status: 'pending', toolCallId: 'tc-2' });
const other = makeAttachment({ file_id: 'fid-other', status: 'pending', toolCallId: 'tc-3' });
const ctx = setup({
attachment: first,
isSubmitting: true,
seedEntries: [first, sibling, other],
preview: {
file_id: fileId,
status: 'ready',
text: '<table>final</table>',
textFormat: 'html',
},
});
const list = (ctx.map[messageId] ?? []) as AttachmentFixture[];
expect(list).toHaveLength(3);
expect(list[0].status).toBe('ready');
expect(list[0].text).toBe('<table>final</table>');
expect(list[1].status).toBe('ready');
expect(list[1].text).toBe('<table>final</table>');
expect(list[1].toolCallId).toBe('tc-2');
expect(list[2].status).toBe('pending');
expect(list[2].text).toBeUndefined();
});
it('does NOT upsert while the polled status is still pending', () => {
const ctx = setup({
attachment: makeAttachment({ status: 'pending' }),

View file

@ -185,24 +185,31 @@ export default function useAttachmentPreviewSync(
setAttachmentsMap((prevMap) => {
const messageAttachments =
(prevMap as Record<string, TAttachment[] | undefined>)[messageId] || [];
const existingIndex = messageAttachments.findIndex(
(a) => (a as Partial<TFile>).file_id === fileId,
);
const resolvedFields = {
status: polled.status,
text: polled.text ?? null,
textFormat: polled.textFormat ?? null,
previewError: polled.previewError,
};
if (existingIndex >= 0) {
const existing = messageAttachments[existingIndex] as Partial<TFile> & TAttachment;
const merged = [...messageAttachments];
merged[existingIndex] = {
/* Fan out to EVERY entry sharing this file_id: sibling tool calls
* (or handoff agents) can legitimately hold the same claimed file,
* and the preview is a per-file result patching only the first
* match would leave the other cards stuck on `pending`. */
let found = false;
const merged = messageAttachments.map((a) => {
if ((a as Partial<TFile>).file_id !== fileId) {
return a;
}
found = true;
const existing = a as Partial<TFile> & TAttachment;
return {
...existing,
...resolvedFields,
text: polled.text ?? existing.text ?? null,
textFormat: polled.textFormat ?? existing.textFormat ?? null,
} as TAttachment;
});
if (found) {
return { ...prevMap, [messageId]: merged };
}
const inserted = { ...attachment, ...resolvedFields } as TAttachment;

View file

@ -118,17 +118,113 @@ describe('useAttachments', () => {
expect(merged.filename).toBe('data.xlsx');
});
it('leaves DB attachments untouched when no live entry shares the file_id', () => {
it('keeps live-only entries alongside DB attachments (background harvest delivery)', () => {
const db = makeAttachment({ file_id: 'fid-A', status: 'pending' });
const live = makeAttachment({ file_id: 'fid-B', status: 'ready' });
const { result } = setup({
attachments: [db],
liveMap: { [messageId]: [live] },
});
/* DB attachments are the authoritative list for THIS message a
* live entry without a matching file_id must NOT bleed in. */
expect(result.current.attachments).toHaveLength(1);
/* Attachments are only emitted after successful persistence, so a
* live-only entry is a real file whose message-row snapshot predates
* it e.g. a background code task's harvested files, SSE-delivered
* on a poll turn after the dispatch message was saved. Dropping it
* would hide the files until a full reload. */
expect(result.current.attachments).toHaveLength(2);
expect((result.current.attachments[0] as AttachmentFixture).file_id).toBe('fid-A');
expect((result.current.attachments[0] as AttachmentFixture).status).toBe('pending');
expect((result.current.attachments[1] as AttachmentFixture).file_id).toBe('fid-B');
});
it('overlays bare live records (no toolCallId) onto DB entries that carry one', () => {
/* Preview-sync polling inserts the resolved FILE record, which has no
* toolCallId; the DB attachment does. The lifecycle overlay must still
* match (wildcard), or reloaded office previews stick on pending. */
const db = makeAttachment({ status: 'pending', text: undefined });
const live = { ...makeAttachment({ status: 'ready', text: 'resolved' }) };
delete (live as { toolCallId?: string }).toolCallId;
const { result } = setup({
attachments: [db],
liveMap: { [messageId]: [live as AttachmentFixture] },
});
expect(result.current.attachments).toHaveLength(1);
expect((result.current.attachments[0] as AttachmentFixture).status).toBe('ready');
});
it('keeps sibling tool calls live attachments when file ids repeat across calls', () => {
/* Two background code calls regenerated the same filename same
* claimed file_id, different toolCallId. Each card anchors its own
* attachment, so the second must not displace the first. */
const db = makeAttachment({ file_id: 'shared' });
const sibling = { ...makeAttachment({ file_id: 'shared' }), toolCallId: 'tc-2' };
const { result } = setup({
attachments: [db],
liveMap: { [messageId]: [sibling] },
});
expect(result.current.attachments).toHaveLength(2);
});
it('keeps handoff agents live attachments when file_id AND toolCallId collide', () => {
/* Handoff agents can repeat provider tool ids (`call_0`) and share a
* claimed file_id. The sibling agent's live entry must neither
* overlay the first agent's DB row nor be deduped away each
* agent's card anchors its own attachment. */
const db = { ...makeAttachment({ file_id: 'shared', status: 'pending' }), agentId: 'agent_a' };
const sibling = {
...makeAttachment({ file_id: 'shared', status: 'ready' }),
agentId: 'agent_b',
};
const { result } = setup({
attachments: [db],
liveMap: { [messageId]: [sibling] },
});
expect(result.current.attachments).toHaveLength(2);
expect((result.current.attachments[0] as AttachmentFixture).status).toBe('pending');
expect((result.current.attachments[0] as { agentId?: string }).agentId).toBe('agent_a');
expect((result.current.attachments[1] as { agentId?: string }).agentId).toBe('agent_b');
});
it('overlays agent-less live records onto agent-scoped DB rows (wildcard) without re-appending', () => {
/* Preview-sync fan-out and bare deferred updates carry no agentId;
* they must still resolve an agent-stamped DB row, and the overlaid
* entry must be recognized as a duplicate via its less-specific key. */
const db = {
...makeAttachment({ file_id: 'shared', status: 'pending', text: undefined }),
agentId: 'agent_a',
};
const live = makeAttachment({ file_id: 'shared', status: 'ready', text: 'resolved' });
const { result } = setup({
attachments: [db],
liveMap: { [messageId]: [live] },
});
expect(result.current.attachments).toHaveLength(1);
expect((result.current.attachments[0] as AttachmentFixture).status).toBe('ready');
expect((result.current.attachments[0] as { agentId?: string }).agentId).toBe('agent_a');
});
it('dedupes unkeyed live entries against DB copies by type + toolCallId', () => {
const citation = {
type: 'file_search',
toolCallId: 'tc-1',
messageId,
} as unknown as AttachmentFixture;
const { result } = setup({
attachments: [citation],
liveMap: { [messageId]: [{ ...citation }] },
});
/* The final message event replays the same persisted file_search
* citation the SSE handler already stored one card, not two. */
expect(result.current.attachments).toHaveLength(1);
});
it('drops live entries with no stable identity at all', () => {
const db = makeAttachment({ file_id: 'fid-A' });
const anonymous = { messageId } as unknown as AttachmentFixture;
const { result } = setup({
attachments: [db],
liveMap: { [messageId]: [anonymous] },
});
expect(result.current.attachments).toHaveLength(1);
expect((result.current.attachments[0] as AttachmentFixture).file_id).toBe('fid-A');
});
});

View file

@ -4,6 +4,64 @@ import type { TAttachment, TFile } from 'librechat-data-provider';
import { useSearchResultsByTurn } from './useSearchResultsByTurn';
import store from '~/store';
function fileKeyOf(attachment: TAttachment): string | undefined {
const { file_id, filepath } = attachment as Partial<TFile>;
return file_id ?? filepath;
}
function toolCallIdOf(attachment: TAttachment): string | undefined {
return (attachment as { toolCallId?: string }).toolCallId;
}
function agentIdOf(attachment: TAttachment): string | undefined {
return (attachment as { agentId?: string }).agentId;
}
/**
* Stable identity for merging DB and live attachments: `file_id ?? filepath`
* scoped by `toolCallId` and `agentId` (sibling code calls can share a
* claimed file_id for the same filename, and handoff agents can repeat
* provider tool ids each combination anchors its own card), else
* `type:toolCallId` for unkeyed tool artifacts like file_search citations.
* Undefined = no stable identity.
*/
function attachmentKey(attachment: TAttachment): string | undefined {
const { type } = attachment as { type?: string };
const toolCallId = toolCallIdOf(attachment);
const fileKey = fileKeyOf(attachment);
if (fileKey) {
if (toolCallId == null) {
return fileKey;
}
const agentId = agentIdOf(attachment);
return agentId ? `${fileKey}::${toolCallId}::${agentId}` : `${fileKey}::${toolCallId}`;
}
if (type != null && toolCallId != null) {
return `${type}:${toolCallId}`;
}
return undefined;
}
/**
* Wildcard-tolerant match for the lifecycle overlay: a missing `toolCallId`
* or `agentId` on either side matches (preview-sync records are bare
* `{file_id, ...}`); only DISTINCT values keep same-file entries separate.
*/
function matchesLiveEntry(db: TAttachment, live: TAttachment): boolean {
const key = fileKeyOf(db);
if (!key || fileKeyOf(live) !== key) {
return false;
}
const dbToolCallId = toolCallIdOf(db);
const liveToolCallId = toolCallIdOf(live);
if (dbToolCallId != null && liveToolCallId != null && dbToolCallId !== liveToolCallId) {
return false;
}
const dbAgentId = agentIdOf(db);
const liveAgentId = agentIdOf(live);
return dbAgentId == null || liveAgentId == null || dbAgentId === liveAgentId;
}
export default function useAttachments({
messageId,
attachments,
@ -33,21 +91,41 @@ export default function useAttachments({
* resolved record into `messageAttachmentsMap`; merging here lets
* `artifactTypeForAttachment` see the resolved text/textFormat
* and route through the proper PanelArtifact card. */
const liveByFileId = new Map<string, TAttachment>();
for (const a of live) {
const id = (a as Partial<TFile>).file_id;
if (id) {
liveByFileId.set(id, a);
}
}
return attachments.map((db) => {
const id = (db as Partial<TFile>).file_id;
if (!id) {
const dbKeys = new Set<string>();
const merged = attachments.map((db) => {
const key = attachmentKey(db);
if (!key) {
return db;
}
const liveEntry = liveByFileId.get(id);
dbKeys.add(key);
/** Partial live records must still be reachable by their less-specific
* keys so an overlaid entry isn't re-appended below: bare records (no
* toolCallId) key by plain file key, agent-less records by
* fileKey::toolCallId. */
const fileKey = fileKeyOf(db);
if (fileKey) {
dbKeys.add(fileKey);
const toolCallId = toolCallIdOf(db);
if (toolCallId != null) {
dbKeys.add(`${fileKey}::${toolCallId}`);
}
}
const liveEntry = live.find((a) => matchesLiveEntry(db, a));
return liveEntry ? ({ ...db, ...liveEntry } as TAttachment) : db;
});
/* Live-only entries with a stable identity are kept, not discarded: a
* background code task's harvested files arrive via SSE anchored to a
* message whose DB `attachments` snapshot predates them (the row is
* patched post-finalize), so treating the DB list as exhaustive would
* make those files vanish until a full reload. Entries whose key is
* already in the DB list (e.g. unkeyed file_search citations replayed
* by the final message event) are duplicates, and entries with no
* stable identity at all cannot be deduped both are dropped. */
const liveOnly = live.filter((a) => {
const key = attachmentKey(a);
return key != null && !dbKeys.has(key);
});
return liveOnly.length > 0 ? [...merged, ...liveOnly] : merged;
}, [attachments, messageAttachmentsMap, messageId]);
const searchResults = useSearchResultsByTurn(messageAttachments);

View file

@ -144,6 +144,37 @@ describe('useAttachmentHandler upsert-by-file_id', () => {
expect(ctx.list).toHaveLength(2);
});
it('keeps sibling tool calls separate when they share a file_id (distinct toolCallIds)', () => {
/* Two background code calls regenerated the same filename same
* claimed file_id, different toolCallId. Each card anchors its own
* attachment, so the second emit must append, not replace. A bare
* update (no toolCallId) still merges by file key (wildcard). */
const ctx = setup();
ctx.handle(makeAttachment({ status: 'ready' }));
ctx.handle(makeAttachment({ status: 'ready', toolCallId: 'tc-2' }));
expect(ctx.list).toHaveLength(2);
});
it('keeps handoff agents separate when file_id AND toolCallId collide (distinct agentIds)', () => {
/* Two handoff agents both emitted `call_0` and wrote the same
* filename same claimed file_id, same provider toolCallId,
* different agentId. Merging would overwrite the first agent's
* agentId and leave ContentParts one attachment short. A record
* with no agentId still merges (wildcard, single-agent runs). */
const ctx = setup();
ctx.handle({ ...makeAttachment({ status: 'ready' }), agentId: 'agent_a' } as TAttachment);
ctx.handle({ ...makeAttachment({ status: 'ready' }), agentId: 'agent_b' } as TAttachment);
expect(ctx.list).toHaveLength(2);
expect(ctx.list.map((a) => (a as { agentId?: string }).agentId).sort()).toEqual([
'agent_a',
'agent_b',
]);
ctx.handle(makeAttachment({ status: 'failed', previewError: 'timeout' }));
/* Bare-agent update merged into the FIRST compatible entry, not appended. */
expect(ctx.list).toHaveLength(2);
expect((ctx.list[0] as { previewError?: string }).previewError).toBe('timeout');
});
it('preserves fields from the first event when the second omits them', () => {
/* The deferred preview update only carries the deltas (text, status,
* textFormat). Fields set in the initial emit (filename, type, etc.)

View file

@ -1,5 +1,4 @@
import { useSetRecoilState } from 'recoil';
import type { QueryClient } from '@tanstack/react-query';
import { QueryKeys, Tools } from 'librechat-data-provider';
import type {
MemoriesResponse,
@ -7,6 +6,7 @@ import type {
TAttachment,
TFile,
} from 'librechat-data-provider';
import type { QueryClient } from '@tanstack/react-query';
import { handleMemoryArtifact } from '~/utils/memory';
import store from '~/store';
@ -70,19 +70,54 @@ export default function useAttachmentHandler(queryClient?: QueryClient) {
setAttachmentsMap((prevMap) => {
const messageAttachments =
(prevMap as Record<string, TAttachment[] | undefined>)[messageId] || [];
/* Upsert by `file_id` rather than always appending. The
* deferred-preview flow emits the same attachment twice: first
* with `status: 'pending'` and `text: null`, then again with
* `status: 'ready'` (and text/textFormat) or `'failed'` (with
* previewError). The second event must merge over the first in
* place appending would render the artifact card twice, once
* stuck pending and once resolved. Attachments without a
* `file_id` (lightweight types like web_search / file_search
/* Upsert by `file_id` (falling back to `filepath` for keyed entries
* without one, e.g. code download fallbacks a background poll
* re-emits), SCOPED by toolCallId sibling code calls can share a
* claimed file_id for the same filename, and each card anchors its
* own attachment. The deferred-preview flow emits the same
* attachment twice: first with `status: 'pending'` and `text:
* null`, then again with `status: 'ready'` (and text/textFormat) or
* `'failed'` (with previewError). The second event must merge over
* the first in place appending would render the artifact card
* twice, once stuck pending and once resolved. Attachments with no
* file key (lightweight types like web_search / file_search
* citations) keep the legacy append behavior. */
if (fileId) {
const existingIndex = messageAttachments.findIndex(
(a) => (a as Partial<TFile>).file_id === fileId,
);
const fileKeyOf = (a: TAttachment): string | undefined => {
const { file_id, filepath } = a as Partial<TFile>;
return file_id ?? filepath;
};
const agentIdOf = (a: TAttachment): string | undefined => (a as { agentId?: string }).agentId;
const upsertKey = fileKeyOf(data);
const incomingToolCallId = (data as { toolCallId?: string }).toolCallId;
const incomingAgentId = agentIdOf(data);
if (upsertKey) {
/** A missing toolCallId on either side is a wildcard (deferred-preview
* updates emit bare `{file_id, status}` payloads); only DISTINCT
* toolCallIds keep entries separate sibling code calls can share a
* claimed file_id and each card anchors its own attachment. The same
* wildcard applies to agentId: handoff agents can repeat provider
* tool ids (`call_0`) AND share a claimed file_id, so distinct
* non-null agentIds must stay separate entries or the second agent's
* event would merge over (and re-badge) the first's. */
const existingIndex = messageAttachments.findIndex((a) => {
if (fileKeyOf(a) !== upsertKey) {
return false;
}
const existingToolCallId = (a as { toolCallId?: string }).toolCallId;
if (
existingToolCallId != null &&
incomingToolCallId != null &&
existingToolCallId !== incomingToolCallId
) {
return false;
}
const existingAgentId = agentIdOf(a);
return (
existingAgentId == null ||
incomingAgentId == null ||
existingAgentId === incomingAgentId
);
});
if (existingIndex > -1) {
const existing = messageAttachments[existingIndex] as Partial<TFile>;
const incoming = data as Partial<TFile>;

View file

@ -517,6 +517,7 @@
"com_nav_help": "Help",
"com_nav_help_faq": "Help & FAQ",
"com_nav_info_balance": "Balance shows how many token credits you have left to use. Token credits translate to monetary value (e.g., 1000 credits = $0.001 USD)",
"com_nav_info_code_background": "When enabled, the model can run code executions in the background: the conversation continues immediately while the code runs, and the model retrieves the result later with the background task tool. Output and generated files still attach to the original code run. Requires the app-level background tools capability.",
"com_nav_info_default_temporary_chat": "When enabled, new chats will start with temporary chat mode activated by default. Temporary chats are not saved to your history.",
"com_nav_info_during_run_action": "Choose what pressing Enter does while the assistant is still responding. \"Steer the response\" inserts your message into the current response at its next step; \"Queue for after\" sends it as a new turn once the response finishes. You can always override this per message from the send button.",
"com_nav_info_enter_to_send": "When enabled, pressing `ENTER` will send your message. When disabled, pressing Enter will add a new line, and you'll need to press `CTRL + ENTER` / `⌘ + ENTER` to send your message.",
@ -913,6 +914,8 @@
"com_ui_back": "Back",
"com_ui_back_to_builder": "Back to builder",
"com_ui_back_to_prompts": "Back to Prompts",
"com_ui_background_finished": "Finished in background",
"com_ui_background_running": "Running in background",
"com_ui_backup_code_number": "Code #{{number}}",
"com_ui_backup_codes": "Backup Codes",
"com_ui_backup_codes_regenerate_error": "There was an error regenerating backup codes",
@ -976,6 +979,7 @@
"com_ui_close_var": "Close {{0}}",
"com_ui_close_window": "Close Window",
"com_ui_code": "Code",
"com_ui_code_background": "Background execution",
"com_ui_collapse": "Collapse",
"com_ui_collapse_chat": "Collapse Chat",
"com_ui_collapse_summary": "Collapse Summary",

View file

@ -0,0 +1,32 @@
import type { TAttachment } from 'librechat-data-provider';
import { filterAttachmentsForPart, mapAttachments } from '../map';
const att = (overrides: Record<string, unknown>): TAttachment =>
({ toolCallId: 'call_0', file_id: 'f1', ...overrides }) as unknown as TAttachment;
describe('filterAttachmentsForPart', () => {
it('drops attachments owned by a different agent (repeated provider ids)', () => {
const attachments = [att({ agentId: 'agent_a' }), att({ agentId: 'agent_b', file_id: 'f2' })];
const filtered = filterAttachmentsForPart(attachments, 'agent_b');
expect(filtered).toHaveLength(1);
expect((filtered?.[0] as { file_id?: string }).file_id).toBe('f2');
});
it('treats missing agentId on either side as a wildcard', () => {
const attachments = [att({}), att({ agentId: 'agent_a', file_id: 'f2' })];
expect(filterAttachmentsForPart(attachments, 'agent_a')).toHaveLength(2);
expect(filterAttachmentsForPart(attachments, undefined)).toHaveLength(2);
});
it('returns the same reference when nothing is filtered (render stability)', () => {
const attachments = [att({ agentId: 'agent_a' })];
expect(filterAttachmentsForPart(attachments, 'agent_a')).toBe(attachments);
});
});
describe('mapAttachments', () => {
it('groups by toolCallId and drops unkeyed entries', () => {
const map = mapAttachments([att({}), att({ toolCallId: 'call_1' }), att({ toolCallId: '' })]);
expect(Object.keys(map).sort()).toEqual(['call_0', 'call_1']);
});
});

View file

@ -24,6 +24,27 @@ export function mapAttachments(attachments: Array<t.TAttachment | null | undefin
return attachmentMap;
}
/**
* Filters a part's mapped attachments to those owned by the part's agent:
* provider tool-call ids repeat across agents in handoff responses (e.g.
* `call_0`), so `toolCallId` alone can route one agent's harvested files to a
* sibling agent's card. An attachment without `agentId` matches any part
* (single-agent runs and legacy rows); a part without `agentId` accepts all.
*/
export function filterAttachmentsForPart(
attachments: t.TAttachment[] | undefined,
partAgentId?: string,
): t.TAttachment[] | undefined {
if (!attachments || partAgentId == null) {
return attachments;
}
const filtered = attachments.filter((attachment) => {
const agentId = (attachment as { agentId?: string }).agentId;
return agentId == null || agentId === partAgentId;
});
return filtered.length === attachments.length ? attachments : filtered;
}
/** Maps Files by `file_id` for quick lookup */
export function mapFiles(files: t.TFile[]) {
const fileMap = {} as Record<string, t.TFile>;

View file

@ -413,10 +413,10 @@ describe('loadAgent', () => {
deps,
);
// eligible MCP tool opts in; excluded built-ins (web_search, execute_code) do not
// eligible tools opt in (MCP + code execution); excluded built-ins (web_search) do not
expect(result?.tool_options?.crm_lookup).toEqual({ run_in_background: true });
expect(result?.tool_options?.web_search).toBeUndefined();
expect(result?.tool_options?.execute_code).toBeUndefined();
expect(result?.tool_options?.execute_code).toEqual({ run_in_background: true });
});
test('synthesizes background tool_options from a model spec (runInBackground: true), and not without it', async () => {

View file

@ -11,6 +11,7 @@ import {
registerBackgroundTaskTool,
buildBackgroundHandleContent,
runCheckBackgroundTask,
getBackgroundCodeDelivery,
backgroundTaskRegistry,
BackgroundTaskRegistryClass,
CHECK_BACKGROUND_TASK_NAME,
@ -26,10 +27,8 @@ const mcpDef = (name: string): LCTool =>
}) as unknown as LCTool;
describe('isBackgroundEligibleToolName', () => {
it('excludes direct-path, host-special, code-session, and machinery tools', () => {
it('excludes direct-path, host-special, and machinery tools', () => {
for (const name of [
'execute_code',
'bash_tool',
'read_file',
'skill',
'tool_search',
@ -62,6 +61,11 @@ describe('isBackgroundEligibleToolName', () => {
expect(isBackgroundEligibleToolName(name)).toBe(true);
}
});
it('allows the code-execution pair (natively backgroundable)', () => {
expect(isBackgroundEligibleToolName('execute_code')).toBe(true);
expect(isBackgroundEligibleToolName('bash_tool')).toBe(true);
});
});
describe('isBackgroundRequested / stripRunInBackgroundArg', () => {
@ -297,20 +301,21 @@ describe('synthesizeBackgroundToolOptions', () => {
).toBeUndefined();
});
it('marks only eligible tools (excludes code/HITL/attachment built-ins)', () => {
it('marks only eligible tools (excludes HITL/attachment built-ins; code tools are eligible)', () => {
const options = synthesizeBackgroundToolOptions(
['search_mcp_docs', 'execute_code', 'ask_user_question', 'web_search', 'lookup_customer'],
{ ephemeralAgent: { run_in_background: true } },
);
expect(options).toEqual({
search_mcp_docs: { run_in_background: true },
execute_code: { run_in_background: true },
lookup_customer: { run_in_background: true },
});
});
it('returns undefined when nothing is eligible', () => {
expect(
synthesizeBackgroundToolOptions(['execute_code', 'skill'], {
synthesizeBackgroundToolOptions(['read_file', 'skill'], {
modelSpec: { runInBackground: true },
}),
).toBeUndefined();
@ -339,6 +344,35 @@ describe('BackgroundTaskRegistryClass', () => {
expect(task?.result).toBe('DONE');
});
it('stamps strictly-increasing createdAt even for same-millisecond dispatches', () => {
/* `createdAt` orders writers in the stale-output guard, which accepts
* equal stamps for idempotent re-commits a wall-clock tie between two
* DIFFERENT dispatches would let the older one overwrite the newer. */
const registry = new BackgroundTaskRegistryClass();
const frozenNow = Date.now();
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(frozenNow);
try {
const first = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_a',
toolName: 'execute_code',
});
const second = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_b',
toolName: 'execute_code',
});
if ('atCapacity' in first || 'atCapacity' in second) {
throw new Error('unexpected capacity');
}
expect(second.task.createdAt).toBeGreaterThan(first.task.createdAt);
} finally {
nowSpy.mockRestore();
}
});
it('is idempotent within the same run (never double-dispatches on replay)', () => {
const registry = new BackgroundTaskRegistryClass();
const first = registry.create({
@ -446,6 +480,7 @@ describe('BackgroundTaskRegistryClass', () => {
const claimed = registry.claimArtifact('u1', 'c1', created.task.id);
expect(claimed).toEqual({
toolName: 'search_mcp_docs',
toolCallId: 'call_art',
artifact: { files: ['a.png'] },
content: 'DONE',
});
@ -454,6 +489,128 @@ describe('BackgroundTaskRegistryClass', () => {
expect(registry.get('u1', 'c1', created.task.id)?.artifact).toBeUndefined();
});
it('keeps harvest state (messageId, attachments) independent of the one-shot artifact claim', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_code',
toolName: 'execute_code',
messageId: 'dispatch-msg',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
registry.complete('u1', 'c1', created.task.id, {
content: 'stdout',
artifact: { session_id: 'exec-1', files: [{ id: 'f1' }] },
harvestStarted: true,
});
const claimed = registry.claimArtifact('u1', 'c1', created.task.id);
expect(claimed).toEqual({
toolName: 'execute_code',
toolCallId: 'call_code',
messageId: 'dispatch-msg',
harvestStarted: true,
artifact: { session_id: 'exec-1', files: [{ id: 'f1' }] },
content: 'stdout',
});
expect(registry.claimArtifact('u1', 'c1', created.task.id)).toBeUndefined();
/** Attachments can land AFTER the artifact was claimed (harvest is
* detached) and stay retrievable on every later poll. */
const attachments = [{ file_id: 'f1', toolCallId: 'call_code' }];
registry.attachHarvest('u1', 'c1', created.task.id, attachments);
expect(registry.get('u1', 'c1', created.task.id)?.attachments).toEqual(attachments);
});
it('revokeHarvest hands delivery back to the fallback path, restoring a claimed artifact', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_code',
toolName: 'execute_code',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
const artifact = { session_id: 'exec-1', files: [{ id: 'f1' }] };
registry.complete('u1', 'c1', created.task.id, {
content: 'stdout',
artifact,
harvestStarted: true,
});
/** Poll claimed the artifact while the harvest was in flight… */
expect(registry.claimArtifact('u1', 'c1', created.task.id)?.harvestStarted).toBe(true);
/** then the harvest failed: revoke restores the artifact for the
* legacy fallback and clears the suppression flag. */
registry.revokeHarvest('u1', 'c1', created.task.id, artifact);
const task = registry.get('u1', 'c1', created.task.id);
expect(task?.harvestStarted).toBeUndefined();
expect(task?.artifact).toEqual(artifact);
expect(registry.claimArtifact('u1', 'c1', created.task.id)?.harvestStarted).toBeUndefined();
});
it('exposes reaped (timed-out) tasks to the heal path when harvest was armed at dispatch', () => {
jest.useFakeTimers();
try {
const created = backgroundTaskRegistry.create({
userId: 'reap_user',
conversationId: 'reap_convo',
toolCallId: 'call_reaped',
toolName: 'execute_code',
messageId: 'dispatch-msg',
harvestStarted: true,
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
/** Past the running TTL the sweeper reaps the task to an error; the
* dispatch-time harvest flag keeps it visible to marker/re-anchor
* delivery so the original card doesn't stay on "running" forever. */
jest.advanceTimersByTime(31 * 60 * 1000);
const delivery = getBackgroundCodeDelivery({
userId: 'reap_user',
conversationId: 'reap_convo',
args: { background_task_id: created.task.id },
});
expect(delivery).toEqual(
expect.objectContaining({
status: 'error',
toolCallId: 'call_reaped',
messageId: 'dispatch-msg',
error: 'Background task timed out',
}),
);
} finally {
jest.useRealTimers();
}
});
it('fail() can mark a task harvested so failed code tasks join the heal path', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
userId: 'u1',
conversationId: 'c1',
toolCallId: 'call_code_err',
toolName: 'execute_code',
messageId: 'dispatch-msg',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
registry.fail('u1', 'c1', created.task.id, 'Execution error:\n\nboom', {
harvestStarted: true,
});
const task = registry.get('u1', 'c1', created.task.id);
expect(task?.status).toBe('error');
expect(task?.harvestStarted).toBe(true);
});
it('truncates an oversized stored result with an explicit marker (not a silent cut)', () => {
const registry = new BackgroundTaskRegistryClass();
const created = registry.create({
@ -493,6 +650,7 @@ describe('BackgroundTaskRegistryClass', () => {
registry.restoreArtifact('u1', 'c1', created.task.id, claimed?.artifact);
expect(registry.claimArtifact('u1', 'c1', created.task.id)).toEqual({
toolName: 'search_mcp_docs',
toolCallId: 'call_art_retry',
artifact: { files: ['a.png'] },
content: 'DONE',
});
@ -604,6 +762,95 @@ describe('BackgroundTaskRegistryClass', () => {
});
});
describe('applyBackgroundToolCalls — code-pair expansion', () => {
it('an execute_code opt-in covers the runtime bash_tool definition', () => {
const defs = [mcpDef('bash_tool')];
const registry: LCToolRegistry = new Map(defs.map((d) => [d.name, { ...d }]));
const result = applyBackgroundToolCalls({
toolDefinitions: defs,
toolRegistry: registry,
toolOptions: { execute_code: { run_in_background: true } },
});
expect(result.backgroundToolNames).toEqual(['bash_tool']);
const bashDef = result.toolDefinitions.find((d) => d.name === 'bash_tool');
expect(
(bashDef?.parameters as { properties: Record<string, unknown> }).properties[
RUN_IN_BACKGROUND_ARG
],
).toBeDefined();
});
});
describe('getBackgroundCodeDelivery (singleton)', () => {
it('exposes harvest state for a settled task and stays available across polls', () => {
const created = backgroundTaskRegistry.create({
userId: 'delivery_user',
conversationId: 'delivery_convo',
toolCallId: 'call_code',
toolName: 'execute_code',
messageId: 'dispatch-msg',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
backgroundTaskRegistry.complete('delivery_user', 'delivery_convo', created.task.id, {
content: 'stdout',
artifact: { session_id: 'exec-1' },
harvestStarted: true,
});
backgroundTaskRegistry.attachHarvest('delivery_user', 'delivery_convo', created.task.id, [
{ file_id: 'f1' },
]);
const args = { background_task_id: created.task.id };
const first = getBackgroundCodeDelivery({
userId: 'delivery_user',
conversationId: 'delivery_convo',
args,
});
expect(first).toEqual(
expect.objectContaining({
status: 'completed',
toolName: 'execute_code',
toolCallId: 'call_code',
messageId: 'dispatch-msg',
result: 'stdout',
attachments: [{ file_id: 'f1' }],
}),
);
/** Not one-shot: a later poll can still re-emit / re-anchor. */
expect(
getBackgroundCodeDelivery({
userId: 'delivery_user',
conversationId: 'delivery_convo',
args,
})?.attachments,
).toEqual([{ file_id: 'f1' }]);
});
it('returns undefined for tasks without a harvest (non-code tools)', () => {
const created = backgroundTaskRegistry.create({
userId: 'delivery_user',
conversationId: 'delivery_convo2',
toolCallId: 'call_mcp',
toolName: 'search_mcp_docs',
});
if ('atCapacity' in created) {
throw new Error('unexpected capacity');
}
backgroundTaskRegistry.complete('delivery_user', 'delivery_convo2', created.task.id, {
content: 'RESULT',
});
expect(
getBackgroundCodeDelivery({
userId: 'delivery_user',
conversationId: 'delivery_convo2',
args: { background_task_id: created.task.id },
}),
).toBeUndefined();
});
});
describe('runCheckBackgroundTask (singleton)', () => {
it('returns not_found for an unknown id', () => {
const content = runCheckBackgroundTask({

View file

@ -41,6 +41,16 @@ import { truncateMiddle } from '~/utils';
/** Argument the model sets on a tool call to dispatch it in the background. */
export const RUN_IN_BACKGROUND_ARG = 'run_in_background';
/**
* `type` of the synthetic attachment emitted on a poll turn when a harvested
* code task settles the live "this backgrounded call finished" signal for
* the original tool-call card (stdout-only runs emit no file attachments, so
* attachment presence alone can't signal completion). Rides the existing
* `attachment` SSE channel; never persisted. Mirrored in
* `client/src/components/Chat/Messages/Content/Parts/handle.ts`.
*/
export const BACKGROUND_STATUS_ATTACHMENT_TYPE = 'background_task_status';
/** Poll tool name (LibreChat host-special-cased, not an SDK tool). */
export const CHECK_BACKGROUND_TASK_NAME: string = Constants.CHECK_BACKGROUND_TASK;
@ -49,10 +59,14 @@ export const CHECK_BACKGROUND_TASK_NAME: string = Constants.CHECK_BACKGROUND_TAS
* direct/host-special path (so the host `ON_TOOL_EXECUTE` interception never
* sees them), depend on synchronous artifact/code-session continuity, or are
* the background machinery itself.
*
* `execute_code`/`bash_tool` are NOT excluded: they flow through the generic
* `ON_TOOL_EXECUTE` path, the detached invoke carries their code-session
* config, and their completion is harvested onto the dispatch turn's message
* (files persisted + tool-call output patched), with the exec session folded
* back into the run's shared code session on poll.
*/
const EXCLUDED_BACKGROUND_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
AgentConstants.EXECUTE_CODE,
AgentConstants.BASH_TOOL,
AgentConstants.READ_FILE,
AgentConstants.SKILL_TOOL,
AgentConstants.TOOL_SEARCH,
@ -80,6 +94,37 @@ const EXCLUDED_BACKGROUND_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
'image_edit_oai',
]);
/**
* The `execute_code` capability marker expands into the `bash_tool` definition
* at load time (there is one code-execution tool path end-to-end), so a code
* background opt-in keyed by EITHER name covers the pair. Synthesized
* ephemeral/model-spec options and hand-edited agents typically carry only the
* `execute_code` key; without this the actual runtime def (`bash_tool`) would
* silently never receive the injected param.
*/
function expandCodeToolOptions(toolOptions?: AgentToolOptions): AgentToolOptions | undefined {
if (!toolOptions) {
return toolOptions;
}
const codeOptIn =
toolOptions[AgentConstants.EXECUTE_CODE]?.run_in_background === true ||
toolOptions[AgentConstants.BASH_TOOL]?.run_in_background === true;
if (!codeOptIn) {
return toolOptions;
}
return {
...toolOptions,
[AgentConstants.EXECUTE_CODE]: {
...toolOptions[AgentConstants.EXECUTE_CODE],
run_in_background: true,
},
[AgentConstants.BASH_TOOL]: {
...toolOptions[AgentConstants.BASH_TOOL],
run_in_background: true,
},
};
}
/**
* Whether a tool may be dispatched in the background. Handoff tools
* (`lc_transfer_to_*`) run through the direct path and are excluded by prefix.
@ -333,7 +378,8 @@ export function applyBackgroundToolCalls(params: {
*/
excludeTool?: (toolName: string) => boolean;
}): { toolDefinitions: LCTool[]; backgroundToolNames: string[] } {
const { toolRegistry, toolOptions, excludeTool } = params;
const { toolRegistry, excludeTool } = params;
const toolOptions = expandCodeToolOptions(params.toolOptions);
const defs = params.toolDefinitions ?? [];
if (!toolOptions || !Object.values(toolOptions).some((o) => o?.run_in_background === true)) {
return { toolDefinitions: defs, backgroundToolNames: [] };
@ -410,6 +456,11 @@ export interface BackgroundTask {
id: string;
toolName: string;
toolCallId: string;
/** The dispatch turn's response messageId, for post-hoc result anchoring. */
messageId?: string;
/** The dispatching agent, disambiguating repeated provider tool-call ids
* (e.g. `call_0`) across agents when patching the dispatch turn. */
agentId?: string;
status: BackgroundTaskStatus;
/** Tool result content once completed. */
result?: string;
@ -419,6 +470,20 @@ export interface BackgroundTask {
* it can't ride that turn). Cleared once delivered to free memory.
*/
artifact?: unknown;
/**
* Attachments persisted onto the dispatch turn's message by the
* completion-time harvest (code tools). Retained until the task is swept so
* every poll can re-emit them on its live stream (the client upserts by
* `file_id`, so re-emission is idempotent) and re-anchor the row patch.
*/
attachments?: unknown[];
/**
* True when a completion-time harvest was dispatched for this task (code
* tools with a wired persister). Suppresses the poll turn's legacy
* `toolEndCallback` delivery the harvest already persisted the files with
* the ORIGINAL tool-call identity.
*/
harvestStarted?: boolean;
/** True once the artifact has been handed to a live poll turn's callback. */
artifactDelivered?: boolean;
/** Error message when status === 'error'. */
@ -446,6 +511,19 @@ const MAX_RESULT_CHARS = 100_000;
const MAX_ARTIFACT_CHARS = 10_000_000;
const GLOBAL_SWEEP_INTERVAL_MS = 60 * 1000;
let lastDispatchStamp = 0;
/**
* Strictly-increasing dispatch stamp. `createdAt` orders writers in the
* stale-output guard (`sourceDispatchedAt`), which accepts equal stamps so
* idempotent re-commits of the SAME task pass two same-millisecond
* dispatches would tie on raw `Date.now()` and let the older task overwrite
* the newer one's committed file. Process-local, like the registry itself.
*/
function nextDispatchStamp(now: number): number {
lastDispatchStamp = lastDispatchStamp < now ? now : lastDispatchStamp + 1;
return lastDispatchStamp;
}
function toStoredContent(content: unknown): string {
const asString = typeof content === 'string' ? content : JSON.stringify(content ?? '');
return truncateMiddle(asString, MAX_RESULT_CHARS);
@ -562,8 +640,13 @@ export class BackgroundTaskRegistryClass {
conversationId: string;
toolCallId: string;
toolName: string;
messageId?: string;
runId?: string;
agentId?: string;
/** Set at dispatch when a settle-time harvest WILL run, so tasks that
* never settle (reaped as timed out) still take the marker/heal path
* instead of leaving the original card on "running" forever. */
harvestStarted?: boolean;
}): { task: BackgroundTask; isNew: boolean } | { atCapacity: true } {
const now = Date.now();
this.sweep(now);
@ -611,8 +694,11 @@ export class BackgroundTaskRegistryClass {
id: randomUUID(),
toolName: params.toolName,
toolCallId: params.toolCallId,
messageId: params.messageId,
agentId: params.agentId,
...(params.harvestStarted === true ? { harvestStarted: true } : {}),
status: 'running',
createdAt: now,
createdAt: nextDispatchStamp(now),
updatedAt: now,
};
bucket.tasks.set(task.id, task);
@ -638,18 +724,37 @@ export class BackgroundTaskRegistryClass {
userId: string,
conversationId: string,
taskId: string,
result: { content: unknown; artifact?: unknown },
result: { content: unknown; artifact?: unknown; harvestStarted?: boolean },
): void {
this.update(userId, conversationId, taskId, {
status: 'completed',
result: toStoredContent(result.content),
artifact: toStoredArtifact(taskId, result.artifact),
...(result.harvestStarted === true ? { harvestStarted: true } : {}),
/** Marks that an artifact existed even after `claimArtifact` clears it,
* so re-polls keep the "produced an artifact" note. */
artifactDelivered: false,
});
}
/**
* Records the attachments a (possibly still in-flight when polled)
* completion-time harvest persisted for a settled task. Arrives after
* `complete()` because the harvest must not gate task completion the
* dispatch turn's message row may not exist until that turn finalizes.
*/
attachHarvest(
userId: string,
conversationId: string,
taskId: string,
attachments: unknown[],
): void {
if (attachments.length === 0) {
return;
}
this.update(userId, conversationId, taskId, { attachments });
}
/**
* Returns a completed task's artifact exactly once, marking it delivered and
* clearing it. The poll turn routes it to a live `toolEndCallback` so the
@ -663,7 +768,16 @@ export class BackgroundTaskRegistryClass {
userId: string,
conversationId: string,
taskId: string,
): { toolName: string; artifact: unknown; content?: string } | undefined {
):
| {
toolName: string;
toolCallId: string;
messageId?: string;
harvestStarted?: boolean;
artifact: unknown;
content?: string;
}
| undefined {
const bucket = this.buckets.get(this.key(userId, conversationId));
const task = bucket?.tasks.get(taskId);
if (!task || task.status !== 'completed' || task.artifact == null || task.artifactDelivered) {
@ -672,7 +786,14 @@ export class BackgroundTaskRegistryClass {
const artifact = task.artifact;
task.artifactDelivered = true;
task.artifact = undefined;
return { toolName: task.toolName, artifact, content: task.result };
return {
toolName: task.toolName,
toolCallId: task.toolCallId,
messageId: task.messageId,
harvestStarted: task.harvestStarted,
artifact,
content: task.result,
};
}
/**
@ -686,12 +807,44 @@ export class BackgroundTaskRegistryClass {
if (!task || task.artifact != null) {
return;
}
task.artifact = artifact;
/** Same size bound as `complete()` a restore path must not resurrect
* an artifact the memory cap already discarded. */
task.artifact = toStoredArtifact(taskId, artifact);
task.artifactDelivered = false;
}
fail(userId: string, conversationId: string, taskId: string, error: string): void {
this.update(userId, conversationId, taskId, { status: 'error', error });
fail(
userId: string,
conversationId: string,
taskId: string,
error: string,
options?: { harvestStarted?: boolean },
): void {
this.update(userId, conversationId, taskId, {
status: 'error',
error,
...(options?.harvestStarted === true ? { harvestStarted: true } : {}),
});
}
/**
* Reverses `harvestStarted` after the detached harvest failed to persist
* anything, restoring the artifact if a poll already claimed it, so the
* legacy poll-turn `toolEndCallback` delivery takes over on a later poll
* instead of the files being silently lost.
*/
revokeHarvest(userId: string, conversationId: string, taskId: string, artifact?: unknown): void {
const bucket = this.buckets.get(this.key(userId, conversationId));
const task = bucket?.tasks.get(taskId);
if (!task) {
return;
}
task.harvestStarted = undefined;
if (task.artifact == null && artifact != null) {
task.artifact = artifact;
task.artifactDelivered = false;
}
task.updatedAt = Date.now();
}
get(userId: string, conversationId: string, taskId: string): BackgroundTask | undefined {
@ -774,6 +927,23 @@ function resultFields(
return { result_available: true, result_chars: task.result.length };
}
function taskNote(task: BackgroundTask): Pick<SerializedBackgroundTask, 'note'> {
if (task.attachments != null && task.attachments.length > 0) {
return {
note: 'Generated files were saved and attached to the tool call that dispatched this task.',
};
}
if (task.harvestStarted === true && task.status === 'completed') {
return {
note: 'Output and any generated files are being attached to the tool call that dispatched this task.',
};
}
if (task.artifact != null || task.artifactDelivered === true) {
return { note: 'The tool produced an artifact that is not included inline.' };
}
return {};
}
function serializeTask(
task: BackgroundTask,
{ includeResult }: { includeResult: boolean },
@ -784,9 +954,7 @@ function serializeTask(
status: task.status,
progress: task.status === 'running' ? 0 : 1,
...resultFields(task, includeResult),
...(task.artifact != null || task.artifactDelivered === true
? { note: 'The tool produced an artifact that is not included inline.' }
: {}),
...taskNote(task),
...(task.error !== undefined ? { error: task.error } : {}),
};
}
@ -830,12 +998,30 @@ export function claimBackgroundArtifact(params: {
userId: string;
conversationId: string;
args: unknown;
}): { taskId: string; toolName: string; artifact: unknown; content?: string } | undefined {
/** Evaluated before claiming; a `false` return leaves the artifact held. */
shouldClaim?: (task: BackgroundTask) => boolean;
}):
| {
taskId: string;
toolName: string;
toolCallId: string;
messageId?: string;
harvestStarted?: boolean;
artifact: unknown;
content?: string;
}
| undefined {
const rawId = coerceArgsObject(params.args)?.background_task_id;
const taskId = typeof rawId === 'string' && rawId.trim() !== '' ? rawId.trim() : undefined;
if (!taskId) {
return undefined;
}
if (params.shouldClaim) {
const task = backgroundTaskRegistry.get(params.userId, params.conversationId, taskId);
if (!task || !params.shouldClaim(task)) {
return undefined;
}
}
const claimed = backgroundTaskRegistry.claimArtifact(
params.userId,
params.conversationId,
@ -844,6 +1030,54 @@ export function claimBackgroundArtifact(params: {
return claimed ? { taskId, ...claimed } : undefined;
}
/**
* Read-only view of a settled code task's harvest state for the poll turn:
* attachments to re-emit on the live stream and the identity needed to
* re-anchor the row patch (a HITL-pause/resume full-row save can revert it;
* re-application is idempotent). Independent of the one-shot artifact claim so
* late-landing harvests still deliver on subsequent polls.
*/
export function getBackgroundCodeDelivery(params: {
userId: string;
conversationId: string;
args: unknown;
}):
| {
taskId: string;
status: BackgroundTaskStatus;
toolName: string;
toolCallId: string;
messageId?: string;
agentId?: string;
harvestStarted?: boolean;
result?: string;
error?: string;
attachments?: unknown[];
}
| undefined {
const rawId = coerceArgsObject(params.args)?.background_task_id;
const taskId = typeof rawId === 'string' && rawId.trim() !== '' ? rawId.trim() : undefined;
if (!taskId) {
return undefined;
}
const task = backgroundTaskRegistry.get(params.userId, params.conversationId, taskId);
if (!task || task.harvestStarted !== true) {
return undefined;
}
return {
taskId,
status: task.status,
toolName: task.toolName,
toolCallId: task.toolCallId,
messageId: task.messageId,
agentId: task.agentId,
harvestStarted: task.harvestStarted,
result: task.result,
error: task.error,
attachments: task.attachments,
};
}
/** Reverses a `claimBackgroundArtifact` after a failed delivery (see `restoreArtifact`). */
export function restoreBackgroundArtifact(params: {
userId: string;

View file

@ -4,7 +4,15 @@ import { CHECK_BACKGROUND_TASK_NAME } from './background';
import { createToolExecuteHandler } from './handlers';
interface BatchInput {
toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }>;
toolCalls: Array<{
id: string;
name: string;
args: Record<string, unknown>;
stepId?: string;
turn?: number;
codeSessionContext?: { session_id: string; files?: Array<Record<string, unknown>> };
runtimeSessionHint?: string;
}>;
agentId: string;
configurable: Record<string, unknown>;
metadata: Record<string, unknown>;
@ -524,3 +532,428 @@ describe('createToolExecuteHandler — background tool calls', () => {
expect(toolEndCalls[0]).toEqual({ name: 'search_mcp_docs', artifact: { files: ['a.png'] } });
});
});
describe('createToolExecuteHandler — backgrounded code execution', () => {
interface CodeToolState {
calls: number;
throwError?: boolean;
lastInput?: Record<string, unknown>;
lastConfig?: { toolCall?: Record<string, unknown> };
}
const CODE_ARTIFACT = {
session_id: 'exec-sess',
files: [{ id: 'f1', name: 'plot.png', storage_session_id: 'store-1' }],
};
const makeCodeTool = (state: CodeToolState) =>
({
name: 'execute_code',
description: 'run code',
schema: z.object({ lang: z.string(), code: z.string() }),
invoke: async (
input: Record<string, unknown>,
config: { toolCall?: Record<string, unknown> },
) => {
state.calls += 1;
state.lastInput = input;
state.lastConfig = config;
if (state.throwError) {
throw new Error('Execution error:\n\nboom');
}
return { content: 'stdout:\nhello', artifact: CODE_ARTIFACT };
},
}) as unknown as StructuredToolInterface;
const codeCall = (overrides: Record<string, unknown> = {}) => ({
id: 'call_code',
name: 'execute_code',
args: { lang: 'py', code: 'print(1)', run_in_background: true },
stepId: 'step_1',
turn: 2,
codeSessionContext: {
session_id: 'sess-prev',
files: [{ id: 'in1', name: 'data.csv', storage_session_id: 'store-0', resource_id: 'r1' }],
},
runtimeSessionHint: 'convo-hint',
...overrides,
});
it('carries full code-session config into the detached invoke, harvests onto the dispatch turn, and re-emits on poll', async () => {
const state: CodeToolState = { calls: 0 };
const persistCalls: Array<Record<string, unknown>> = [];
const emitted: unknown[] = [];
const toolEndCalls: unknown[] = [];
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [makeCodeTool(state)] }),
toolEndCallback: (async (data: { output?: unknown }) => {
toolEndCalls.push(data.output);
}) as unknown as Parameters<typeof createToolExecuteHandler>[0]['toolEndCallback'],
persistBackgroundCodeResult: async (params) => {
persistCalls.push(params as unknown as Record<string, unknown>);
return { attachments: [{ file_id: 'f1', toolCallId: params.toolCallId }] };
},
emitAttachment: (attachment) => {
emitted.push(attachment);
},
});
const configurable = buildConfig(['execute_code']);
const metadata = { thread_id: 'exec_convo_code', run_id: 'msg-dispatch' };
const dispatch = await runBatch(handler, {
toolCalls: [codeCall()],
agentId: 'a',
configurable,
metadata,
});
const handle = JSON.parse(dispatch[0].content);
expect(handle.status).toBe('running');
await flushMicrotasks();
await flushMicrotasks();
await flushMicrotasks();
// detached invoke received the same session/file config a foreground call gets
expect(state.calls).toBe(1);
expect(state.lastInput).toEqual({ lang: 'py', code: 'print(1)' });
const toolCall = state.lastConfig?.toolCall ?? {};
expect(toolCall.session_id).toBe('sess-prev');
expect(toolCall._injected_files).toEqual([
{ id: 'in1', name: 'data.csv', storage_session_id: 'store-0', resource_id: 'r1' },
]);
expect(toolCall._runtime_session_hint).toBe('convo-hint');
expect(toolCall.id).toBe('call_code');
expect(toolCall.stepId).toBe('step_1');
// completion-time harvest anchored to the ORIGINAL dispatch identity
expect(persistCalls).toHaveLength(1);
expect(persistCalls[0]).toEqual(
expect.objectContaining({
toolName: 'execute_code',
toolCallId: 'call_code',
messageId: 'msg-dispatch',
conversationId: 'exec_convo_code',
dispatchedAt: expect.any(Number),
output: 'stdout:\nhello',
artifact: CODE_ARTIFACT,
}),
);
// nothing rode the finalized dispatch turn's callback
expect(toolEndCalls).toHaveLength(0);
const poll = (await runBatch(handler, {
toolCalls: [
{
id: 'call_poll',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: handle.background_task_id },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_code', run_id: 'msg-poll' },
})) as Array<{ content: string; artifact?: unknown }>;
await flushMicrotasks();
const polled = JSON.parse(poll[0].content);
expect(polled.status).toBe('completed');
expect(polled.result).toContain('hello');
expect(polled.note).toContain('attached to the tool call');
// harvested attachments re-emitted on the live poll stream (not
// re-processed), followed by the live completion marker
expect(emitted).toEqual([
{ file_id: 'f1', toolCallId: 'call_code' },
expect.objectContaining({
type: 'background_task_status',
/** Agent-suffixed: sibling agents' `call_0` markers must not upsert
* over each other client-side. */
file_id: 'bg-call_code-a',
messageId: 'msg-dispatch',
toolCallId: 'call_code',
status: 'completed',
}),
]);
expect(toolEndCalls).toHaveLength(0);
// the claimed artifact rides the poll result so the SDK folds the exec session
expect(poll[0].artifact).toEqual(CODE_ARTIFACT);
// the poll also re-anchors the row patch (idempotent heal after full-row saves)
expect(persistCalls).toHaveLength(2);
expect(persistCalls[1]).toEqual(
expect.objectContaining({
reapply: true,
toolCallId: 'call_code',
messageId: 'msg-dispatch',
output: 'stdout:\nhello',
attachments: [{ file_id: 'f1', toolCallId: 'call_code' }],
}),
);
});
it('does not gate task completion on the harvest (same-turn polls see completed)', async () => {
const state: CodeToolState = { calls: 0 };
const toolEndCalls: unknown[] = [];
const emitted: unknown[] = [];
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [makeCodeTool(state)] }),
toolEndCallback: (async (data: { output?: unknown }) => {
toolEndCalls.push(data.output);
}) as unknown as Parameters<typeof createToolExecuteHandler>[0]['toolEndCallback'],
/** The dispatch turn's row does not exist until that turn finalizes, so
* the real persister can block for a long time completion must not. */
persistBackgroundCodeResult: () => new Promise(() => undefined),
emitAttachment: (attachment) => {
emitted.push(attachment);
},
});
const configurable = buildConfig(['execute_code']);
const metadata = { thread_id: 'exec_convo_code_slow', run_id: 'msg-slow' };
const dispatch = await runBatch(handler, {
toolCalls: [codeCall({ id: 'call_code_slow' })],
agentId: 'a',
configurable,
metadata,
});
await flushMicrotasks();
await flushMicrotasks();
const poll = (await runBatch(handler, {
toolCalls: [
{
id: 'call_poll_slow',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: JSON.parse(dispatch[0].content).background_task_id },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_code_slow', run_id: 'msg-slow-poll' },
})) as Array<{ content: string; artifact?: unknown }>;
const polled = JSON.parse(poll[0].content);
expect(polled.status).toBe('completed');
expect(polled.result).toContain('hello');
expect(polled.note).toContain('being attached');
// harvest hasn't landed: no file attachments yet and no poll-identity
// fallback — but the completion marker fires (execution IS finished)
expect(emitted).toEqual([
expect.objectContaining({ type: 'background_task_status', status: 'completed' }),
]);
expect(toolEndCalls).toHaveLength(0);
expect(poll[0].artifact).toEqual(CODE_ARTIFACT);
});
it('falls back to poll-turn delivery when the harvest fails (files not lost)', async () => {
const state: CodeToolState = { calls: 0 };
const toolEndCalls: Array<{ name?: string; artifact?: unknown }> = [];
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [makeCodeTool(state)] }),
toolEndCallback: (async (data: { output?: { name?: string; artifact?: unknown } }) => {
toolEndCalls.push({ name: data.output?.name, artifact: data.output?.artifact });
}) as unknown as Parameters<typeof createToolExecuteHandler>[0]['toolEndCallback'],
persistBackgroundCodeResult: async () => {
throw new Error('mongo down');
},
});
const configurable = buildConfig(['execute_code']);
const metadata = { thread_id: 'exec_convo_code_hfail', run_id: 'msg-hfail' };
const dispatch = await runBatch(handler, {
toolCalls: [codeCall({ id: 'call_code_hfail' })],
agentId: 'a',
configurable,
metadata,
});
await flushMicrotasks();
await flushMicrotasks();
await flushMicrotasks();
const poll = (await runBatch(handler, {
toolCalls: [
{
id: 'call_poll_hfail',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: JSON.parse(dispatch[0].content).background_task_id },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_code_hfail', run_id: 'msg-hfail-poll' },
})) as Array<{ content: string; artifact?: unknown }>;
/** Harvest revoked: the poll turn's callback processes the files instead. */
expect(toolEndCalls).toHaveLength(1);
expect(toolEndCalls[0].artifact).toEqual(CODE_ARTIFACT);
expect(poll[0].artifact).toEqual(CODE_ARTIFACT);
});
it('re-anchors failed code tasks on poll (error output heals like success)', async () => {
const state: CodeToolState = { calls: 0, throwError: true };
const persistCalls: Array<Record<string, unknown>> = [];
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [makeCodeTool(state)] }),
persistBackgroundCodeResult: async (params) => {
persistCalls.push(params as unknown as Record<string, unknown>);
return { attachments: [] };
},
});
const configurable = buildConfig(['execute_code']);
const metadata = { thread_id: 'exec_convo_code_errheal', run_id: 'msg-errheal' };
const dispatch = await runBatch(handler, {
toolCalls: [codeCall({ id: 'call_code_errheal' })],
agentId: 'a',
configurable,
metadata,
});
await flushMicrotasks();
await flushMicrotasks();
await runBatch(handler, {
toolCalls: [
{
id: 'call_poll_errheal',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: JSON.parse(dispatch[0].content).background_task_id },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_code_errheal', run_id: 'msg-errheal-poll' },
});
await flushMicrotasks();
expect(persistCalls).toHaveLength(2);
expect(persistCalls[1]).toEqual(
expect.objectContaining({ reapply: true, toolCallId: 'call_code_errheal' }),
);
expect(String(persistCalls[1].output)).toContain('boom');
});
it('re-anchors reaped (timed-out) tasks with the client-recognized failure wrapper', async () => {
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
try {
const persistCalls: Array<Record<string, unknown>> = [];
const hangingTool = {
name: 'execute_code',
description: 'never settles',
schema: z.object({ lang: z.string(), code: z.string() }),
invoke: () => new Promise(() => undefined),
} as unknown as StructuredToolInterface;
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [hangingTool] }),
persistBackgroundCodeResult: async (params) => {
persistCalls.push(params as unknown as Record<string, unknown>);
return { attachments: [] };
},
});
const configurable = buildConfig(['execute_code']);
const dispatch = await runBatch(handler, {
toolCalls: [codeCall({ id: 'call_code_reap' })],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_reap', run_id: 'msg-reap' },
});
/** Past the running TTL the registry reaps the never-settling task. */
jest.advanceTimersByTime(31 * 60 * 1000);
const poll = await runBatch(handler, {
toolCalls: [
{
id: 'call_poll_reap',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: JSON.parse(dispatch[0].content).background_task_id },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_reap', run_id: 'msg-reap-poll' },
});
await flushMicrotasks();
expect(JSON.parse(poll[0].content).status).toBe('error');
const reapply = persistCalls.find((call) => call.reapply === true);
expect(reapply).toBeDefined();
expect(String(reapply?.output)).toMatch(/^Error:\s*\[execute_code\]\s*tool call failed:/);
expect(String(reapply?.output)).toContain('timed out');
} finally {
jest.useRealTimers();
}
});
it('patches the dispatch turn with the error message when a backgrounded code call fails', async () => {
const state: CodeToolState = { calls: 0, throwError: true };
const persistCalls: Array<Record<string, unknown>> = [];
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [makeCodeTool(state)] }),
persistBackgroundCodeResult: async (params) => {
persistCalls.push(params as unknown as Record<string, unknown>);
return { attachments: [] };
},
});
const configurable = buildConfig(['execute_code']);
const metadata = { thread_id: 'exec_convo_code_err', run_id: 'msg-err' };
const dispatch = await runBatch(handler, {
toolCalls: [codeCall({ id: 'call_code_err' })],
agentId: 'a',
configurable,
metadata,
});
await flushMicrotasks();
await flushMicrotasks();
await flushMicrotasks();
expect(persistCalls).toHaveLength(1);
expect(String(persistCalls[0].output)).toContain('boom');
/** Parity with foreground failures (the graph's error wrapper) so the
* client's `isError` detection flags the patched output on reload. */
expect(String(persistCalls[0].output)).toMatch(
/^Error:\s*\[execute_code\]\s*tool call failed:/,
);
expect(persistCalls[0].artifact).toBeUndefined();
const poll = await runBatch(handler, {
toolCalls: [
{
id: 'call_poll_err',
name: CHECK_BACKGROUND_TASK_NAME,
args: { background_task_id: JSON.parse(dispatch[0].content).background_task_id },
},
],
agentId: 'a',
configurable,
metadata: { thread_id: 'exec_convo_code_err', run_id: 'msg-err-poll' },
});
const polled = JSON.parse(poll[0].content);
expect(polled.status).toBe('error');
expect(polled.error).toContain('boom');
});
it('downgrades code calls to foreground when the host wires no persister (OpenAI-compat routes)', async () => {
const state: CodeToolState = { calls: 0 };
const handler = createToolExecuteHandler({
loadTools: async () => ({ loadedTools: [makeCodeTool(state)] }),
/** No persistBackgroundCodeResult: generated files could only anchor
* via a later poll (or never) safer to run the call foreground. */
});
const configurable = buildConfig(['execute_code']);
const metadata = { thread_id: 'exec_convo_code_fg', run_id: 'msg-fg' };
const results = (await runBatch(handler, {
toolCalls: [codeCall({ id: 'call_code_fg' })],
agentId: 'a',
configurable,
metadata,
})) as Array<{ content: string; artifact?: unknown }>;
expect(state.calls).toBe(1);
expect(results[0].content).not.toContain('background_task_id');
expect(results[0].content).toContain('hello');
expect(results[0].artifact).toEqual(CODE_ARTIFACT);
/** The injected flag never reaches the real tool. */
expect(state.lastInput).toEqual({ lang: 'py', code: 'print(1)' });
});
});

View file

@ -20,12 +20,14 @@ import {
runCheckBackgroundTask,
claimBackgroundArtifact,
restoreBackgroundArtifact,
getBackgroundCodeDelivery,
isBackgroundRequested,
hasRunInBackgroundArg,
stripRunInBackgroundArg,
buildBackgroundHandleContent,
buildBackgroundCapacityContent,
stripBackgroundFromToolDefinitions,
BACKGROUND_STATUS_ATTACHMENT_TYPE,
CHECK_BACKGROUND_TASK_NAME,
RUN_IN_BACKGROUND_ARG,
} from './background';
@ -74,6 +76,29 @@ export interface ToolExecuteOptions {
}>;
/** Callback to process tool artifacts (code output files, file citations, etc.) */
toolEndCallback?: ToolEndCallback;
/**
* Persists a backgrounded code-execution result onto the dispatch turn once
* the detached call settles: downloads/persists generated files, patches the
* original tool-call part's `output`, and appends the attachments to the
* dispatch turn's message row. Returns the persisted attachments so the poll
* turn can re-emit them on its live stream. With `reapply: true` it only
* re-applies the (idempotent) row patch using the provided attachments no
* file processing to heal a full-row save that reverted the anchor.
*/
persistBackgroundCodeResult?: (params: {
toolName: string;
toolCallId: string;
messageId?: string;
conversationId?: string;
agentId?: string;
dispatchedAt?: number;
output?: string;
artifact?: unknown;
attachments?: unknown[];
reapply?: boolean;
}) => Promise<{ attachments?: unknown[] } | null>;
/** Emits an `attachment` SSE event on the current request's live stream. */
emitAttachment?: (attachment: unknown) => void;
/**
* Loads a skill by name with ACL constraint (returns full body for injection).
*
@ -3463,8 +3488,122 @@ function getFileAuthoringQueueKey(
* This handler receives batched tool calls, loads the required tools,
* executes them in parallel, and resolves with the results.
*/
/**
* Foreground tool failures reach persisted parts wrapped by the graph as
* `Error: [toolName] tool call failed: <message>` the exact shape the
* client's `isError` detection keys on. Detached failures bypass the graph,
* so wrap them identically before patching the dispatch row, or a reloaded
* failed background run renders as clean stdout.
*/
function toCodeToolFailure(toolName: string, message: string): string {
if (/^Error:\s*(\[.*?\]\s*)*tool call failed:/i.test(message)) {
return message;
}
return `Error: [${toolName}] tool call failed: ${message}`;
}
/**
* Invoke-time `toolCall` config for a call: identity plus the stateful
* runtime-session hint and code-session context (`session_id` +
* `_injected_files`) for sandbox-bound tools. Shared by the foreground path
* and background dispatch so a detached code call keeps the same session and
* file continuity a foreground call gets.
*/
function buildToolCallConfig(
tc: ToolCallRequest,
mergedConfigurable: Record<string, unknown>,
): Record<string, unknown> {
const toolCallConfig: Record<string, unknown> = {
id: tc.id,
stepId: tc.stepId,
turn: tc.turn,
};
/* Stateful runtime-session hint: the SDK resolves it onto
* the request for execute_code/bash (orthogonal to the
* transient exec-session below a first call has a hint but
* no session yet). The remote executors read it off
* `config.toolCall._runtime_session_hint`; without this the
* event-driven ON_TOOL_EXECUTE path drops it and every
* conversation collapses onto the Code API's `default`
* session (no per-conversation isolation). */
if (tc.runtimeSessionHint != null && tc.runtimeSessionHint !== '') {
toolCallConfig._runtime_session_hint = tc.runtimeSessionHint;
}
if (tc.codeSessionContext && isCodeSessionAwareToolCall(tc.name, mergedConfigurable)) {
toolCallConfig.session_id = tc.codeSessionContext.session_id;
if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) {
toolCallConfig._injected_files = tc.codeSessionContext.files;
/* Last LC-controlled point before the wire. Mirrors
* codeapi's validator context so the two log sides
* correlate on a single grep. */
const refs = tc.codeSessionContext.files as Array<{
id?: unknown;
resource_id?: unknown;
storage_session_id?: unknown;
kind?: unknown;
version?: unknown;
name?: unknown;
}>;
const summary = refs.map((f) => ({
kind: f.kind,
hasResourceId: typeof f.resource_id === 'string' && !!f.resource_id,
hasStorageSessionId: typeof f.storage_session_id === 'string' && !!f.storage_session_id,
hasVersion: typeof f.version === 'number',
}));
let missingResourceId = 0;
let missingStorageSessionId = 0;
let missingVersion = 0;
const kindCounts: Record<string, number> = {};
for (const s of summary) {
if (!s.hasResourceId) missingResourceId++;
if (!s.hasStorageSessionId) missingStorageSessionId++;
if (!s.hasVersion) missingVersion++;
const k = typeof s.kind === 'string' ? s.kind : 'unknown';
kindCounts[k] = (kindCounts[k] ?? 0) + 1;
}
logger.debug(
`[code-env:inject] tool=${tc.name} files=${refs.length} ` +
`missingResourceId=${missingResourceId} ` +
`missingStorageSessionId=${missingStorageSessionId} ` +
`missingVersion=${missingVersion} ` +
`kinds=${JSON.stringify(kindCounts)}`,
);
if (missingResourceId > 0) {
logger.warn(
`[code-env:inject] ${missingResourceId}/${refs.length} files missing resource_id ` +
`for tool=${tc.name} — codeapi will reject with 400`,
{ summary },
);
}
} else {
/* Empty `_injected_files` on a code-execution tool
* call. Almost always means the seeding chain
* (primeCodeFiles initialSessions
* CodeSessionContext) dropped the file upstream.
* `session_id` is still emitted for continuity, but
* concrete file refs must arrive through
* `_injected_files`; agents no longer falls back to
* `/files/<sid>`. Pair with `[primeCodeFiles]`
* traces below to locate the layer that lost the ref. */
logger.warn(
`[code-env:inject] tool=${tc.name} _injected_files=0 — sandbox will see no input files`,
{
tool: tc.name,
session_id: tc.codeSessionContext.session_id,
codeSessionContextHasFiles: tc.codeSessionContext.files !== undefined,
codeSessionContextFileCount: tc.codeSessionContext.files?.length ?? 0,
},
);
}
}
return toolCallConfig;
}
export function createToolExecuteHandler(options: ToolExecuteOptions): EventHandler {
const { loadTools, toolEndCallback } = options;
const { loadTools, toolEndCallback, persistBackgroundCodeResult, emitAttachment } = options;
return {
handle: async (_event: string, data: ToolExecuteBatchRequest) => {
@ -3558,11 +3697,15 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
errorMessage: `Tool ${tc.name} not found`,
};
}
const isCodeCall = isCodeSessionAwareToolCall(tc.name, mergedConfigurable);
const harvestEnabled = isCodeCall && persistBackgroundCodeResult != null;
const created = backgroundTaskRegistry.create({
userId: backgroundUserId,
conversationId: backgroundConversationId,
toolCallId: tc.id,
toolName: tc.name,
messageId: backgroundRunId,
harvestStarted: harvestEnabled,
/** Scope idempotency to the agent + run + turn so a later turn's
* or a second agent's repeated provider id (e.g. `call_0`)
* starts a fresh task instead of colliding. */
@ -3579,13 +3722,92 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
const { task, isNew } = created;
if (isNew) {
const strippedArgs = stripRunInBackgroundArg(tc.args);
/** Persists the settled result onto the dispatch turn's message
* (patch the tool-call part's output, persist generated files,
* append attachments), so a backgrounded code call reads like a
* foreground one on reload and in later model turns even if
* the model never polls. Runs DETACHED from task completion:
* the dispatch row may not exist until that turn finalizes, so
* gating `complete()` on the patch would livelock same-turn
* polls on `running`. Failures degrade to poll-only delivery. */
const harvestCodeResult = (params: {
output?: string;
artifact?: unknown;
}): void => {
if (!harvestEnabled || !persistBackgroundCodeResult) {
return;
}
void (async () => {
try {
const persisted = await persistBackgroundCodeResult({
toolName: tc.name,
toolCallId: tc.id,
messageId: backgroundRunId,
conversationId: backgroundConversationId,
/** Disambiguates repeated provider ids (e.g. `call_0`)
* across agents sharing one response message. */
agentId,
/** Stale-output ordering is decided by DISPATCH order,
* not harvest wall-clock: a slow old task settling
* after a newer run wrote the same filename must not
* overwrite it. */
dispatchedAt: task.createdAt,
...params,
});
if (persisted == null) {
/** Harvest never persisted anything (missing anchor
* identity): hand delivery back to the legacy poll-turn
* callback, restoring the artifact if a poll already
* claimed it while the harvest was in flight. */
backgroundTaskRegistry.revokeHarvest(
backgroundUserId,
backgroundConversationId,
task.id,
params.artifact,
);
return;
}
const attachments = persisted.attachments;
if (attachments != null && attachments.length > 0) {
backgroundTaskRegistry.attachHarvest(
backgroundUserId,
backgroundConversationId,
task.id,
attachments,
);
}
} catch (persistError) {
logger.warn(
`[background] Failed to persist code result for task ${task.id}:`,
persistError,
);
backgroundTaskRegistry.revokeHarvest(
backgroundUserId,
backgroundConversationId,
task.id,
params.artifact,
);
}
})();
};
void (async () => {
try {
const result = (await tool.invoke(normalizeToolInvokeArgs(strippedArgs, tool), {
toolCall: { id: tc.id, stepId: tc.stepId, turn: tc.turn },
/** Full invoke config (not just identity): a detached
* code call still needs `session_id`/`_injected_files`/
* `_runtime_session_hint` or it runs fileless on the
* Code API's default runtime session. */
toolCall: buildToolCallConfig(tc, mergedConfigurable),
configurable: mergedConfigurable,
metadata,
} as Record<string, unknown>)) as { content?: unknown; artifact?: unknown };
if (tc.runtimeSessionHint != null && tc.runtimeSessionHint !== '') {
void markSandboxReady(tc.runtimeSessionHint);
}
const content =
isCodeCall && typeof result.content === 'string'
? cleanCodeToolOutput(result.content)
: result.content;
/** Hold any artifact (images, files, UI resources,
* citations) on the task instead of routing it through
* this dispatch turn's callback: a slow background call
@ -3597,16 +3819,26 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
backgroundUserId,
backgroundConversationId,
task.id,
{ content: result.content, artifact: result.artifact },
{ content, artifact: result.artifact, harvestStarted: harvestEnabled },
);
harvestCodeResult({
output: typeof content === 'string' ? content : undefined,
artifact: result.artifact,
});
} catch (toolError) {
const { message } = getSafeToolError(toolError);
const errorOutput = isCodeCall ? toCodeToolFailure(tc.name, message) : message;
backgroundTaskRegistry.fail(
backgroundUserId,
backgroundConversationId,
task.id,
message,
errorOutput,
/** Failed code tasks join the heal path too: without this,
* a full-row save reverting the error patch would leave
* the dispatch card on the handle JSON forever. */
{ harvestStarted: harvestEnabled },
);
harvestCodeResult({ output: errorOutput });
}
})();
}
@ -3626,15 +3858,33 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
args: tc.args,
});
/** Deliver a completed task's artifact through THIS live poll
* turn's callback (once): the tool's own turn finalized before
* the artifact resolved, so this is where it can be persisted. */
if (toolEndCallback) {
const pending = claimBackgroundArtifact({
userId: backgroundUserId,
conversationId: backgroundConversationId,
args: tc.args,
});
if (pending) {
* turn (once): the tool's own turn finalized before the
* artifact resolved, so this is where it can be surfaced.
* Code tasks are claimed even without a `toolEndCallback`
* their files were already persisted at completion, and the
* claimed artifact still has to ride this result so the SDK
* folds the exec session into the run's shared code session. */
let codeSessionArtifact: unknown;
const pending = claimBackgroundArtifact({
userId: backgroundUserId,
conversationId: backgroundConversationId,
args: tc.args,
shouldClaim: (pendingTask) =>
toolEndCallback != null ||
isCodeSessionAwareToolCall(pendingTask.toolName, mergedConfigurable),
});
if (pending) {
const isCodeTask = isCodeSessionAwareToolCall(
pending.toolName,
mergedConfigurable,
);
if (isCodeTask) {
codeSessionArtifact = pending.artifact;
}
/** Harvested code tasks never route through the poll turn's
* callback their files were already persisted with the
* ORIGINAL tool-call identity by the completion harvest. */
if (toolEndCallback && !(isCodeTask && pending.harvestStarted === true)) {
try {
await toolEndCallback(
{
@ -3666,17 +3916,108 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
}
}
}
/** Harvest delivery is independent of the one-shot artifact
* claim so attachments that land AFTER an earlier poll still
* reach a later one. Re-emitting is idempotent (the client
* upserts by `file_id`) and the row patch re-application
* guards against a HITL-pause/resume full-row save having
* reverted the anchored result. */
const delivery = getBackgroundCodeDelivery({
userId: backgroundUserId,
conversationId: backgroundConversationId,
args: tc.args,
});
if (
delivery &&
delivery.status !== 'running' &&
isCodeSessionAwareToolCall(delivery.toolName, mergedConfigurable)
) {
for (const attachment of delivery.attachments ?? []) {
try {
emitAttachment?.(attachment);
} catch (emitError) {
logger.warn(
'[background] Failed to emit harvested attachment on poll:',
emitError,
);
}
}
/** Live completion signal for the original card: stdout-only
* runs emit no file attachments, so a settled task also
* emits a synthetic status marker (upserted client-side by
* its stable id; filtered out of file rendering). */
if (emitAttachment && delivery.messageId) {
try {
emitAttachment({
type: BACKGROUND_STATUS_ATTACHMENT_TYPE,
/** Provider ids repeat across agents in handoffs;
* the agent suffix keeps sibling markers from
* upserting over each other client-side. */
file_id: `bg-${delivery.toolCallId}${
delivery.agentId != null ? `-${delivery.agentId}` : ''
}`,
messageId: delivery.messageId,
conversationId: backgroundConversationId,
toolCallId: delivery.toolCallId,
agentId: delivery.agentId,
status: delivery.status,
});
} catch (emitError) {
logger.warn(
'[background] Failed to emit background status marker on poll:',
emitError,
);
}
}
if (persistBackgroundCodeResult && delivery.messageId) {
/** Error tasks carry their message in `error`, not
* `result`; reaped (timed-out) tasks store it raw, so
* wrap here `toCodeToolFailure` is a no-op for
* already-wrapped detached failures. */
const reapplyOutput =
delivery.status === 'error'
? toCodeToolFailure(
delivery.toolName,
delivery.error ?? delivery.result ?? 'Background task failed',
)
: delivery.result;
void persistBackgroundCodeResult({
toolName: delivery.toolName,
toolCallId: delivery.toolCallId,
messageId: delivery.messageId,
conversationId: backgroundConversationId,
agentId: delivery.agentId,
output: reapplyOutput,
attachments: delivery.attachments,
reapply: true,
}).catch((reapplyError) => {
logger.warn(
'[background] Failed to re-anchor harvested code result:',
reapplyError,
);
});
}
}
return reportResult({
toolCallId: tc.id,
status: 'success' as const,
content: pollContent,
...(codeSessionArtifact != null ? { artifact: codeSessionArtifact } : {}),
});
}
if (
backgroundToolSet.has(tc.name) &&
isBackgroundRequested(tc.args) &&
!toolRequiresEphemeralConnection(toolMap.get(tc.name))
!toolRequiresEphemeralConnection(toolMap.get(tc.name)) &&
/** Code tools depend on the completion-time harvest to anchor
* results; hosts that don't wire the persister (OpenAI-compat
* and Responses controllers) downgrade code calls to
* foreground rather than losing generated files. */
!(
isCodeSessionAwareToolCall(tc.name, mergedConfigurable) &&
persistBackgroundCodeResult == null
)
) {
return reportResult(dispatchBackgroundToolCall(tc));
}
@ -3811,95 +4152,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
}
try {
const toolCallConfig: Record<string, unknown> = {
id: tc.id,
stepId: tc.stepId,
turn: tc.turn,
};
/* Stateful runtime-session hint: the SDK resolves it onto
* the request for execute_code/bash (orthogonal to the
* transient exec-session below a first call has a hint but
* no session yet). The remote executors read it off
* `config.toolCall._runtime_session_hint`; without this the
* event-driven ON_TOOL_EXECUTE path drops it and every
* conversation collapses onto the Code API's `default`
* session (no per-conversation isolation). */
if (tc.runtimeSessionHint != null && tc.runtimeSessionHint !== '') {
toolCallConfig._runtime_session_hint = tc.runtimeSessionHint;
}
if (
tc.codeSessionContext &&
isCodeSessionAwareToolCall(tc.name, mergedConfigurable)
) {
toolCallConfig.session_id = tc.codeSessionContext.session_id;
if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) {
toolCallConfig._injected_files = tc.codeSessionContext.files;
/* Last LC-controlled point before the wire. Mirrors
* codeapi's validator context so the two log sides
* correlate on a single grep. */
const refs = tc.codeSessionContext.files as Array<{
id?: unknown;
resource_id?: unknown;
storage_session_id?: unknown;
kind?: unknown;
version?: unknown;
name?: unknown;
}>;
const summary = refs.map((f) => ({
kind: f.kind,
hasResourceId: typeof f.resource_id === 'string' && !!f.resource_id,
hasStorageSessionId:
typeof f.storage_session_id === 'string' && !!f.storage_session_id,
hasVersion: typeof f.version === 'number',
}));
let missingResourceId = 0;
let missingStorageSessionId = 0;
let missingVersion = 0;
const kindCounts: Record<string, number> = {};
for (const s of summary) {
if (!s.hasResourceId) missingResourceId++;
if (!s.hasStorageSessionId) missingStorageSessionId++;
if (!s.hasVersion) missingVersion++;
const k = typeof s.kind === 'string' ? s.kind : 'unknown';
kindCounts[k] = (kindCounts[k] ?? 0) + 1;
}
logger.debug(
`[code-env:inject] tool=${tc.name} files=${refs.length} ` +
`missingResourceId=${missingResourceId} ` +
`missingStorageSessionId=${missingStorageSessionId} ` +
`missingVersion=${missingVersion} ` +
`kinds=${JSON.stringify(kindCounts)}`,
);
if (missingResourceId > 0) {
logger.warn(
`[code-env:inject] ${missingResourceId}/${refs.length} files missing resource_id ` +
`for tool=${tc.name} — codeapi will reject with 400`,
{ summary },
);
}
} else {
/* Empty `_injected_files` on a code-execution tool
* call. Almost always means the seeding chain
* (primeCodeFiles initialSessions
* CodeSessionContext) dropped the file upstream.
* `session_id` is still emitted for continuity, but
* concrete file refs must arrive through
* `_injected_files`; agents no longer falls back to
* `/files/<sid>`. Pair with `[primeCodeFiles]`
* traces below to locate the layer that lost the ref. */
logger.warn(
`[code-env:inject] tool=${tc.name} _injected_files=0 — sandbox will see no input files`,
{
tool: tc.name,
session_id: tc.codeSessionContext.session_id,
codeSessionContextHasFiles: tc.codeSessionContext.files !== undefined,
codeSessionContextFileCount: tc.codeSessionContext.files?.length ?? 0,
},
);
}
}
const toolCallConfig = buildToolCallConfig(tc, mergedConfigurable);
if (
tc.name === Constants.BASH_PROGRAMMATIC_TOOL_CALLING ||

View file

@ -0,0 +1,212 @@
import { logger } from '@librechat/data-schemas';
import type { ServerRequest } from '~/types';
/**
* Leading sub-second retries cover the common case of a fast background task
* settling moments before the dispatch turn finalizes its message row an
* immediate follow-up turn should find the attachments already anchored.
* The long tail covers dispatch turns that keep running for minutes.
*/
const BACKGROUND_PATCH_RETRY_DELAYS_MS = [
250, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 60_000, 120_000, 180_000, 240_000, 300_000,
];
interface HarvestFileRef {
id: string;
name: string;
storage_session_id?: string;
inherited?: boolean;
}
interface HarvestArtifact {
session_id?: string;
files?: HarvestFileRef[];
}
export interface ProcessedCodeOutput {
file?: { file_id: string } & Record<string, unknown>;
finalize?: () => Promise<unknown>;
previewRevision?: number;
}
export interface CodeHarvestDeps {
req: ServerRequest;
/** Data-schemas method: idempotent tool-call part patch + attachment append. */
updateToolCallResult: (params: {
userId: string;
messageId: string;
conversationId: string;
toolCallId: string;
agentId?: string;
output?: string;
attachments?: unknown[];
}) => Promise<{ matched: boolean; unfinished: boolean }>;
/** Host file service: downloads and persists one code output file. */
processCodeOutput: (params: {
req: ServerRequest;
id: string;
name: string;
messageId: string;
toolCallId: string;
conversationId: string;
agentId?: string;
session_id?: string;
freshClaimAfter?: number;
}) => Promise<ProcessedCodeOutput | null>;
/** Host file service: runs the deferred office-preview extraction. */
runPreviewFinalize: (params: {
finalize?: () => Promise<unknown>;
fileId: string;
previewRevision?: number;
}) => void;
}
export interface CodeHarvestParams {
toolName: string;
toolCallId: string;
messageId?: string;
conversationId?: string;
/** Dispatching agent scopes the part patch when provider tool-call ids
* repeat across agents in one response message. */
agentId?: string;
/** When the background task was DISPATCHED the ordering anchor for the
* stale-output guard. A slow task settling after a newer run wrote the
* same filename must not overwrite it, so harvest wall-clock is wrong. */
dispatchedAt?: number;
output?: string;
artifact?: unknown;
attachments?: unknown[];
reapply?: boolean;
}
export type CodeHarvestHandler = (
params: CodeHarvestParams,
) => Promise<{ attachments: unknown[] } | null>;
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Handles a backgrounded code-execution result once the detached call settles:
* persists generated files (same `processCodeOutput` path as the foreground
* callback, anchored to the ORIGINAL messageId/toolCallId), then patches the
* dispatch turn's tool-call part output and appends the attachments to that
* message row so the backgrounded call reads like a foreground one on reload
* and in later model turns, and next-turn file priming picks the outputs up.
*
* The dispatch turn may still be streaming when a fast task settles (its
* response message is only saved at turn end), so the row patch retries on a
* backoff schedule before giving up; files are already persisted either way,
* and the poll turn still delivers content/attachments live. With
* `reapply: true` it only re-applies the (idempotent) row patch using the
* provided attachments no file processing to heal a full-row save that
* reverted the anchor.
*/
export function createBackgroundCodeResultHandler(deps: CodeHarvestDeps): CodeHarvestHandler {
const { req, updateToolCallResult, processCodeOutput, runPreviewFinalize } = deps;
return async ({
toolCallId,
messageId,
conversationId,
agentId,
dispatchedAt,
output,
artifact,
attachments: knownAttachments,
reapply,
}) => {
const userId = req.user?.id;
if (!userId || !messageId || !conversationId) {
return null;
}
if (reapply === true) {
const reapplied = await updateToolCallResult({
userId,
messageId,
conversationId,
toolCallId,
agentId,
output,
attachments: knownAttachments ?? [],
});
if (!reapplied.matched) {
logger.debug(
`[background] Re-anchor found no row for message ${messageId} (tool call ${toolCallId}).`,
);
}
return { attachments: knownAttachments ?? [] };
}
const attachments: unknown[] = [];
/** Ordering guard: a filename claim whose row was really written after
* this task was DISPATCHED belongs to a newer run the harvest must
* not overwrite it with stale bytes, no matter how late it settles. */
const freshClaimAfter = dispatchedAt ?? Date.now();
const codeArtifact = (artifact ?? {}) as HarvestArtifact;
const files = Array.isArray(codeArtifact.files) ? codeArtifact.files : [];
for (const file of files) {
if (file.inherited === true) {
continue;
}
try {
const result = await processCodeOutput({
req,
id: file.id,
name: file.name,
messageId,
toolCallId,
conversationId,
/** Rides the attachment so the client can route it to the right
* card when provider ids repeat across agents. */
agentId,
session_id: file.storage_session_id ?? codeArtifact.session_id,
freshClaimAfter,
});
if (result?.file) {
attachments.push(result.file);
/** No live stream at completion time; the client's preview polling
* (or the poll turn's re-emit) surfaces the finalized preview. */
runPreviewFinalize({
finalize: result.finalize,
fileId: result.file.file_id,
previewRevision: result.previewRevision,
});
}
} catch (error) {
logger.error('[background] Error processing code output file:', error);
}
}
let patched = false;
for (let attempt = 0; attempt <= BACKGROUND_PATCH_RETRY_DELAYS_MS.length; attempt++) {
const result = await updateToolCallResult({
userId,
messageId,
conversationId,
toolCallId,
agentId,
output,
attachments,
});
patched = result.matched;
/** An `unfinished` match is a mid-turn partial save (client disconnect):
* the eventual finalize overwrites it with in-memory content the
* handle JSON so keep re-applying (idempotent) until a finalized row
* holds the patch. */
if (
(result.matched && !result.unfinished) ||
attempt === BACKGROUND_PATCH_RETRY_DELAYS_MS.length
) {
break;
}
await sleep(BACKGROUND_PATCH_RETRY_DELAYS_MS[attempt]);
}
if (!patched) {
logger.warn(
`[background] Could not anchor code result onto message ${messageId} (tool call ${toolCallId}); ` +
'the dispatch turn never persisted. Poll delivery still returns the result.',
);
}
return { attachments };
};
}

View file

@ -9,6 +9,7 @@ export * from './context';
export * from './discovery';
export * from './edges';
export * from './handlers';
export * from './harvest';
export * from './initialize';
export * from './legacy';
export * from './memory';

View file

@ -1515,7 +1515,17 @@ export async function createRun({
// API's per-user default runtime session and cannot see files bash_tool
// just wrote in the conversation's session. Requires @librechat/agents
// with codeSessionToolNames support (agents#283); older versions ignore it.
codeSessionToolNames: [CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, Constants.READ_FILE],
// `check_background_task` participates so a backgrounded code call's exec
// session/files (returned as the poll result's artifact when claimed) fold
// into the shared code session, keeping same-run continuity for later
// foreground code calls. Poll results carry an artifact only for code
// tasks, so non-code polls never touch the session.
codeSessionToolNames: [
CREATE_FILE_TOOL_NAME,
EDIT_FILE_TOOL_NAME,
Constants.READ_FILE,
CHECK_BACKGROUND_TASK_NAME,
],
// Derive the Langfuse trace id deterministically from runId so message
// feedback can be scored against the trace without a lookup (see the
// feedback route in api/server/routes/messages.js). No-op unless Langfuse

View file

@ -121,6 +121,64 @@ describe('File Methods', () => {
expect(tenantAAgain.file_id).toBe('file-tenant-a');
});
it('does not bump updatedAt on re-claim (id reservation, not a content write)', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const File = mongoose.models.File;
await fileMethods.claimCodeFile({
filename: 'stable.csv',
conversationId: 'conversation-ts',
file_id: 'stable-file',
user: userId,
});
const written = new Date('2024-01-01T00:00:00.000Z');
await File.updateOne(
{ file_id: 'stable-file' },
{ $set: { updatedAt: written } },
{ timestamps: false },
);
/** The background harvest's out-of-order guard compares `updatedAt`
* against the harvest start; a claim bumping it would make every
* existing filename look freshly written and misfire the guard. */
const reclaimed = await fileMethods.claimCodeFile({
filename: 'stable.csv',
conversationId: 'conversation-ts',
file_id: 'stable-file-second',
user: userId,
});
expect(reclaimed.file_id).toBe('stable-file');
expect(new Date(reclaimed.updatedAt as unknown as string).getTime()).toBe(written.getTime());
});
it('stamps sourceDispatchedAt on claim INSERT only (existing claims untouched)', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const inserted = await fileMethods.claimCodeFile({
filename: 'stamped.csv',
conversationId: 'conversation-stamp',
file_id: 'stamped-file',
user: userId,
sourceDispatchedAt: 111,
});
expect(
(inserted.metadata as { sourceDispatchedAt?: number } | undefined)?.sourceDispatchedAt,
).toBe(111);
/** A later claimant's stamp must not overwrite the owner's. */
const reclaimed = await fileMethods.claimCodeFile({
filename: 'stamped.csv',
conversationId: 'conversation-stamp',
file_id: 'stamped-file-second',
user: userId,
sourceDispatchedAt: 222,
});
expect(reclaimed.file_id).toBe('stamped-file');
expect(
(reclaimed.metadata as { sourceDispatchedAt?: number } | undefined)?.sourceDispatchedAt,
).toBe(111);
});
it('keeps non-tenant code output claims in the legacy namespace', async () => {
const userId = new mongoose.Types.ObjectId().toString();

View file

@ -53,6 +53,7 @@ export function createFileMethods(mongoose: typeof import('mongoose')): {
file_id: string;
user: string;
tenantId?: string | null;
sourceDispatchedAt?: number;
}) => Promise<IMongoFile>;
createFile: (data: Partial<IMongoFile>, disableTTL?: boolean) => Promise<IMongoFile | null>;
updateFile: (
@ -306,12 +307,21 @@ export function createFileMethods(mongoose: typeof import('mongoose')): {
file_id: string;
user: string;
tenantId?: string | null;
/** The claimant's dispatch-order stamp, persisted on INSERT so a
* freshly claimed (not-yet-written) row still carries an ownership
* signal for the background harvest's stale-output guard. */
sourceDispatchedAt?: number;
}): Promise<IMongoFile> {
const File = mongoose.models.File as Model<IMongoFile>;
const tenantFilter = data.tenantId ? { tenantId: data.tenantId } : { tenantId: null };
const insertData = data.tenantId
? { file_id: data.file_id, user: data.user, tenantId: data.tenantId }
: { file_id: data.file_id, user: data.user };
const insertData = {
file_id: data.file_id,
user: data.user,
...(data.tenantId ? { tenantId: data.tenantId } : {}),
...(data.sourceDispatchedAt != null
? { metadata: { sourceDispatchedAt: data.sourceDispatchedAt } }
: {}),
};
const result = await File.findOneAndUpdate(
{
filename: data.filename,
@ -320,7 +330,11 @@ export function createFileMethods(mongoose: typeof import('mongoose')): {
...tenantFilter,
},
{ $setOnInsert: insertData },
{ upsert: true, new: true },
/** `timestamps: false`: a claim is an id reservation, not a content
* write bumping `updatedAt` here would make the row look freshly
* written to the background harvest's out-of-order guard, which
* compares `updatedAt` against the harvest's start time. */
{ upsert: true, new: true, timestamps: false },
).lean<IMongoFile>();
if (!result) {
throw new Error(

View file

@ -22,6 +22,7 @@ let Message: mongoose.Model<IMessage>;
let saveMessage: ReturnType<typeof createMessageMethods>['saveMessage'];
let getMessages: ReturnType<typeof createMessageMethods>['getMessages'];
let updateMessage: ReturnType<typeof createMessageMethods>['updateMessage'];
let updateToolCallResult: ReturnType<typeof createMessageMethods>['updateToolCallResult'];
let deleteMessages: ReturnType<typeof createMessageMethods>['deleteMessages'];
let bulkSaveMessages: ReturnType<typeof createMessageMethods>['bulkSaveMessages'];
let updateMessageText: ReturnType<typeof createMessageMethods>['updateMessageText'];
@ -40,6 +41,7 @@ beforeAll(async () => {
saveMessage = methods.saveMessage;
getMessages = methods.getMessages;
updateMessage = methods.updateMessage;
updateToolCallResult = methods.updateToolCallResult;
deleteMessages = methods.deleteMessages;
bulkSaveMessages = methods.bulkSaveMessages;
updateMessageText = methods.updateMessageText;
@ -165,6 +167,259 @@ describe('Message Operations', () => {
});
});
describe('updateToolCallResult', () => {
const toolCallContent = () => [
{ type: 'text', text: 'intro' },
{
type: 'tool_call',
tool_call: {
id: 'call_bg',
name: 'execute_code',
args: '{"lang":"py","code":"print(1)"}',
output: '{"background_task_id":"task-1"}',
progress: 1,
},
},
{
type: 'tool_call',
tool_call: { id: 'call_other', name: 'execute_code', args: '{}', output: 'untouched' },
},
];
it('patches only the matching tool_call part and appends attachments atomically', async () => {
await saveMessage(mockCtx, { ...mockMessageData, content: toolCallContent() });
const result = await updateToolCallResult({
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
output: 'stdout:\nhello',
attachments: [{ file_id: 'f1', toolCallId: 'call_bg' }],
});
expect(result).toEqual({ matched: true, unfinished: false });
const saved = await Message.findOne({ messageId: 'msg123', user: 'user123' }).lean();
const content = saved?.content as Array<{
type: string;
tool_call?: { id: string; output?: string };
}>;
expect(content[1].tool_call?.output).toBe('stdout:\nhello');
expect(content[2].tool_call?.output).toBe('untouched');
expect(saved?.attachments).toEqual([{ file_id: 'f1', toolCallId: 'call_bg' }]);
});
it('appends to existing attachments instead of replacing them', async () => {
await saveMessage(mockCtx, {
...mockMessageData,
content: toolCallContent(),
attachments: [{ file_id: 'existing' }] as unknown as IMessage['attachments'],
});
await updateToolCallResult({
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
attachments: [{ file_id: 'f2' }],
});
const saved = await Message.findOne({ messageId: 'msg123', user: 'user123' }).lean();
expect(saved?.attachments).toEqual([{ file_id: 'existing' }, { file_id: 'f2' }]);
});
it('is idempotent: re-applying the same patch does not duplicate attachments', async () => {
await saveMessage(mockCtx, { ...mockMessageData, content: toolCallContent() });
const patch = {
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
output: 'stdout:\nhello',
attachments: [{ file_id: 'f1', toolCallId: 'call_bg' }],
};
await updateToolCallResult(patch);
await updateToolCallResult(patch);
const saved = await Message.findOne({ messageId: 'msg123', user: 'user123' }).lean();
expect(saved?.attachments).toEqual([{ file_id: 'f1', toolCallId: 'call_bg' }]);
const content = saved?.content as Array<{ tool_call?: { output?: string } }>;
expect(content[1].tool_call?.output).toBe('stdout:\nhello');
});
it('dedupes download-fallback attachments (no file_id) by filepath on re-apply', async () => {
await saveMessage(mockCtx, { ...mockMessageData, content: toolCallContent() });
const patch = {
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
attachments: [
{
filepath: '/api/files/code/download/sess-1/f1',
filename: 'big.zip',
toolCallId: 'call_bg',
},
{ file_id: 'f2', toolCallId: 'call_bg' },
],
};
await updateToolCallResult(patch);
await updateToolCallResult(patch);
const saved = await Message.findOne({ messageId: 'msg123', user: 'user123' }).lean();
expect(saved?.attachments).toEqual([
{
filepath: '/api/files/code/download/sess-1/f1',
filename: 'big.zip',
toolCallId: 'call_bg',
},
{ file_id: 'f2', toolCallId: 'call_bg' },
]);
});
it('returns false when the message row does not exist yet (caller retries)', async () => {
const result = await updateToolCallResult({
userId: 'user123',
messageId: 'missing-msg',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
output: 'stdout',
});
expect(result.matched).toBe(false);
});
it('scopes the patch by agentId when provider ids repeat across agents', async () => {
/* Handoff runs append multiple agents' parts to ONE response message,
* and provider ids like call_0 repeat per model response. */
await saveMessage(mockCtx, {
...mockMessageData,
content: [
{
type: 'tool_call',
agentId: 'agent_a',
tool_call: { id: 'call_0', name: 'execute_code', output: 'handle-a' },
},
{
type: 'tool_call',
agentId: 'agent_b',
tool_call: { id: 'call_0', name: 'execute_code', output: 'handle-b' },
},
],
});
const result = await updateToolCallResult({
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_0',
agentId: 'agent_b',
output: 'stdout-b',
});
expect(result.matched).toBe(true);
const saved = await Message.findOne({ messageId: 'msg123', user: 'user123' }).lean();
const content = saved?.content as Array<{ tool_call?: { output?: string } }>;
expect(content[0].tool_call?.output).toBe('handle-a');
expect(content[1].tool_call?.output).toBe('stdout-b');
});
it('flags unfinished partial rows so callers keep re-applying until finalize', async () => {
await saveMessage(mockCtx, {
...mockMessageData,
content: toolCallContent(),
unfinished: true,
} as Parameters<typeof saveMessage>[1]);
const result = await updateToolCallResult({
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
output: 'stdout:\nhello',
});
/** The patch still lands (idempotent), but the finalize save will
* overwrite this partial row with in-memory content. */
expect(result).toEqual({ matched: true, unfinished: true });
});
it('keeps a sibling AGENTs attachment when both id and file key collide', async () => {
/* Handoff agents can share a provider tool-call id AND a claimed
* file_id (same filename in one conversation); the second agent's
* anchor must not evict the first agent's card-scoped attachment. */
await saveMessage(mockCtx, { ...mockMessageData, content: toolCallContent() });
await updateToolCallResult({
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
agentId: 'agent_a',
attachments: [{ file_id: 'shared', toolCallId: 'call_bg', agentId: 'agent_a' }],
});
await updateToolCallResult({
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
agentId: 'agent_b',
attachments: [{ file_id: 'shared', toolCallId: 'call_bg', agentId: 'agent_b' }],
});
const saved = await Message.findOne({ messageId: 'msg123', user: 'user123' }).lean();
expect(saved?.attachments).toEqual([
{ file_id: 'shared', toolCallId: 'call_bg', agentId: 'agent_a' },
{ file_id: 'shared', toolCallId: 'call_bg', agentId: 'agent_b' },
]);
});
it('keeps a sibling tool calls attachment when file ids repeat across calls', async () => {
await saveMessage(mockCtx, { ...mockMessageData, content: toolCallContent() });
await updateToolCallResult({
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
attachments: [{ file_id: 'shared', toolCallId: 'call_bg' }],
});
/** A second background call regenerated the same filename same
* claimed file_id, different tool call. The first card must keep
* its attachment (the client anchors by toolCallId). */
await updateToolCallResult({
userId: 'user123',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_other',
attachments: [{ file_id: 'shared', toolCallId: 'call_other' }],
});
const saved = await Message.findOne({ messageId: 'msg123', user: 'user123' }).lean();
expect(saved?.attachments).toEqual([
{ file_id: 'shared', toolCallId: 'call_bg' },
{ file_id: 'shared', toolCallId: 'call_other' },
]);
});
it('does not match another users message', async () => {
await saveMessage(mockCtx, { ...mockMessageData, content: toolCallContent() });
const result = await updateToolCallResult({
userId: 'someone-else',
messageId: 'msg123',
conversationId: mockMessageData.conversationId as string,
toolCallId: 'call_bg',
output: 'hijacked',
});
expect(result.matched).toBe(false);
const saved = await Message.findOne({ messageId: 'msg123', user: 'user123' }).lean();
const content = saved?.content as Array<{ tool_call?: { output?: string } }>;
expect(content[1].tool_call?.output).toBe('{"background_task_id":"task-1"}');
});
});
describe('deleteMessagesSince', () => {
it('should delete messages only for the authenticated user', async () => {
const conversationId = uuidv4();

View file

@ -33,6 +33,15 @@ export interface MessageMethods {
[key: string]: unknown;
}): Promise<IMessage | null>;
updateMessageText(userId: string, params: { messageId: string; text: string }): Promise<void>;
updateToolCallResult(params: {
userId: string;
messageId: string;
conversationId: string;
toolCallId: string;
agentId?: string;
output?: string;
attachments?: unknown[];
}): Promise<{ matched: boolean; unfinished: boolean }>;
updateMessage(
userId: string,
message: Partial<IMessage> & { newMessageId?: string },
@ -266,6 +275,158 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
}
}
/**
* Patches a persisted tool_call content part in place and appends attachments,
* for results that settle after the turn's message was finalized (background
* tool calls). Atomic single update so two tasks completing concurrently on
* the same message cannot lose each other's attachments, and IDEMPOTENT
* (attachments dedupe by `file_id ?? filepath`, scoped to this tool call so
* sibling calls sharing a filename keep their own entries) so it can be
* re-applied to heal a later full-row save that reverted the patch.
*
* Returns `matched: false` when the message row does not exist yet (the
* dispatch turn has not finalized) and surfaces `unfinished` when the
* matched row is a mid-turn partial save (client disconnect) the eventual
* finalize will overwrite the patch with in-memory content, so callers
* should keep re-applying until a finalized row is patched.
*/
async function updateToolCallResult({
userId,
messageId,
conversationId,
toolCallId,
agentId,
output,
attachments,
}: {
userId: string;
messageId: string;
conversationId: string;
toolCallId: string;
/** Scopes the part match when provider tool-call ids repeat across
* agents in one response message (e.g. `call_0` per response); a part
* without agent identity matches any caller (single-agent runs). */
agentId?: string;
output?: string;
attachments?: unknown[];
}): Promise<{ matched: boolean; unfinished: boolean }> {
const stages: Record<string, unknown>[] = [];
if (output !== undefined) {
stages.push({
$set: {
content: {
$map: {
input: { $ifNull: ['$content', []] },
as: 'part',
in: {
$cond: [
{
$and: [
{ $eq: ['$$part.type', 'tool_call'] },
{ $eq: ['$$part.tool_call.id', toolCallId] },
...(agentId != null
? [
{
$in: [
{ $ifNull: ['$$part.agentId', '$$part.tool_call.agentId'] },
[null, agentId],
],
},
]
: []),
],
},
{
$mergeObjects: [
'$$part',
{
tool_call: {
$mergeObjects: ['$$part.tool_call', { output: { $literal: output } }],
},
},
],
},
'$$part',
],
},
},
},
},
});
}
if (attachments !== undefined && attachments.length > 0) {
/** Dedupe key mirrors the resume merge: `file_id ?? filepath`, so
* download-fallback attachments (no `file_id`, only a filepath) stay
* idempotent across re-applications instead of duplicating per poll. */
const attachmentKeys = attachments
.map((attachment) => {
const { file_id, filepath } = attachment as { file_id?: unknown; filepath?: unknown };
return typeof file_id === 'string' ? file_id : filepath;
})
.filter((key): key is string => typeof key === 'string');
stages.push({
$set: {
attachments: {
$concatArrays: [
{
$filter: {
input: { $ifNull: ['$attachments', []] },
as: 'existing',
/** Replace only THIS tool call's prior entries: sibling calls
* can legitimately share a `file_id` (the filename claim is
* per-conversation), and the client anchors attachments to
* cards by `toolCallId`. */
cond: {
$not: [
{
$and: [
{
$in: [
{ $ifNull: ['$$existing.file_id', '$$existing.filepath'] },
{ $literal: attachmentKeys },
],
},
{ $eq: ['$$existing.toolCallId', toolCallId] },
/** Provider tool-call ids repeat across agents in
* handoff messages; a sibling agent's attachment
* under the same id/key must survive (missing
* agent identity = legacy wildcard). */
...(agentId != null
? [
{
$in: [{ $ifNull: ['$$existing.agentId', null] }, [null, agentId]],
},
]
: []),
],
},
],
},
},
},
{ $literal: attachments },
],
},
},
});
}
if (stages.length === 0) {
return { matched: false, unfinished: false };
}
try {
const Message = mongoose.models.Message as Model<IMessage>;
const result = await Message.findOneAndUpdate(
{ messageId, user: userId, conversationId },
stages,
{ new: true, projection: { unfinished: 1 } },
).lean<{ unfinished?: boolean } | null>();
return { matched: result != null, unfinished: result?.unfinished === true };
} catch (err) {
logger.error('Error updating tool call result:', err);
throw err;
}
}
/**
* Updates a message and returns sanitized fields.
*/
@ -440,6 +601,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
bulkSaveMessages,
recordMessage,
updateMessageText,
updateToolCallResult,
updateMessage,
deleteMessagesSince,
getMessages,

View file

@ -135,6 +135,14 @@ const file: Schema<IMongoFile> = new Schema(
),
default: undefined,
},
/** Dispatch-order stamp of the last writer (or claimant, on insert):
* the background harvest's stale-output guard compares writer
* dispatch order so an older task settling late cannot overwrite a
* newer task's same-named output. */
sourceDispatchedAt: {
type: Number,
default: undefined,
},
},
expiresAt: {
/* Short-lived upload TTL managed by MongoDB. This is separate from