🪴 feat: Fork Completed Subagents Into Continuable Chats (#15133)

* feat: continue completed subagents as chats

* chore: sort continuation imports
This commit is contained in:
Danny Avila 2026-08-23 10:09:14 -04:00 • committed by GitHub
parent 77cb72e50c
commit 1d9c2fc591
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 225 additions and 8 deletions

View file

@ -11,9 +11,15 @@ const BaseClient = require('~/app/clients/BaseClient');
* Helper function to clone messages with proper parent-child relationships and timestamps
* @param {TMessage[]} messagesToClone - Original messages to clone
* @param {ImportBatchBuilder} importBatchBuilder - Instance of ImportBatchBuilder
* @param {object} [options] - Clone behavior for the source conversation.
* @param {boolean} [options.detachSubagentRuntime=false] - Remove durable child execution metadata.
* @returns {Map<string, string>} Map of original messageIds to new messageIds
*/
function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) {
function cloneMessagesWithTimestamps(
messagesToClone,
importBatchBuilder,
{ detachSubagentRuntime = false } = {},
) {
const idMapping = new Map();
// First pass: create ID mapping and sort messages by parentMessageId
@ -63,6 +69,10 @@ function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) {
parentMessageId: parentId,
createdAt,
};
if (detachSubagentRuntime) {
delete clonedMessage.subagentTask;
delete clonedMessage.subagentTranscript;
}
importBatchBuilder.saveMessage(clonedMessage);
}
@ -136,7 +146,12 @@ async function forkConversation({
messagesToClone = getMessagesUpToTargetLevel(originalMessages, targetMessageId);
}
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder, {
/** A human continuation is an ordinary conversation snapshot, not another
* durable child executor. Preserve visible history while dropping the
* task protocol and private serialized model transcript. */
detachSubagentRuntime: originalConvo.subagentThread != null,
});
const result = importBatchBuilder.finishConversation(
newTitle || originalConvo.title,

View file

@ -178,6 +178,68 @@ describe('forkConversation', () => {
});
expect(bulkSaveConvos.mock.calls[0][0][0]).not.toHaveProperty('subagentThread');
expect(
bulkSaveMessages.mock.calls[0][0].every(
(message) => message.subagentTask == null && message.subagentTranscript == null,
),
).toBe(true);
});
test('drops child execution metadata while retaining its visible transcript', async () => {
getConvo.mockResolvedValue({
...mockConversation,
agent_id: 'agent-1',
subagentThread: {
rootConversationId: 'root-conversation',
parentConversationId: 'parent-conversation',
parentToolCallId: 'parent-tool-call',
subagentType: 'researcher',
subagentKind: 'agent',
depth: 1,
},
});
getMessages.mockResolvedValue([
{
messageId: 'task-1:user',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: true,
text: 'Investigate this.',
subagentTask: { attemptKey: 'attempt-1', status: 'running' },
},
{
messageId: 'task-1:assistant',
parentMessageId: 'task-1:user',
isCreatedByUser: false,
text: 'The investigation is complete.',
subagentTask: { attemptKey: 'attempt-1', status: 'completed' },
subagentTranscript: {
taskId: 'task-1',
mode: 'replace',
messagesJson: '[{"role":"assistant","content":"private"}]',
},
},
]);
await forkConversation({
originalConvoId: 'abc123',
targetMessageId: 'task-1:assistant',
requestUserId: 'user1',
option: ForkOptions.DIRECT_PATH,
});
const savedConversation = bulkSaveConvos.mock.calls[0][0][0];
const savedMessages = bulkSaveMessages.mock.calls[0][0];
expect(savedConversation).toEqual(expect.objectContaining({ agent_id: 'agent-1' }));
expect(savedConversation).not.toHaveProperty('subagentThread');
expect(savedMessages.map((message) => message.text)).toEqual([
'Investigate this.',
'The investigation is complete.',
]);
expect(
savedMessages.every(
(message) => message.subagentTask == null && message.subagentTranscript == null,
),
).toBe(true);
});
test('should fork conversation with branches', async () => {