🔨 chore: extend execAgent with parentMessageId for Gateway regeneration/continue (#13699)

* 🌐 chore: update execServerAgentRuntime i18n copy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

*  feat: extend execAgent with parentMessageId for regeneration/continue via Gateway

Add parentMessageId support to the execAgent API, enabling regeneration and continue-generation flows through the Gateway WebSocket path. When parentMessageId is provided, user message creation is skipped (resume mode) and the new assistant message branches from the specified parent.

Fixes LOBE-6933

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: propagate parentMessageId through execAgents batch and fix test types

- Forward parentMessageId in execAgents executeTask to maintain batch parity with execAgent
- Fix ExecAgentResult mock types in gateway tests
- Fix messages table insert type cast in server router test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-09 21:51:59 +08:00 committed by GitHub
parent 1eb1fca7f2
commit a23e159ef3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 238 additions and 2 deletions

View file

@ -1,6 +1,13 @@
// @vitest-environment node
import { type LobeChatDatabase } from '@lobechat/database';
import { agents, agentsToSessions, sessions, threads, topics } from '@lobechat/database/schemas';
import {
agents,
agentsToSessions,
messages,
sessions,
threads,
topics,
} from '@lobechat/database/schemas';
import { getTestDB } from '@lobechat/database/test-utils';
import { eq } from 'drizzle-orm';
import type * as ModelBankModule from 'model-bank';
@ -373,5 +380,57 @@ describe('AI Agent Router Integration Tests', () => {
}),
);
});
it('should skip user message creation when parentMessageId is provided (regeneration)', async () => {
const caller = aiAgentRouter.createCaller(createTestContext());
// Create a topic and a user message to regenerate from
const [topic] = await serverDB
.insert(topics)
.values({
title: 'Regen Topic',
agentId: testAgentId,
sessionId: testSessionId,
userId,
})
.returning();
const [userMsg] = (await serverDB
.insert(messages)
.values({
role: 'user',
content: 'Original question',
userId,
agentId: testAgentId,
topicId: topic.id,
})
.returning()) as any[];
const result = await caller.execAgent({
agentId: testAgentId,
prompt: 'Original question',
parentMessageId: userMsg.id,
appContext: { topicId: topic.id },
});
expect(result.success).toBe(true);
// Verify only the assistant message was created (no new user message)
const allMessages = await serverDB
.select()
.from(messages)
.where(eq(messages.topicId, topic.id));
const userMessages = allMessages.filter((m) => m.role === 'user');
const assistantMessages = allMessages.filter((m) => m.role === 'assistant');
// Should still have only 1 user message (the original, no new one created)
expect(userMessages).toHaveLength(1);
expect(userMessages[0].id).toBe(userMsg.id);
// Should have 1 assistant message with parentId pointing to the user message
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].parentId).toBe(userMsg.id);
});
});
});

View file

@ -95,6 +95,8 @@ const ExecAgentSchema = z
deviceId: z.string().optional(),
/** Optional existing message IDs to include in context */
existingMessageIds: z.array(z.string()).optional().default([]),
/** Parent message ID for regeneration/continue (skip user message creation, branch from this message) */
parentMessageId: z.string().optional(),
/** The user input/prompt */
prompt: z.string(),
/** The agent slug to run (either agentId or slug is required) */
@ -528,6 +530,7 @@ export const aiAgentRouter = router({
autoStart = true,
deviceId,
existingMessageIds = [],
parentMessageId,
} = input;
log('execAgent: identifier=%s, prompt=%s', agentId || slug, prompt.slice(0, 50));
@ -539,7 +542,10 @@ export const aiAgentRouter = router({
autoStart,
deviceId,
existingMessageIds,
parentMessageId,
prompt,
// When parentMessageId is provided, this is a regeneration/continue — skip user message creation
resume: !!parentMessageId,
slug,
});
} catch (error: any) {
@ -586,6 +592,7 @@ export const aiAgentRouter = router({
autoStart = true,
deviceId,
existingMessageIds = [],
parentMessageId,
} = task;
try {
@ -595,7 +602,10 @@ export const aiAgentRouter = router({
autoStart,
deviceId,
existingMessageIds,
parentMessageId,
prompt,
// When parentMessageId is provided, this is a regeneration/continue — skip user message creation
resume: !!parentMessageId,
slug,
});

View file

@ -16,6 +16,8 @@ export interface ExecAgentTaskParams {
autoStart?: boolean;
deviceId?: string;
existingMessageIds?: string[];
/** Parent message ID for regeneration/continue (skip user message creation, branch from this message) */
parentMessageId?: string;
prompt: string;
slug?: string;
}

View file

@ -1,10 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AgentStreamEvent } from '@/libs/agent-stream';
import { aiAgentService } from '@/services/aiAgent';
import type { GatewayConnection } from '../gateway';
import { GatewayActionImpl } from '../gateway';
vi.mock('@/services/aiAgent', () => ({
aiAgentService: {
execAgentTask: vi.fn(),
},
}));
vi.mock('@/services/message', () => ({
messageService: {
getMessages: vi.fn().mockResolvedValue([]),
},
}));
vi.mock('@/services/topic', () => ({
topicService: {
updateTopicMetadata: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('@/store/user', () => ({
useUserStore: {
getState: vi.fn(() => ({ preference: { lab: {} } })),
},
}));
// ─── Mock Client Factory ───
function createMockClient(): GatewayConnection['client'] & {
@ -236,4 +261,141 @@ describe('GatewayActionImpl', () => {
expect(action.getGatewayConnectionStatus('nonexistent')).toBeUndefined();
});
});
describe('executeGatewayAgent', () => {
function createExecuteTestAction() {
const mockClient = createMockClient();
const state: Record<string, any> = { gatewayConnections: {} };
const set = vi.fn((updater: any) => {
if (typeof updater === 'function') {
Object.assign(state, updater(state));
} else {
Object.assign(state, updater);
}
});
const get = vi.fn(() => ({
...state,
startOperation: vi.fn(() => ({ operationId: 'gw-op-1' })),
associateMessageWithOperation: vi.fn(),
connectToGateway: vi.fn(),
internal_updateTopicLoading: vi.fn(),
replaceMessages: vi.fn(),
switchTopic: vi.fn(),
})) as any;
// Set up window.global_serverConfigStore
(globalThis as any).window = {
global_serverConfigStore: {
getState: () => ({
serverConfig: { agentGatewayUrl: 'https://gateway.test.com' },
}),
},
};
const action = new GatewayActionImpl(set as any, get, undefined);
action.createClient = vi.fn(() => mockClient);
return { action, get, mockClient, set, state };
}
afterEach(() => {
delete (globalThis as any).window;
});
it('should forward parentMessageId to execAgentTask for regeneration', async () => {
const { action } = createExecuteTestAction();
vi.mocked(aiAgentService.execAgentTask).mockResolvedValue({
agentId: 'agent-1',
assistantMessageId: 'ast-1',
autoStarted: true,
createdAt: new Date().toISOString(),
message: 'ok',
operationId: 'server-op-1',
status: 'created',
success: true,
timestamp: new Date().toISOString(),
token: 'test-token',
topicId: 'topic-1',
userMessageId: 'usr-1',
});
await action.executeGatewayAgent({
context: { agentId: 'agent-1', topicId: 'topic-1', threadId: null, scope: 'main' },
message: 'Original question',
parentMessageId: 'user-msg-123',
});
expect(aiAgentService.execAgentTask).toHaveBeenCalledWith(
expect.objectContaining({
parentMessageId: 'user-msg-123',
prompt: 'Original question',
}),
);
});
it('should not include parentMessageId when not provided (normal send)', async () => {
const { action } = createExecuteTestAction();
vi.mocked(aiAgentService.execAgentTask).mockResolvedValue({
agentId: 'agent-1',
assistantMessageId: 'ast-1',
autoStarted: true,
createdAt: new Date().toISOString(),
message: 'ok',
operationId: 'server-op-1',
status: 'created',
success: true,
timestamp: new Date().toISOString(),
token: 'test-token',
topicId: 'topic-1',
userMessageId: 'usr-1',
});
await action.executeGatewayAgent({
context: { agentId: 'agent-1', topicId: 'topic-1', threadId: null, scope: 'main' },
message: 'Hello',
});
expect(aiAgentService.execAgentTask).toHaveBeenCalledWith(
expect.objectContaining({
parentMessageId: undefined,
prompt: 'Hello',
}),
);
});
it('should forward empty prompt for continue generation', async () => {
const { action } = createExecuteTestAction();
vi.mocked(aiAgentService.execAgentTask).mockResolvedValue({
agentId: 'agent-1',
assistantMessageId: 'ast-1',
autoStarted: true,
createdAt: new Date().toISOString(),
message: 'ok',
operationId: 'server-op-1',
status: 'created',
success: true,
timestamp: new Date().toISOString(),
token: 'test-token',
topicId: 'topic-1',
userMessageId: 'usr-1',
});
await action.executeGatewayAgent({
context: { agentId: 'agent-1', topicId: 'topic-1', threadId: null, scope: 'main' },
message: '',
parentMessageId: 'assistant-msg-456',
});
expect(aiAgentService.execAgentTask).toHaveBeenCalledWith(
expect.objectContaining({
parentMessageId: 'assistant-msg-456',
prompt: '',
}),
);
});
});
});

View file

@ -188,8 +188,10 @@ export class GatewayActionImpl {
executeGatewayAgent = async (params: {
context: ConversationContext;
message: string;
/** Parent message ID for regeneration/continue (skip user message creation, branch from this message) */
parentMessageId?: string;
}): Promise<ExecAgentResult> => {
const { context, message } = params;
const { context, message, parentMessageId } = params;
const agentGatewayUrl =
window.global_serverConfigStore!.getState().serverConfig.agentGatewayUrl!;
@ -204,6 +206,7 @@ export class GatewayActionImpl {
threadId: context.threadId,
topicId: context.topicId,
},
parentMessageId,
prompt: message,
});