mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
🔨 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:
parent
1eb1fca7f2
commit
a23e159ef3
5 changed files with 238 additions and 2 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue