mirror of
https://github.com/lobehub/lobehub.git
synced 2026-09-20 04:56:13 +08:00
🐛 fix(opencode-go): preserve conversation session headers (#19235)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment node
|
||||
import { ModelRuntime } from '@lobechat/model-runtime';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
|
||||
import type { RuntimeExecutorContext } from '../context';
|
||||
import { ServerLLMTransport } from './ServerLLMTransport';
|
||||
|
||||
vi.mock('@/server/modules/ModelRuntime', () => ({
|
||||
initModelRuntimeFromDB: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ServerLLMTransport.stream · conversation affinity', () => {
|
||||
it('retains the conversation ID across compression requests and runtime recreation', async () => {
|
||||
const chat = vi.fn().mockImplementation(async () => new Response(''));
|
||||
vi.mocked(initModelRuntimeFromDB).mockImplementation(async () => new ModelRuntime({ chat }));
|
||||
|
||||
for (const topicId of ['topic-1', 'topic-1', 'topic-2']) {
|
||||
const ctx = { topicId, userId: 'user-1' } as RuntimeExecutorContext;
|
||||
await new ServerLLMTransport(ctx).stream({
|
||||
messages: [],
|
||||
model: 'glm-5',
|
||||
provider: 'opencodecodingplan',
|
||||
});
|
||||
}
|
||||
|
||||
expect(chat).toHaveBeenCalledTimes(3);
|
||||
expect(chat.mock.calls.map(([, options]) => options.metadata.topicId)).toEqual([
|
||||
'topic-1',
|
||||
'topic-1',
|
||||
'topic-2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -263,6 +263,7 @@ export class ServerLLMTransport implements LLMTransport {
|
||||
handlers?.onText?.(text);
|
||||
},
|
||||
},
|
||||
metadata: { topicId: this.ctx.topicId },
|
||||
user: this.ctx.userId,
|
||||
});
|
||||
|
||||
|
||||
@@ -1472,6 +1472,7 @@ export class AgentBridgeService {
|
||||
);
|
||||
const title = await systemAgent.generateTopicTitle({
|
||||
lastAssistantContent,
|
||||
topicId: resolvedTopicId,
|
||||
userPrompt: prompt,
|
||||
});
|
||||
if (!title) return;
|
||||
|
||||
@@ -735,6 +735,7 @@ export class BotCallbackService {
|
||||
const systemAgent = new SystemAgentService(this.db, userId, body.workspaceId ?? undefined);
|
||||
const title = await systemAgent.generateTopicTitle({
|
||||
lastAssistantContent,
|
||||
topicId,
|
||||
userPrompt,
|
||||
});
|
||||
if (!title) return;
|
||||
|
||||
@@ -1043,6 +1043,7 @@ describe('BotCallbackService', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(mockGenerateTopicTitle).toHaveBeenCalledWith({
|
||||
lastAssistantContent: 'Here is the answer.',
|
||||
topicId: 'topic-1',
|
||||
userPrompt: 'What is the meaning of life?',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { ModelRuntime } from '@lobechat/model-runtime';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { notShareVisitorMessage } from '@/database/utils/shareVisitor';
|
||||
@@ -38,6 +39,41 @@ describe('FollowUpActionService.extract', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('reuses the source topic in outgoing OpenCode requests across extractions', async () => {
|
||||
const sessions: (string | null)[] = [];
|
||||
queryFindFirstSpy.mockResolvedValue({ id: FOUND_MSG, content: 'Choose a next step.' });
|
||||
vi.spyOn(ModelRuntimeModule, 'initModelRuntimeFromDB').mockImplementation(async () =>
|
||||
ModelRuntime.initializeWithProvider('opencodecodingplan', { apiKey: 'test' }),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (String(url) === 'https://models.dev/api.json') {
|
||||
return Response.json({ 'opencode-go': { models: {} } });
|
||||
}
|
||||
expect(String(url)).toBe('https://opencode.ai/zen/go/v1/chat/completions');
|
||||
sessions.push(new Headers(init?.headers).get('x-opencode-session'));
|
||||
return Response.json({
|
||||
choices: [
|
||||
{ finish_reason: 'stop', message: { content: '{"chips":[]}', role: 'assistant' } },
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
for (const topicId of ['topic-1', 'topic-1', 'topic-2']) {
|
||||
expect(
|
||||
await svc.extract({
|
||||
modelConfig: { model: 'glm-5', provider: 'opencodecodingplan' },
|
||||
topicId,
|
||||
}),
|
||||
).toEqual({ chips: [], messageId: FOUND_MSG });
|
||||
}
|
||||
|
||||
expect(sessions).toEqual(['topic-1', 'topic-1', 'topic-2']);
|
||||
});
|
||||
|
||||
it('excludes agent-share visitor messages from the assistant lookup', async () => {
|
||||
@@ -121,6 +157,7 @@ describe('FollowUpActionService.extract', () => {
|
||||
model: 'custom-scene-model',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ topicId: TEST_TOPIC }),
|
||||
tracing: expect.objectContaining({
|
||||
promptVersion: 'v1.0',
|
||||
scenario: 'follow_up',
|
||||
|
||||
@@ -79,6 +79,7 @@ export class FollowUpActionService {
|
||||
schema: FOLLOW_UP_JSON_SCHEMA,
|
||||
},
|
||||
{
|
||||
metadata: { topicId },
|
||||
tracing: {
|
||||
promptVersion: FOLLOW_UP_PROMPT_VERSION,
|
||||
scenario: TRACING_SCENARIOS.FollowUp,
|
||||
|
||||
@@ -1830,6 +1830,7 @@ export class MemoryExtractionExecutor {
|
||||
sessionDate: topic.updatedAt.toISOString(),
|
||||
// TODO: make topK configurable
|
||||
topK: 10,
|
||||
topicId: topic.id,
|
||||
username:
|
||||
userState.fullName || `${userState.firstName} ${userState.lastName}`.trim() || 'User',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment node
|
||||
import { ModelRuntime } from '@lobechat/model-runtime';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import * as ModelRuntimeModule from '@/server/modules/ModelRuntime';
|
||||
|
||||
import { SystemAgentService } from './index';
|
||||
|
||||
describe('SystemAgentService.generateTopicTitle', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('preserves the source topic through outgoing OpenCode title requests', async () => {
|
||||
const sessions: (string | null)[] = [];
|
||||
const db = {
|
||||
query: {
|
||||
userSettings: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
systemAgent: { topic: { model: 'glm-5', provider: 'opencodecodingplan' } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as LobeChatDatabase;
|
||||
vi.spyOn(UserModel, 'getInfoForAIGeneration').mockResolvedValue({
|
||||
responseLanguage: 'en-US',
|
||||
userName: 'User',
|
||||
});
|
||||
vi.spyOn(ModelRuntimeModule, 'initModelRuntimeFromDB').mockImplementation(async () =>
|
||||
ModelRuntime.initializeWithProvider('opencodecodingplan', { apiKey: 'test' }),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (String(url) === 'https://models.dev/api.json') {
|
||||
return Response.json({ 'opencode-go': { models: {} } });
|
||||
}
|
||||
expect(String(url)).toBe('https://opencode.ai/zen/go/v1/chat/completions');
|
||||
sessions.push(new Headers(init?.headers).get('x-opencode-session'));
|
||||
return Response.json({
|
||||
choices: [
|
||||
{
|
||||
finish_reason: 'stop',
|
||||
message: { content: '{"title":"Next steps"}', role: 'assistant' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
for (const topicId of ['topic-1', 'topic-1', 'topic-2']) {
|
||||
const service = new SystemAgentService(db, 'user-1');
|
||||
expect(
|
||||
await service.generateTopicTitle({
|
||||
lastAssistantContent: 'Here are the next steps.',
|
||||
topicId,
|
||||
userPrompt: 'What should I do next?',
|
||||
}),
|
||||
).toBe('Next steps');
|
||||
}
|
||||
|
||||
expect(sessions).toEqual(['topic-1', 'topic-1', 'topic-2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment node
|
||||
import type { ModelRuntime } from '@lobechat/model-runtime';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import * as ModelRuntimeModule from '@/server/modules/ModelRuntime';
|
||||
|
||||
import { SystemAgentService } from './index';
|
||||
|
||||
vi.mock('@/database/models/user', () => ({
|
||||
UserModel: class {
|
||||
getUserSettings = async () => ({});
|
||||
|
||||
static getInfoForAIGeneration = async () => ({ responseLanguage: 'en-US' });
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('SystemAgentService.generateTopicTitle', () => {
|
||||
it('retains the requested topic identity across calls on a shared runtime', async () => {
|
||||
const generateObject = vi.fn().mockResolvedValue({ title: ' Generated title ' });
|
||||
vi.spyOn(ModelRuntimeModule, 'initModelRuntimeFromDB').mockResolvedValue({
|
||||
generateObject,
|
||||
} as unknown as ModelRuntime);
|
||||
const service = new SystemAgentService({} as LobeChatDatabase, 'user-1');
|
||||
|
||||
for (const topicId of ['topic-a', 'topic-b', 'topic-a']) {
|
||||
expect(
|
||||
await service.generateTopicTitle({
|
||||
lastAssistantContent: 'Here is the answer.',
|
||||
topicId,
|
||||
userPrompt: 'A question',
|
||||
}),
|
||||
).toBe('Generated title');
|
||||
expect(generateObject).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ schema: expect.objectContaining({ name: 'topic_title' }) }),
|
||||
{
|
||||
metadata: { topicId, trigger: 'topic' },
|
||||
tracing: {
|
||||
promptVersion: expect.any(String),
|
||||
scenario: 'topic_title',
|
||||
schemaName: 'topic_title',
|
||||
topicId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -50,9 +50,10 @@ export class SystemAgentService {
|
||||
*/
|
||||
async generateTopicTitle(params: {
|
||||
lastAssistantContent: string;
|
||||
topicId: string;
|
||||
userPrompt: string;
|
||||
}): Promise<string | null> {
|
||||
const { userPrompt, lastAssistantContent } = params;
|
||||
const { userPrompt, lastAssistantContent, topicId } = params;
|
||||
|
||||
try {
|
||||
const { model, provider } = await this.getTaskModelConfig('topic');
|
||||
@@ -80,11 +81,12 @@ export class SystemAgentService {
|
||||
schema: TOPIC_TITLE_JSON_SCHEMA,
|
||||
},
|
||||
{
|
||||
metadata: { trigger: RequestTrigger.Topic },
|
||||
metadata: { topicId, trigger: RequestTrigger.Topic },
|
||||
tracing: {
|
||||
promptVersion: TOPIC_TITLE_PROMPT_VERSION,
|
||||
scenario: TRACING_SCENARIOS.TopicTitle,
|
||||
schemaName: TOPIC_TITLE_JSON_SCHEMA.name,
|
||||
topicId,
|
||||
} satisfies TracingOptions,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -164,6 +164,8 @@ export abstract class BaseMemoryExtractor<
|
||||
...(options?.parentMemoryTraceKey
|
||||
? { parent_memory_trace_key: options.parentMemoryTraceKey }
|
||||
: {}),
|
||||
...(options?.taskId ? { taskId: options.taskId } : {}),
|
||||
...(options?.topicId ? { topicId: options.topicId } : {}),
|
||||
trigger: RequestTrigger.Memory,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import { ModelRuntime } from '@lobechat/model-runtime';
|
||||
import { LayersEnum, MemorySourceType } from '@lobechat/types';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ExtractorRunOptions, MemoryExtractionJob } from '../types';
|
||||
import { MemoryExtractionService } from './extractExecutor';
|
||||
|
||||
const createService = () =>
|
||||
new MemoryExtractionService({
|
||||
config: {
|
||||
gateModel: 'glm-5',
|
||||
layerModels: {
|
||||
[LayersEnum.Activity]: 'glm-5',
|
||||
[LayersEnum.Context]: 'glm-5',
|
||||
[LayersEnum.Experience]: 'glm-5',
|
||||
[LayersEnum.Identity]: 'glm-5',
|
||||
[LayersEnum.Preference]: 'glm-5',
|
||||
},
|
||||
},
|
||||
db: {} as LobeChatDatabase,
|
||||
runtimes: {
|
||||
gatekeeper: ModelRuntime.initializeWithProvider('opencodecodingplan', { apiKey: 'test' }),
|
||||
layerExtractor: ModelRuntime.initializeWithProvider('opencodecodingplan', { apiKey: 'test' }),
|
||||
},
|
||||
});
|
||||
|
||||
const createJob = (sourceId: string, source = MemorySourceType.ChatTopic): MemoryExtractionJob => ({
|
||||
source,
|
||||
sourceId,
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
const createOptions = (): ExtractorRunOptions<unknown> => ({
|
||||
contextProvider: { buildContext: vi.fn() },
|
||||
retrievedContexts: ['A conversation about a project.'],
|
||||
});
|
||||
|
||||
describe('MemoryExtractionService session affinity', () => {
|
||||
const sessions: (string | null)[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
sessions.length = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (String(url) === 'https://models.dev/api.json') {
|
||||
return Response.json({ 'opencode-go': { models: {} } });
|
||||
}
|
||||
expect(String(url)).toBe('https://opencode.ai/zen/go/v1/chat/completions');
|
||||
sessions.push(new Headers(init?.headers).get('x-opencode-session'));
|
||||
const body = JSON.parse(String(init?.body));
|
||||
const schemaName = body.response_format.json_schema.name;
|
||||
const result =
|
||||
schemaName === 'gatekeeper_decision'
|
||||
? Object.fromEntries(
|
||||
Object.values(LayersEnum).map((layer) => [
|
||||
layer,
|
||||
{ reasoning: 'Extract', shouldExtract: true },
|
||||
]),
|
||||
)
|
||||
: schemaName === 'identity_extraction'
|
||||
? { add: [], remove: [], update: [] }
|
||||
: { memories: [] };
|
||||
return Response.json({
|
||||
choices: [
|
||||
{
|
||||
finish_reason: 'stop',
|
||||
message: { content: JSON.stringify(result), role: 'assistant' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shares the source topic across gatekeeper, all layers and recreated runtimes', async () => {
|
||||
for (const topicId of ['topic-1', 'topic-1', 'topic-2']) {
|
||||
const result = await createService().run(createJob('source-that-must-not-be-inferred'), {
|
||||
...createOptions(),
|
||||
taskId: 'memory-task',
|
||||
topicId,
|
||||
});
|
||||
expect(result?.layers).toHaveLength(5);
|
||||
expect(Object.values(result!.processedErrorsCount)).toEqual([0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
expect(sessions).toEqual([
|
||||
...Array.from({ length: 12 }).fill('topic-1'),
|
||||
...Array.from({ length: 6 }).fill('topic-2'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('allocates one ID per topicless extraction, even when reusing the service', async () => {
|
||||
const service = createService();
|
||||
for (const sourceId of ['source-1', 'source-2']) {
|
||||
const result = await service.run(
|
||||
createJob(sourceId, MemorySourceType.BenchmarkLocomo),
|
||||
createOptions(),
|
||||
);
|
||||
expect(result?.layers).toHaveLength(5);
|
||||
}
|
||||
|
||||
expect(sessions).toHaveLength(12);
|
||||
expect(sessions[0]).toMatch(/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/);
|
||||
expect(sessions.slice(0, 6)).toEqual(Array.from({ length: 6 }).fill(sessions[0]));
|
||||
expect(sessions.slice(6)).toEqual(Array.from({ length: 6 }).fill(sessions[6]));
|
||||
expect(sessions[0]).not.toBe(sessions[6]);
|
||||
});
|
||||
|
||||
it('reuses an explicit task ID across repeated invocations with fresh runtimes', async () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await createService().run(createJob('source-1', MemorySourceType.BenchmarkLocomo), {
|
||||
...createOptions(),
|
||||
taskId: 'extraction-task-1',
|
||||
});
|
||||
}
|
||||
|
||||
expect(sessions).toEqual(Array.from({ length: 12 }).fill('extraction-task-1'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import type { GenerateObjectPayload, ModelRuntime } from '@lobechat/model-runtime';
|
||||
import { LayersEnum, MemorySourceType } from '@lobechat/types';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ExtractorRunOptions, MemoryExtractionJob } from '../types';
|
||||
import { MemoryExtractionService } from './extractExecutor';
|
||||
|
||||
const gatekeeperDecision = {
|
||||
activity: { reasoning: 'not needed', shouldExtract: false },
|
||||
context: { reasoning: 'extract context', shouldExtract: true },
|
||||
experience: { reasoning: 'not needed', shouldExtract: false },
|
||||
identity: { reasoning: 'not needed', shouldExtract: false },
|
||||
preference: { reasoning: 'not needed', shouldExtract: false },
|
||||
};
|
||||
|
||||
const job: MemoryExtractionJob = {
|
||||
source: MemorySourceType.ChatTopic,
|
||||
sourceId: 'source-that-must-not-be-inferred',
|
||||
userId: 'user-that-must-not-be-inferred',
|
||||
};
|
||||
|
||||
const buildOptions = (topicId?: string): ExtractorRunOptions<never> => ({
|
||||
contextProvider: {
|
||||
buildContext: vi.fn(),
|
||||
},
|
||||
retrievedContexts: ['conversation'],
|
||||
topicId,
|
||||
});
|
||||
|
||||
describe('MemoryExtractionService topic metadata', () => {
|
||||
const generateObject = vi.fn(
|
||||
async (payload: GenerateObjectPayload, _options?: { metadata: Record<string, unknown> }) =>
|
||||
payload.schema?.name === 'gatekeeper_decision' ? gatekeeperDecision : { memories: [] },
|
||||
);
|
||||
const runtime = { generateObject } as unknown as ModelRuntime;
|
||||
const service = new MemoryExtractionService<never>({
|
||||
config: {
|
||||
gateModel: 'gate-model',
|
||||
layerModels: {
|
||||
[LayersEnum.Activity]: 'layer-model',
|
||||
[LayersEnum.Context]: 'layer-model',
|
||||
[LayersEnum.Experience]: 'layer-model',
|
||||
[LayersEnum.Identity]: 'layer-model',
|
||||
[LayersEnum.Preference]: 'layer-model',
|
||||
},
|
||||
},
|
||||
db: {} as LobeChatDatabase,
|
||||
runtimes: { gatekeeper: runtime, layerExtractor: runtime },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
generateObject.mockClear();
|
||||
});
|
||||
|
||||
it('keeps each optional topic scoped to its gatekeeper and layer calls on a shared runtime', async () => {
|
||||
await service.run(job, buildOptions('topic-a'));
|
||||
await service.run(job, buildOptions('topic-b'));
|
||||
await service.run(job, buildOptions());
|
||||
|
||||
expect(generateObject).toHaveBeenCalledTimes(6);
|
||||
const taskId = generateObject.mock.calls[4][1]?.metadata.taskId;
|
||||
expect(taskId).toMatch(/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/);
|
||||
expect(generateObject.mock.calls.map(([, options]) => options?.metadata)).toEqual([
|
||||
{ topicId: 'topic-a', trigger: 'memory' },
|
||||
{ topicId: 'topic-a', trigger: 'memory' },
|
||||
{ topicId: 'topic-b', trigger: 'memory' },
|
||||
{ topicId: 'topic-b', trigger: 'memory' },
|
||||
{ taskId, trigger: 'memory' },
|
||||
{ taskId, trigger: 'memory' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -216,10 +216,15 @@ export class MemoryExtractionService<RO> {
|
||||
job: MemoryExtractionJob,
|
||||
options: ExtractorRunOptions<RO>,
|
||||
): Promise<MemoryExtractionResult | null> {
|
||||
const runOptions = {
|
||||
...options,
|
||||
taskId: options.taskId || (options.topicId ? undefined : crypto.randomUUID()),
|
||||
};
|
||||
|
||||
try {
|
||||
const decision = await this.runGatekeeper(job, { ...options });
|
||||
const decision = await this.runGatekeeper(job, runOptions);
|
||||
const layersToExtract = this.resolveJobLayers(decision, job.layers);
|
||||
const outputs = await this.runLayers(job, layersToExtract, { ...options });
|
||||
const outputs = await this.runLayers(job, layersToExtract, runOptions);
|
||||
|
||||
const processedLayersCount = {
|
||||
activity: outputs.activity?.data ? outputs.activity?.data?.memories?.length : 0,
|
||||
@@ -271,6 +276,8 @@ export class MemoryExtractionService<RO> {
|
||||
callbacks: options.callbacks,
|
||||
gateKeeperLanguage: options.gateKeeperLanguage || 'English',
|
||||
retrievedContexts: options.retrievedContexts,
|
||||
taskId: options.taskId,
|
||||
topicId: options.topicId,
|
||||
topK: options.topK,
|
||||
});
|
||||
this.recordGatekeeperMetrics(job, Date.now() - start, 'ok');
|
||||
@@ -362,32 +369,27 @@ export class MemoryExtractionService<RO> {
|
||||
switch (layer) {
|
||||
case LayersEnum.Context: {
|
||||
outputs.context = result as
|
||||
| { data: MemoryExtractionLayerOutputTypes[typeof layer] }
|
||||
| { error: unknown };
|
||||
{ data: MemoryExtractionLayerOutputTypes[typeof layer] } | { error: unknown };
|
||||
break;
|
||||
}
|
||||
case LayersEnum.Activity: {
|
||||
outputs.activity = result as
|
||||
| { data: MemoryExtractionLayerOutputTypes[typeof layer] }
|
||||
| { error: unknown };
|
||||
{ data: MemoryExtractionLayerOutputTypes[typeof layer] } | { error: unknown };
|
||||
break;
|
||||
}
|
||||
case LayersEnum.Experience: {
|
||||
outputs.experience = result as
|
||||
| { data: MemoryExtractionLayerOutputTypes[typeof layer] }
|
||||
| { error: unknown };
|
||||
{ data: MemoryExtractionLayerOutputTypes[typeof layer] } | { error: unknown };
|
||||
break;
|
||||
}
|
||||
case LayersEnum.Preference: {
|
||||
outputs.preference = result as
|
||||
| { data: MemoryExtractionLayerOutputTypes[typeof layer] }
|
||||
| { error: unknown };
|
||||
{ data: MemoryExtractionLayerOutputTypes[typeof layer] } | { error: unknown };
|
||||
break;
|
||||
}
|
||||
case LayersEnum.Identity: {
|
||||
outputs.identity = result as
|
||||
| { data: MemoryExtractionLayerOutputTypes[typeof layer] }
|
||||
| { error: unknown };
|
||||
{ data: MemoryExtractionLayerOutputTypes[typeof layer] } | { error: unknown };
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface ExtractorOptions extends ExtractorTemplateProps {
|
||||
*/
|
||||
parentMemoryTraceKey?: string;
|
||||
sourceId?: string;
|
||||
/** Stable ID shared by calls in an extraction task without a chat topic. */
|
||||
taskId?: string;
|
||||
/** Topic identity for extraction scoped to one chat topic. Leave unset for cross-topic sources. */
|
||||
topicId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
@@ -70,7 +74,10 @@ export interface GatekeeperTemplateProps extends ExtractorTemplateProps {
|
||||
gateKeeperLanguage?: string;
|
||||
}
|
||||
|
||||
export type GatekeeperOptions = Pick<ExtractorOptions, 'retrievedContexts' | 'topK'> & {
|
||||
export type GatekeeperOptions = Pick<
|
||||
ExtractorOptions,
|
||||
'retrievedContexts' | 'taskId' | 'topicId' | 'topK'
|
||||
> & {
|
||||
additionalMessages?: OpenAIChatMessage[];
|
||||
callbacks?: ExtractorOptions['callbacks'];
|
||||
gateKeeperLanguage?: string;
|
||||
|
||||
@@ -195,7 +195,10 @@ export const createAnthropicGenerateObject = async (
|
||||
try {
|
||||
log('calling Anthropic API with max_tokens: %d', finalRequestParams.max_tokens);
|
||||
|
||||
const response = await client.messages.create(finalRequestParams, { signal: options?.signal });
|
||||
const response = await client.messages.create(finalRequestParams, {
|
||||
headers: options?.headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
log('received response with %d content blocks', response.content.length);
|
||||
log('response: %O', response);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LobeOpenCodeCodingPlanAI } from './index';
|
||||
|
||||
vi.mock('@lobechat/business-model-bank/model-config', () => ({
|
||||
loadModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
describe('OpenCode Go session headers', () => {
|
||||
const requests: { headers: Headers; url: string }[] = [];
|
||||
const messages = [{ content: 'Hello', role: 'user' as const }];
|
||||
const createRuntime = () => new LobeOpenCodeCodingPlanAI({ apiKey: 'test' });
|
||||
|
||||
beforeEach(() => {
|
||||
requests.length = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (String(url) === 'https://models.dev/api.json') {
|
||||
return Response.json({
|
||||
'opencode-go': {
|
||||
models: {
|
||||
'qwen-test': { id: 'qwen-test', provider: { npm: '@ai-sdk/anthropic' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
requests.push({ headers: new Headers(init?.headers), url: String(url) });
|
||||
return Response.json({
|
||||
id: 'test-response',
|
||||
choices: [
|
||||
{
|
||||
finish_reason: 'stop',
|
||||
index: 0,
|
||||
message: {
|
||||
content: '{"ok":true}',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
type: 'function',
|
||||
function: { name: 'result', arguments: '{"ok":true}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
content: [{ id: 'tool-1', type: 'tool_use', name: 'result', input: { ok: true } }],
|
||||
model: 'test',
|
||||
role: 'assistant',
|
||||
stop_reason: 'end_turn',
|
||||
type: 'message',
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 2,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['glm-5', '/chat/completions'],
|
||||
['deepseek-v4-pro', '/chat/completions'],
|
||||
['qwen-test', '/messages'],
|
||||
])('preserves conversation affinity across runtime instances for %s', async (model, endpoint) => {
|
||||
for (const topicId of ['topic-a', 'topic-a', 'topic-b']) {
|
||||
const response = await createRuntime().chat(
|
||||
{ messages, model, stream: false },
|
||||
{
|
||||
metadata: { topicId },
|
||||
requestHeaders: { 'x-custom': 'preserved' },
|
||||
},
|
||||
);
|
||||
await response.text();
|
||||
}
|
||||
|
||||
expect(requests).toHaveLength(3);
|
||||
expect(requests.map(({ headers }) => headers.get('x-opencode-session'))).toEqual([
|
||||
'topic-a',
|
||||
'topic-a',
|
||||
'topic-b',
|
||||
]);
|
||||
for (const { headers, url } of requests) {
|
||||
expect(url).toBe(`https://opencode.ai/zen/go/v1${endpoint}`);
|
||||
expect(headers.get('x-opencode-client')).toBe('lobehub');
|
||||
expect(headers.get('user-agent')).toBe('lobehub');
|
||||
expect(headers.get('x-custom')).toBe('preserved');
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['glm-5', 'deepseek-v4-pro', 'qwen-test'])(
|
||||
'sends session headers for %s structured output',
|
||||
async (model) => {
|
||||
const result = await createRuntime().generateObject(
|
||||
{
|
||||
messages,
|
||||
model,
|
||||
schema: {
|
||||
name: 'result',
|
||||
schema: { properties: { ok: { type: 'boolean' } }, type: 'object' },
|
||||
},
|
||||
},
|
||||
{ metadata: { topicId: 'topic-object' } },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0].headers.get('x-opencode-session')).toBe('topic-object');
|
||||
expect(requests[0].headers.get('x-opencode-client')).toBe('lobehub');
|
||||
},
|
||||
);
|
||||
|
||||
it('reuses a topicless task ID across chat and structured output', async () => {
|
||||
for (const taskId of ['task-1', 'task-1', 'task-2']) {
|
||||
const runtime = createRuntime();
|
||||
const response = await runtime.chat(
|
||||
{ messages, model: 'glm-5', stream: false },
|
||||
{ metadata: { taskId } },
|
||||
);
|
||||
await response.text();
|
||||
await runtime.generateObject(
|
||||
{
|
||||
messages,
|
||||
model: 'glm-5',
|
||||
schema: {
|
||||
name: 'result',
|
||||
schema: { properties: { ok: { type: 'boolean' } }, type: 'object' },
|
||||
},
|
||||
},
|
||||
{ metadata: { taskId } },
|
||||
);
|
||||
}
|
||||
|
||||
expect(requests.map(({ headers }) => headers.get('x-opencode-session'))).toEqual([
|
||||
'task-1',
|
||||
'task-1',
|
||||
'task-1',
|
||||
'task-1',
|
||||
'task-2',
|
||||
'task-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the topic over a task ID', async () => {
|
||||
const response = await createRuntime().chat(
|
||||
{ messages, model: 'glm-5', stream: false },
|
||||
{ metadata: { taskId: 'task-1', topicId: 'topic-1' } },
|
||||
);
|
||||
await response.text();
|
||||
|
||||
expect(requests[0].headers.get('x-opencode-session')).toBe('topic-1');
|
||||
});
|
||||
|
||||
it('assigns separate UUIDs to standalone requests without a topic or task', async () => {
|
||||
const runtime = createRuntime();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const response = await runtime.chat({ messages, model: 'glm-5', stream: false });
|
||||
await response.text();
|
||||
}
|
||||
const ids = requests.map(({ headers }) => headers.get('x-opencode-session'));
|
||||
for (const id of ids)
|
||||
expect(id).toMatch(/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/);
|
||||
expect(ids[0]).not.toBe(ids[1]);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import { pickNonEmptyString } from '@lobechat/utils/object';
|
||||
import { LOBE_DEFAULT_MODEL_LIST, ModelProvider } from 'model-bank';
|
||||
import type OpenAI from 'openai';
|
||||
|
||||
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
|
||||
import { createRouterRuntime } from '../../core/RouterRuntime';
|
||||
import type { CreateRouterRuntimeOptions } from '../../core/RouterRuntime/createRuntime';
|
||||
import type { ChatStreamPayload } from '../../types';
|
||||
import type {
|
||||
ChatMethodOptions,
|
||||
ChatStreamPayload,
|
||||
GenerateObjectOptions,
|
||||
GenerateObjectPayload,
|
||||
} from '../../types';
|
||||
import { processMultiProviderModelList } from '../../utils/modelParse';
|
||||
import {
|
||||
isKimiNativeThinkingModel,
|
||||
@@ -522,4 +528,37 @@ export const params = {
|
||||
},
|
||||
} satisfies CreateRouterRuntimeOptions;
|
||||
|
||||
export const LobeOpenCodeCodingPlanAI = createRouterRuntime(params);
|
||||
export class LobeOpenCodeCodingPlanAI extends createRouterRuntime(params) {
|
||||
private getSessionHeaders(metadata?: Record<string, unknown>) {
|
||||
return {
|
||||
'User-Agent': 'lobehub',
|
||||
'x-opencode-client': 'lobehub',
|
||||
// Callers preserve topic identity or reuse a task ID across related calls.
|
||||
// Only requests without either identity receive a standalone session.
|
||||
'x-opencode-session':
|
||||
pickNonEmptyString(metadata?.topicId) ??
|
||||
pickNonEmptyString(metadata?.taskId) ??
|
||||
crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
override async chat(payload: ChatStreamPayload, options?: ChatMethodOptions) {
|
||||
return super.chat(payload, {
|
||||
...options,
|
||||
requestHeaders: {
|
||||
...this.getSessionHeaders(options?.metadata),
|
||||
...options?.requestHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
override async generateObject(payload: GenerateObjectPayload, options?: GenerateObjectOptions) {
|
||||
return super.generateObject(payload, {
|
||||
...options,
|
||||
headers: {
|
||||
...this.getSessionHeaders(options?.metadata),
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { REQUEST_TOPIC_ID_HEADER } from '@lobechat/const';
|
||||
import { type LobeRuntimeAI } from '@lobechat/model-runtime';
|
||||
import { ModelRuntime } from '@lobechat/model-runtime';
|
||||
import { ChatErrorType } from '@lobechat/types';
|
||||
@@ -82,30 +83,35 @@ describe('POST handler', () => {
|
||||
});
|
||||
|
||||
describe('chat', () => {
|
||||
it('should correctly handle chat completion with valid payload', async () => {
|
||||
const mockParams = Promise.resolve({ provider: 'test-provider' });
|
||||
const mockChatPayload = { message: 'Hello, world!' };
|
||||
request = new Request(new URL('https://test.com'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(mockChatPayload),
|
||||
});
|
||||
it.each([undefined, 'topic-123'])(
|
||||
'should pass topic %s to chat runtime metadata',
|
||||
async (topicId) => {
|
||||
const mockParams = Promise.resolve({ provider: 'test-provider' });
|
||||
const mockChatPayload = { message: 'Hello, world!' };
|
||||
request = new Request(new URL('https://test.com'), {
|
||||
method: 'POST',
|
||||
headers: topicId ? { [REQUEST_TOPIC_ID_HEADER]: topicId } : {},
|
||||
body: JSON.stringify(mockChatPayload),
|
||||
});
|
||||
|
||||
const mockChatResponse: any = { success: true, message: 'Reply from agent' };
|
||||
const mockRuntime: LobeRuntimeAI = {
|
||||
baseURL: 'abc',
|
||||
chat: vi.fn().mockResolvedValue(mockChatResponse),
|
||||
};
|
||||
const mockChatResponse: any = { success: true, message: 'Reply from agent' };
|
||||
const mockRuntime: LobeRuntimeAI = {
|
||||
baseURL: 'abc',
|
||||
chat: vi.fn().mockResolvedValue(mockChatResponse),
|
||||
};
|
||||
|
||||
vi.mocked(initModelRuntimeFromDB).mockResolvedValue(new ModelRuntime(mockRuntime));
|
||||
vi.mocked(initModelRuntimeFromDB).mockResolvedValue(new ModelRuntime(mockRuntime));
|
||||
|
||||
const response = await POST(request as unknown as Request, { params: mockParams });
|
||||
const response = await POST(request as unknown as Request, { params: mockParams });
|
||||
|
||||
expect(response).toEqual(mockChatResponse);
|
||||
expect(mockRuntime.chat).toHaveBeenCalledWith(mockChatPayload, {
|
||||
user: 'test-user-id',
|
||||
signal: expect.anything(),
|
||||
});
|
||||
});
|
||||
expect(response).toEqual(mockChatResponse);
|
||||
expect(mockRuntime.chat).toHaveBeenCalledWith(mockChatPayload, {
|
||||
metadata: { topicId },
|
||||
user: 'test-user-id',
|
||||
signal: expect.anything(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should return an error response when chat completion fails', async () => {
|
||||
const mockParams = Promise.resolve({ provider: 'test-provider' });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { REQUEST_TOPIC_ID_HEADER } from '@lobechat/const';
|
||||
import { type ChatCompletionErrorPayload } from '@lobechat/model-runtime';
|
||||
import { AGENT_RUNTIME_ERROR_SET } from '@lobechat/model-runtime';
|
||||
import { ChatErrorType } from '@lobechat/types';
|
||||
@@ -38,6 +39,7 @@ export const POST = checkAuth(async (req: Request, { params, userId, serverDB })
|
||||
return await modelRuntime.chat(data, {
|
||||
user: userId,
|
||||
...traceOptions,
|
||||
metadata: { topicId: req.headers.get(REQUEST_TOPIC_ID_HEADER) ?? undefined },
|
||||
signal: req.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -17,8 +17,10 @@ import { agentSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors
|
||||
import { aiModelSelectors, useAiInfraStore } from '@/store/aiInfra';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { settingsSelectors } from '@/store/user/selectors';
|
||||
|
||||
import * as chatHelper from './helper';
|
||||
import { chatService } from './index';
|
||||
import * as mechaModule from './mecha';
|
||||
import { type ResolvedAgentConfig } from './mecha';
|
||||
@@ -1779,6 +1781,29 @@ describe('ChatService', () => {
|
||||
mockCreateHeaderWithAuth.mockClear();
|
||||
});
|
||||
|
||||
it('should preserve the topic ID when using the browser runtime', async () => {
|
||||
vi.spyOn(chatHelper, 'isEnableFetchOnClient').mockReturnValue(true);
|
||||
useUserStore.setState({ isSignedIn: true });
|
||||
const runtime = await import('@lobechat/model-runtime');
|
||||
const chat = vi.fn().mockResolvedValue(new Response('ok'));
|
||||
vi.spyOn(mechaModule, 'initializeWithClientStore').mockResolvedValue(
|
||||
new runtime.ModelRuntime({ chat }),
|
||||
);
|
||||
mockFetchSSE.mockImplementation(
|
||||
async (_url: string, options: { fetcher: () => Promise<Response> }) => options.fetcher(),
|
||||
);
|
||||
|
||||
await chatService.getChatCompletion(
|
||||
{ messages: [], model: 'glm-5', provider: ModelProvider.OpenCodeCodingPlan },
|
||||
{ topicId: 'topic-browser' },
|
||||
);
|
||||
|
||||
expect(chat).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ topicId: expect.anything() }),
|
||||
expect.objectContaining({ metadata: { topicId: 'topic-browser' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should make a POST request with the correct payload', async () => {
|
||||
const params: Partial<ChatStreamPayload> = {
|
||||
model: 'test-model',
|
||||
|
||||
@@ -490,7 +490,13 @@ class ChatService {
|
||||
*/
|
||||
fetcher = async () => {
|
||||
try {
|
||||
return await this.fetchOnClient({ payload, provider, runtimeProvider: sdkType, signal });
|
||||
return await this.fetchOnClient({
|
||||
payload,
|
||||
provider,
|
||||
runtimeProvider: sdkType,
|
||||
signal,
|
||||
topicId,
|
||||
});
|
||||
} catch (e) {
|
||||
const {
|
||||
errorType = ChatErrorType.BadRequest,
|
||||
@@ -625,6 +631,7 @@ class ChatService {
|
||||
provider: string;
|
||||
runtimeProvider: string;
|
||||
signal?: AbortSignal;
|
||||
topicId?: string;
|
||||
}) => {
|
||||
/**
|
||||
* if enable login and not signed in, return unauthorized error
|
||||
@@ -641,7 +648,10 @@ class ChatService {
|
||||
});
|
||||
const data = params.payload as ChatStreamPayload;
|
||||
|
||||
return agentRuntime.chat(data, { signal: params.signal });
|
||||
return agentRuntime.chat(data, {
|
||||
metadata: { topicId: params.topicId },
|
||||
signal: params.signal,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ModelEmptyError } from '@lobechat/model-runtime';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { chatService } from '@/services/chat';
|
||||
|
||||
import type { ChatStore } from '../../store';
|
||||
import { ClientLLMTransport } from './ClientLLMTransport';
|
||||
|
||||
@@ -105,6 +107,23 @@ const input = {
|
||||
state: {},
|
||||
} as any;
|
||||
|
||||
describe('ClientLLMTransport.stream · conversation affinity', () => {
|
||||
it('keeps compression requests scoped to the operation when the active topic changes', async () => {
|
||||
const { store, transport } = createTransport();
|
||||
store.activeTopicId = 'other-topic';
|
||||
vi.mocked(chatService.getChatCompletion).mockClear();
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await transport.stream({ messages: [], model: 'glm-5', provider: 'opencodecodingplan' });
|
||||
}
|
||||
|
||||
expect(chatService.getChatCompletion).toHaveBeenCalledTimes(2);
|
||||
for (const [, options] of vi.mocked(chatService.getChatCompletion).mock.calls) {
|
||||
expect(options?.topicId).toBe('topic-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClientLLMTransport.runAttempt · empty-completion grounding guard', () => {
|
||||
beforeEach(() => {
|
||||
finishGrounding = grounding;
|
||||
|
||||
@@ -397,6 +397,8 @@ export class ClientLLMTransport implements LLMTransport {
|
||||
if (streamChunk.type === 'reasoning') reasoning += streamChunk.text;
|
||||
},
|
||||
signal,
|
||||
topicId:
|
||||
this.context.get().operations[this.context.operationId]?.context.topicId ?? undefined,
|
||||
});
|
||||
|
||||
if (streamError) throw streamError;
|
||||
|
||||
@@ -3039,6 +3039,7 @@ describe('topic action', () => {
|
||||
|
||||
expect(updateTitleSpy).toHaveBeenCalledWith(topicId, LOADING_FLAT);
|
||||
expect(generateSpy).toHaveBeenCalledOnce();
|
||||
expect(generateSpy.mock.calls[0][0].metadata).toEqual({ topicId });
|
||||
});
|
||||
|
||||
it('should summarize the final answer inside an assistant group for an audio-only conversation', async () => {
|
||||
|
||||
@@ -348,6 +348,7 @@ export class ChatTopicActionImpl {
|
||||
messagesForTitle,
|
||||
userGeneralSettingsSelectors.currentResponseLanguage(useUserStore.getState()),
|
||||
),
|
||||
metadata: { topicId },
|
||||
model,
|
||||
provider,
|
||||
schema: TOPIC_TITLE_JSON_SCHEMA,
|
||||
|
||||
Reference in New Issue
Block a user