🐛 fix(opencode-go): preserve conversation session headers (#19235)

This commit is contained in:
AmAzing-
2026-09-07 19:11:52 +08:00
committed by GitHub
parent 25bf2310f8
commit 18f6dba56e
27 changed files with 731 additions and 40 deletions
@@ -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: {
+8 -1
View File
@@ -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) {
+25
View File
@@ -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',
+12 -2
View File
@@ -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 () => {
+1
View File
@@ -348,6 +348,7 @@ export class ChatTopicActionImpl {
messagesForTitle,
userGeneralSettingsSelectors.currentResponseLanguage(useUserStore.getState()),
),
metadata: { topicId },
model,
provider,
schema: TOPIC_TITLE_JSON_SCHEMA,