diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx
index 2bd79a6b3..e6b933e49 100644
--- a/gitnexus-web/src/components/SettingsPanel.tsx
+++ b/gitnexus-web/src/components/SettingsPanel.tsx
@@ -341,6 +341,7 @@ export const SettingsPanel = ({
'openrouter',
'minimax',
'glm',
+ 'deepseek',
];
return (
@@ -433,7 +434,9 @@ export const SettingsPanel = ({
? '⚡'
: provider === 'glm'
? '🔮'
- : '☁️'}
+ : provider === 'deepseek'
+ ? '🐋'
+ : '☁️'}
{getProviderDisplayName(provider)}
@@ -856,6 +859,43 @@ export const SettingsPanel = ({
/>
)}
+ {/* DeepSeek Settings */}
+ {settings.activeProvider === 'deepseek' && (
+
+ setSettings((prev) => ({
+ ...prev,
+ deepseek: { ...prev.deepseek!, apiKey: value },
+ })),
+ onToggleVisibility: () => toggleApiKeyVisibility('deepseek'),
+ }}
+ model={{
+ value: settings.deepseek?.model ?? 'deepseek-v4-flash',
+ placeholder: 'e.g., deepseek-v4-flash, deepseek-v4-pro, deepseek-chat',
+ onChange: (value) =>
+ setSettings((prev) => ({
+ ...prev,
+ deepseek: { ...prev.deepseek!, model: value },
+ })),
+ helperText:
+ 'deepseek-v4-flash (default), deepseek-v4-pro, deepseek-chat (V3), deepseek-reasoner (R1)',
+ }}
+ >
+
+ Compatible via OpenAI API format. The deepseek-reasoner model uses thinking mode and
+ requires round-tripping reasoning content.
+
+
+ )}
+
{/* GLM Settings */}
{settings.activeProvider === 'glm' && (
diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts
index 49862a9e6..5153e35fb 100644
--- a/gitnexus-web/src/core/llm/agent.ts
+++ b/gitnexus-web/src/core/llm/agent.ts
@@ -6,7 +6,13 @@
*/
import { createReactAgent } from '@langchain/langgraph/prebuilt';
-import { SystemMessage } from '@langchain/core/messages';
+import {
+ SystemMessage,
+ HumanMessage,
+ AIMessage,
+ ToolMessage,
+ type BaseMessage,
+} from '@langchain/core/messages';
import { ChatOpenAI, AzureChatOpenAI } from '@langchain/openai';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { ChatAnthropic } from '@langchain/anthropic';
@@ -23,10 +29,17 @@ import type {
OpenRouterConfig,
MiniMaxConfig,
GLMConfig,
+ DeepSeekConfig,
AgentStreamChunk,
+ AgentHistoryMessage,
} from './types';
import { type CodebaseContext, buildDynamicSystemPrompt } from './context-builder';
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
+import {
+ DeepSeekChatOpenAI,
+ normalizeMessageContent,
+ normalizeToolCalls,
+} from './deepseek-chat-model';
/**
* System prompt for the Graph RAG agent
@@ -124,6 +137,7 @@ When generating diagrams:
BAD: A[User's Data] --> B(Process & Save)
GOOD: A["User Data"] --> B["Process and Save"]
`;
+
export const createChatModel = (config: ProviderConfig): BaseChatModel => {
switch (config.provider) {
case 'openai': {
@@ -264,6 +278,26 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
});
}
+ case 'deepseek': {
+ const deepseekConfig = config as DeepSeekConfig;
+
+ if (!deepseekConfig.apiKey || deepseekConfig.apiKey.trim() === '') {
+ throw new Error('DeepSeek API key is required but was not provided');
+ }
+
+ return new DeepSeekChatOpenAI({
+ apiKey: deepseekConfig.apiKey,
+ modelName: deepseekConfig.model,
+ temperature: deepseekConfig.temperature ?? 0.1,
+ maxTokens: deepseekConfig.maxTokens,
+ configuration: {
+ apiKey: deepseekConfig.apiKey,
+ baseURL: 'https://api.deepseek.com',
+ },
+ streaming: true,
+ });
+ }
+
default:
throw new Error(`Unsupported provider: ${(config as any).provider}`);
}
@@ -324,11 +358,65 @@ export const createGraphRAGAgent = (
/**
* Message type for agent conversation
*/
-export interface AgentMessage {
- role: 'user' | 'assistant';
- content: string;
+export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessage;
+
+export interface AgentRuntimeOptions {
+ /** Capture assistant/tool messages for providers that require exact transcript replay. */
+ captureHistory?: boolean;
}
+export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] =>
+ messages.map((message) => {
+ if (message.role === 'user') {
+ return new HumanMessage(message.content);
+ }
+ if (message.role === 'tool') {
+ return new ToolMessage({
+ content: message.content,
+ tool_call_id: message.toolCallId,
+ ...(message.name ? { name: message.name } : {}),
+ });
+ }
+ return new AIMessage({
+ content: message.content,
+ ...(typeof message.reasoningContent === 'string'
+ ? { additional_kwargs: { reasoning_content: message.reasoningContent } }
+ : {}),
+ ...(message.toolCalls?.length ? { tool_calls: message.toolCalls } : {}),
+ } as any);
+ });
+
+export const serializeAgentHistoryMessages = (
+ messages: unknown[],
+ startIndex = 0,
+): AgentHistoryMessage[] => {
+ const serialized: AgentHistoryMessage[] = [];
+ for (const rawMessage of messages.slice(startIndex)) {
+ const msg: any = rawMessage;
+ const msgType = msg?._getType?.() || msg?.type || msg?.constructor?.name || 'unknown';
+ if (msgType === 'ai' || msgType === 'AIMessage') {
+ const reasoningContent = (msg.additional_kwargs || msg.kwargs)?.reasoning_content;
+ const toolCalls = normalizeToolCalls(msg.tool_calls);
+ serialized.push({
+ role: 'assistant',
+ content: normalizeMessageContent(msg.content),
+ ...(toolCalls?.length && typeof reasoningContent === 'string' ? { reasoningContent } : {}),
+ ...(toolCalls?.length ? { toolCalls } : {}),
+ });
+ continue;
+ }
+ if (msgType === 'tool' || msgType === 'ToolMessage') {
+ serialized.push({
+ role: 'tool',
+ content: normalizeMessageContent(msg.content),
+ toolCallId: String(msg.tool_call_id ?? ''),
+ ...(typeof msg.name === 'string' ? { name: msg.name } : {}),
+ });
+ }
+ }
+ return serialized;
+};
+
/**
* Stream a response from the agent
* Uses BOTH streamModes for best of both worlds:
@@ -340,12 +428,10 @@ export interface AgentMessage {
export async function* streamAgentResponse(
agent: ReturnType
,
messages: AgentMessage[],
+ options: AgentRuntimeOptions = {},
): AsyncGenerator {
try {
- const formattedMessages = messages.map((m) => ({
- role: m.role,
- content: m.content,
- }));
+ const formattedMessages = buildLangChainMessages(messages);
// Use BOTH modes: 'values' for structure, 'messages' for token streaming
const stream = await agent.stream({ messages: formattedMessages }, {
@@ -364,6 +450,9 @@ export async function* streamAgentResponse(
// Anything before the first tool call should be treated as "reasoning/narration"
// so the UI can show the Cursor-like loop: plan → tool → update → tool → answer.
let hasSeenToolCallThisTurn = false;
+ // Track the last set of messages so we can persist the raw assistant/tool
+ // transcript for the next user turn.
+ let lastStepMessages: any[] | null = null;
for await (const event of stream) {
// Events come as [streamMode, data] tuples when using multiple modes
@@ -482,6 +571,9 @@ export async function* streamAgentResponse(
// Handle 'values' mode - state snapshots for structure
if (mode === 'values' && data?.messages) {
const stepMessages = data.messages || [];
+ if (options.captureHistory) {
+ lastStepMessages = stepMessages;
+ }
// Process new messages for tool calls/results we might have missed
for (let i = lastProcessedMsgCount; i < stepMessages.length; i++) {
@@ -539,7 +631,14 @@ export async function* streamAgentResponse(
if (import.meta.env.DEV) {
console.log('✅ Stream completed normally, yielding done');
}
- yield { type: 'done' };
+
+ yield {
+ type: 'done',
+ historyMessages:
+ options.captureHistory && lastStepMessages
+ ? serializeAgentHistoryMessages(lastStepMessages, formattedMessages.length)
+ : undefined,
+ };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// DEBUG: Stream error
@@ -561,10 +660,7 @@ export const invokeAgent = async (
agent: ReturnType,
messages: AgentMessage[],
): Promise => {
- const formattedMessages = messages.map((m) => ({
- role: m.role,
- content: m.content,
- }));
+ const formattedMessages = buildLangChainMessages(messages);
const result = await agent.invoke({ messages: formattedMessages });
diff --git a/gitnexus-web/src/core/llm/deepseek-chat-model.ts b/gitnexus-web/src/core/llm/deepseek-chat-model.ts
new file mode 100644
index 000000000..9abeb46e2
--- /dev/null
+++ b/gitnexus-web/src/core/llm/deepseek-chat-model.ts
@@ -0,0 +1,257 @@
+import {
+ ChatOpenAI,
+ ChatOpenAICompletions,
+ type ChatOpenAICallOptions,
+ type ChatOpenAICompletionsCallOptions,
+ type ChatOpenAIFields,
+} from '@langchain/openai';
+import type { BaseMessage } from '@langchain/core/messages';
+import type { BaseLanguageModelInput } from '@langchain/core/language_models/base';
+import type { AIMessageChunk } from '@langchain/core/messages';
+import type { Runnable } from '@langchain/core/runnables';
+import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
+import type { ChatGenerationChunk, ChatResult } from '@langchain/core/outputs';
+import type { AgentToolCall } from './types';
+
+/**
+ * DeepSeek's thinking-mode chat API requires assistant `reasoning_content`
+ * from prior turns to be replayed verbatim on the next request. LangChain
+ * preserves the inbound value on `AIMessage.additional_kwargs`, but its
+ * OpenAI-compatible outbound converter currently drops that provider-specific
+ * field. This completions subclass keeps the behavior scoped to DeepSeek by
+ * replacing only the serialized request messages immediately before the
+ * DeepSeek API call.
+ */
+export class DeepSeekChatOpenAICompletions<
+ CallOptions extends ChatOpenAICompletionsCallOptions = ChatOpenAICompletionsCallOptions,
+> extends ChatOpenAICompletions {
+ private activeMessages: BaseMessage[] | null = null;
+
+ private setActiveMessages(messages: BaseMessage[]): void {
+ if (this.activeMessages !== null) {
+ throw new Error('DeepSeekChatOpenAICompletions does not support overlapping requests');
+ }
+ this.activeMessages = messages;
+ }
+
+ override async _generate(
+ messages: BaseMessage[],
+ options: this['ParsedCallOptions'],
+ runManager?: CallbackManagerForLLMRun,
+ ): Promise {
+ this.setActiveMessages(messages);
+ try {
+ return await super._generate(messages, options, runManager);
+ } finally {
+ this.activeMessages = null;
+ }
+ }
+
+ override async *_streamResponseChunks(
+ messages: BaseMessage[],
+ options: this['ParsedCallOptions'],
+ runManager?: CallbackManagerForLLMRun,
+ ): AsyncGenerator {
+ this.setActiveMessages(messages);
+ try {
+ yield* super._streamResponseChunks(messages, options, runManager);
+ } finally {
+ this.activeMessages = null;
+ }
+ }
+
+ override async completionWithRetry(request: any, requestOptions?: any): Promise {
+ const messages = this.activeMessages
+ ? buildDeepSeekRequestMessages(this.activeMessages)
+ : request.messages;
+ return super.completionWithRetry({ ...request, messages }, requestOptions);
+ }
+}
+
+/**
+ * OpenAI-compatible DeepSeek chat model with a DeepSeek-specific completions
+ * serializer. Keeping this as a subclass avoids provider checks in the shared
+ * agent streaming path and ensures LangChain `withConfig()` clones used by tool
+ * binding retain the same request serialization behavior.
+ */
+export class DeepSeekChatOpenAI<
+ CallOptions extends ChatOpenAICallOptions = ChatOpenAICallOptions,
+> extends ChatOpenAI {
+ private readonly deepSeekFields: ChatOpenAIFields;
+
+ constructor(fields: ChatOpenAIFields) {
+ const deepSeekFields = {
+ ...fields,
+ completions: new DeepSeekChatOpenAICompletions(fields),
+ } as ChatOpenAIFields;
+ super(deepSeekFields);
+ this.deepSeekFields = deepSeekFields;
+ }
+
+ override withConfig(
+ config: Partial,
+ ): Runnable {
+ // Mirror ChatOpenAI.withConfig() for this LangChain version, but keep the
+ // DeepSeek subclass. Calling super.withConfig() would drop our custom
+ // completions serializer by returning a plain ChatOpenAI instance.
+ const newModel = new DeepSeekChatOpenAI(this.deepSeekFields);
+ newModel.defaultOptions = {
+ ...this.defaultOptions,
+ ...config,
+ } as typeof this.defaultOptions;
+ return newModel;
+ }
+}
+
+export const normalizeMessageContent = (content: unknown): string => {
+ if (typeof content === 'string') return content;
+ if (Array.isArray(content)) {
+ return content
+ .filter((block: any) => block?.type === 'text' || typeof block === 'string')
+ .map((block: any) => (typeof block === 'string' ? block : block.text || ''))
+ .join('');
+ }
+ if (content == null) return '';
+ return String(content);
+};
+
+const normalizeToolCallArgs = (toolCall: any): Record => {
+ if (toolCall?.args && typeof toolCall.args === 'object') {
+ return toolCall.args as Record;
+ }
+ try {
+ return toolCall?.function?.arguments ? JSON.parse(toolCall.function.arguments) : {};
+ } catch {
+ return {};
+ }
+};
+
+export const normalizeToolCalls = (toolCalls: unknown): AgentToolCall[] | undefined => {
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) return undefined;
+ return toolCalls.map((toolCall: any) => ({
+ id: typeof toolCall?.id === 'string' ? toolCall.id : undefined,
+ name: toolCall?.name || toolCall?.function?.name || 'unknown',
+ args: normalizeToolCallArgs(toolCall),
+ type: typeof toolCall?.type === 'string' ? toolCall.type : 'tool_call',
+ }));
+};
+
+const stringifyToolArguments = (args: unknown): string => {
+ if (typeof args === 'string') return args;
+ try {
+ return JSON.stringify(args ?? {});
+ } catch {
+ return '{}';
+ }
+};
+
+const normalizeOpenAIContent = (content: unknown): string | Array> => {
+ if (typeof content === 'string') return content;
+ if (!Array.isArray(content)) return normalizeMessageContent(content);
+
+ const blocks = content.flatMap((block: any) => {
+ if (typeof block === 'string') {
+ return [{ type: 'text', text: block }];
+ }
+ if (block?.type === 'text' && typeof block.text === 'string') {
+ return [{ type: 'text', text: block.text }];
+ }
+ return [];
+ });
+
+ if (blocks.length === 0) return '';
+ if (blocks.length === 1) return blocks[0].text as string;
+ return blocks;
+};
+
+const getOpenAIRole = (message: any): string => {
+ const messageType =
+ message?._getType?.() || message?.type || message?.constructor?.name || 'unknown';
+ if ((message.additional_kwargs || {}).__openai_role__ === 'developer') {
+ return 'developer';
+ }
+ switch (messageType) {
+ case 'human':
+ case 'HumanMessage':
+ return 'user';
+ case 'ai':
+ case 'AIMessage':
+ return 'assistant';
+ case 'system':
+ case 'SystemMessage':
+ return 'system';
+ case 'tool':
+ case 'ToolMessage':
+ return 'tool';
+ case 'function':
+ case 'FunctionMessage':
+ return 'function';
+ default:
+ return typeof message.role === 'string' ? message.role : 'user';
+ }
+};
+
+export const buildDeepSeekRequestMessages = (
+ messages: Array>,
+): Array> =>
+ messages.map((message: any) => {
+ const role = getOpenAIRole(message);
+ const additionalKwargs =
+ message.additional_kwargs && typeof message.additional_kwargs === 'object'
+ ? message.additional_kwargs
+ : {};
+ const requestMessage: Record = {
+ role,
+ content: normalizeOpenAIContent(message.content),
+ };
+
+ if (typeof message.name === 'string' && message.name.length > 0) {
+ requestMessage.name = message.name;
+ }
+ if (role === 'assistant') {
+ const toolCalls = Array.isArray(message.tool_calls)
+ ? message.tool_calls
+ : Array.isArray(additionalKwargs.tool_calls)
+ ? additionalKwargs.tool_calls
+ : undefined;
+ if (toolCalls?.length) {
+ requestMessage.tool_calls = toolCalls.map((toolCall: any) => {
+ if (toolCall?.function) {
+ return {
+ id: toolCall.id,
+ type: toolCall.type ?? 'function',
+ function: {
+ name: toolCall.function.name,
+ arguments: stringifyToolArguments(toolCall.function.arguments),
+ },
+ };
+ }
+ return {
+ id: toolCall?.id,
+ type: 'function',
+ function: {
+ name: toolCall?.name ?? 'unknown',
+ arguments: stringifyToolArguments(toolCall?.args),
+ },
+ };
+ });
+ }
+ if (additionalKwargs.function_call != null) {
+ requestMessage.function_call = additionalKwargs.function_call;
+ }
+ if (toolCalls?.length && typeof additionalKwargs.reasoning_content === 'string') {
+ requestMessage.reasoning_content = additionalKwargs.reasoning_content;
+ }
+ return requestMessage;
+ }
+
+ if (role === 'tool' && typeof message.tool_call_id === 'string') {
+ requestMessage.tool_call_id = message.tool_call_id;
+ }
+
+ if (role === 'function' && typeof message.name === 'string') {
+ requestMessage.name = message.name;
+ }
+
+ return requestMessage;
+ });
diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts
index 86330d2a5..79a7a4309 100644
--- a/gitnexus-web/src/core/llm/settings-service.ts
+++ b/gitnexus-web/src/core/llm/settings-service.ts
@@ -17,6 +17,7 @@ import {
OpenRouterConfig,
MiniMaxConfig,
GLMConfig,
+ DeepSeekConfig,
ProviderConfig,
} from './types';
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
@@ -59,6 +60,10 @@ const mergeWithDefaults = (parsed?: Partial | null): LLMSettings =>
...DEFAULT_LLM_SETTINGS.glm,
...parsed?.glm,
},
+ deepseek: {
+ ...DEFAULT_LLM_SETTINGS.deepseek,
+ ...parsed?.deepseek,
+ },
});
const readSettings = (storage: Storage): Partial | null => {
@@ -144,7 +149,9 @@ export const updateProviderSettings = (
? Partial>
: T extends 'glm'
? Partial>
- : never
+ : T extends 'deepseek'
+ ? Partial>
+ : never
>,
): LLMSettings => {
const current = loadSettings();
@@ -239,6 +246,17 @@ export const updateProviderSettings = (
saveSettings(updated);
return updated;
}
+ case 'deepseek': {
+ const updated: LLMSettings = {
+ ...current,
+ deepseek: {
+ ...(current.deepseek ?? {}),
+ ...(updates as Partial>),
+ },
+ };
+ saveSettings(updated);
+ return updated;
+ }
default: {
// Should be unreachable due to T extends LLMProvider, but keep a safe fallback
const updated: LLMSettings = { ...current };
@@ -316,6 +334,10 @@ const providerBuilders: Record = {
maxTokens: settings.glm.maxTokens,
} as GLMConfig;
},
+ deepseek: (settings) => {
+ if (!settings.deepseek?.apiKey) return null;
+ return { provider: 'deepseek', ...settings.deepseek } as DeepSeekConfig;
+ },
};
export const getActiveProviderConfig = (): ProviderConfig | null => {
@@ -347,6 +369,24 @@ export const clearSettings = (): void => {
}
};
+interface ProviderCapabilities {
+ /** Provider requires hidden assistant/tool transcript replay across turns. */
+ preserveAssistantTranscript: boolean;
+}
+
+const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
+ preserveAssistantTranscript: false,
+};
+
+const PROVIDER_CAPABILITIES: Partial> = {
+ deepseek: { preserveAssistantTranscript: true },
+};
+
+export const getProviderCapabilities = (provider: LLMProvider): ProviderCapabilities => ({
+ ...DEFAULT_PROVIDER_CAPABILITIES,
+ ...PROVIDER_CAPABILITIES[provider],
+});
+
/**
* Get display name for a provider
*/
@@ -368,6 +408,8 @@ export const getProviderDisplayName = (provider: LLMProvider): string => {
return 'MiniMax';
case 'glm':
return 'GLM (Z.AI)';
+ case 'deepseek':
+ return 'DeepSeek';
default:
return provider;
}
@@ -398,6 +440,8 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed'];
case 'glm':
return ['GLM-5', 'GLM-5-Turbo', 'GLM-4.7', 'GLM-4.5'];
+ case 'deepseek':
+ return ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'];
default:
return [];
}
diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts
index bebbab830..c568c7d93 100644
--- a/gitnexus-web/src/core/llm/types.ts
+++ b/gitnexus-web/src/core/llm/types.ts
@@ -2,7 +2,7 @@
* LLM Provider Types
*
* Type definitions for multi-provider LLM support.
- * Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, and GLM5.
+ * Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, GLM, and DeepSeek.
*/
/**
@@ -17,7 +17,8 @@ export type LLMProvider =
| 'ollama'
| 'openrouter'
| 'minimax'
- | 'glm';
+ | 'glm'
+ | 'deepseek';
/**
* Base configuration shared by all providers
@@ -106,6 +107,15 @@ export interface GLMConfig extends BaseProviderConfig {
baseUrl?: string; // defaults to https://api.z.ai/api/coding/paas/v4
}
+/**
+ * DeepSeek configuration — OpenAI-compatible API
+ */
+export interface DeepSeekConfig extends BaseProviderConfig {
+ provider: 'deepseek';
+ apiKey: string;
+ model: string; // e.g., 'deepseek-v4-flash', 'deepseek-v4-pro'
+}
+
/**
* Union type for all provider configurations
*/
@@ -117,7 +127,8 @@ export type ProviderConfig =
| OllamaConfig
| OpenRouterConfig
| MiniMaxConfig
- | GLMConfig;
+ | GLMConfig
+ | DeepSeekConfig;
/**
* Stored settings (what goes to localStorage)
@@ -136,6 +147,7 @@ export interface LLMSettings {
openrouter?: Partial>;
minimax?: Partial>;
glm?: Partial>;
+ deepseek?: Partial>;
// Intelligent Clustering Settings
intelligentClustering: boolean;
@@ -197,6 +209,11 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = {
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
temperature: 0.1,
},
+ deepseek: {
+ apiKey: '',
+ model: 'deepseek-v4-flash',
+ temperature: 0.1,
+ },
};
/**
@@ -219,6 +236,8 @@ export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
+ /** Hidden raw transcript for reconstructing future agent turns */
+ historyMessages?: AgentHistoryMessage[];
/** @deprecated Use steps instead for proper ordering */
toolCalls?: ToolCallInfo[];
/** Ordered steps: reasoning, tool calls, and final content interleaved */
@@ -238,6 +257,34 @@ export interface ToolCallInfo {
status: 'pending' | 'running' | 'completed' | 'error';
}
+/**
+ * Minimal tool-call payload needed to reconstruct prior assistant turns.
+ */
+export interface AgentToolCall {
+ id?: string;
+ name: string;
+ args: Record;
+ type: 'tool_call';
+}
+
+/**
+ * Hidden per-turn transcript we keep so providers like DeepSeek can replay
+ * the original assistant/tool exchange on later user turns.
+ */
+export type AgentHistoryMessage =
+ | {
+ role: 'assistant';
+ content: string;
+ reasoningContent?: string;
+ toolCalls?: AgentToolCall[];
+ }
+ | {
+ role: 'tool';
+ content: string;
+ toolCallId: string;
+ name?: string;
+ };
+
/**
* Streaming chunk from agent
* Now supports step-based streaming where each step is a distinct message
@@ -248,6 +295,8 @@ export interface AgentStreamChunk {
reasoning?: string;
/** Final answer content (streamed token by token) */
content?: string;
+ /** Hidden raw transcript for reconstructing future agent turns */
+ historyMessages?: AgentHistoryMessage[];
/** Tool call information */
toolCall?: ToolCallInfo;
/** Error message */
diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx
index 876d56852..5a7e85457 100644
--- a/gitnexus-web/src/hooks/useAppState.tsx
+++ b/gitnexus-web/src/hooks/useAppState.tsx
@@ -18,7 +18,12 @@ import type {
ToolCallInfo,
MessageStep,
} from '../core/llm/types';
-import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service';
+import {
+ loadSettings,
+ getActiveProviderConfig,
+ getProviderCapabilities,
+ saveSettings,
+} from '../core/llm/settings-service';
import type { AgentMessage } from '../core/llm/agent';
import { type EdgeType } from '../lib/constants';
import {
@@ -635,6 +640,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const sendChatMessage = useCallback(
async (message: string): Promise => {
+ if (isChatLoading) return;
+
// Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations
clearAICodeReferences();
// Also clear previous tool-driven AI highlights (highlight_in_graph)
@@ -674,11 +681,23 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setIsChatLoading(true);
setCurrentToolCalls([]);
+ const providerCapabilities = getProviderCapabilities(llmSettings.activeProvider);
+
// Prepare message history for agent (convert our format to AgentMessage format)
- const history: AgentMessage[] = [...chatMessages, userMessage].map((m) => ({
- role: m.role === 'tool' ? 'assistant' : m.role,
- content: m.content,
- }));
+ const history: AgentMessage[] = [...chatMessages, userMessage].flatMap((m) => {
+ if (m.role === 'user') {
+ return [{ role: 'user', content: m.content }];
+ }
+ if (m.role === 'tool') {
+ return m.toolCallId
+ ? [{ role: 'tool', content: m.content, toolCallId: m.toolCallId }]
+ : [];
+ }
+ if (providerCapabilities.preserveAssistantTranscript && m.historyMessages?.length) {
+ return m.historyMessages;
+ }
+ return [{ role: 'assistant', content: m.content }];
+ });
// Create placeholder for assistant response
const assistantMessageId = `assistant-${Date.now()}`;
@@ -687,6 +706,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Keep toolCalls for backwards compat and currentToolCalls state
const toolCallsForMessage: ToolCallInfo[] = [];
let stepCounter = 0;
+ let assistantHistoryMessages: ChatMessage['historyMessages'];
// Helper to update the message with current steps
const updateMessage = () => {
@@ -703,6 +723,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
id: assistantMessageId,
role: 'assistant' as const,
content,
+ historyMessages: assistantHistoryMessages,
steps: [...stepsForMessage],
toolCalls: [...toolCallsForMessage],
timestamp: existing?.timestamp ?? Date.now(),
@@ -985,6 +1006,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
break;
case 'done':
+ assistantHistoryMessages = providerCapabilities.preserveAssistantTranscript
+ ? chunk.historyMessages
+ : undefined;
// Finalize the assistant message - just call updateMessage one more time
scheduleMessageUpdate();
break;
@@ -996,10 +1020,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const agent = agentRef.current;
if (!agent) throw new Error('Agent not initialized');
const { streamAgentResponse } = await import('../core/llm/agent');
- for await (const chunk of streamAgentResponse(agent, history)) {
+ for await (const chunk of streamAgentResponse(agent, history, {
+ captureHistory: providerCapabilities.preserveAssistantTranscript,
+ })) {
onChunk(chunk);
}
- onChunk({ type: 'done' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAgentError(message);
@@ -1019,6 +1044,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
clearAIToolHighlights,
graph,
embeddingStatus,
+ isChatLoading,
],
);
diff --git a/gitnexus-web/test/unit/agent-history.test.ts b/gitnexus-web/test/unit/agent-history.test.ts
new file mode 100644
index 000000000..756534b25
--- /dev/null
+++ b/gitnexus-web/test/unit/agent-history.test.ts
@@ -0,0 +1,396 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildLangChainMessages,
+ createChatModel,
+ serializeAgentHistoryMessages,
+ type AgentMessage,
+} from '../../src/core/llm/agent';
+import {
+ buildDeepSeekRequestMessages,
+ DeepSeekChatOpenAI,
+ DeepSeekChatOpenAICompletions,
+} from '../../src/core/llm/deepseek-chat-model';
+
+describe('buildLangChainMessages', () => {
+ it('reconstructs assistant tool-call turns for replay', () => {
+ const messages: AgentMessage[] = [
+ { role: 'user', content: 'Check the weather' },
+ {
+ role: 'assistant',
+ content: 'Let me check that.',
+ reasoningContent: '',
+ toolCalls: [
+ {
+ id: 'call_weather',
+ name: 'get_weather',
+ args: { location: 'Hangzhou' },
+ type: 'tool_call',
+ },
+ ],
+ },
+ {
+ role: 'tool',
+ content: 'Cloudy 7~13°C',
+ toolCallId: 'call_weather',
+ name: 'get_weather',
+ },
+ ];
+
+ const langChainMessages = buildLangChainMessages(messages);
+
+ expect(langChainMessages).toHaveLength(3);
+ expect((langChainMessages[1] as any).additional_kwargs.reasoning_content).toBe('');
+ expect((langChainMessages[1] as any).tool_calls).toEqual([
+ {
+ id: 'call_weather',
+ name: 'get_weather',
+ args: { location: 'Hangzhou' },
+ type: 'tool_call',
+ },
+ ]);
+ expect((langChainMessages[2] as any).tool_call_id).toBe('call_weather');
+ });
+});
+
+describe('serializeAgentHistoryMessages', () => {
+ it('captures assistant and tool messages from a completed turn', () => {
+ const serialized = serializeAgentHistoryMessages(
+ [
+ { _getType: () => 'human', content: 'old prompt' },
+ {
+ _getType: () => 'ai',
+ content: 'Let me check that.',
+ additional_kwargs: { reasoning_content: 'Need weather tool.' },
+ tool_calls: [
+ {
+ id: 'call_weather',
+ name: 'get_weather',
+ args: { location: 'Hangzhou' },
+ type: 'tool_call',
+ },
+ ],
+ },
+ {
+ _getType: () => 'tool',
+ content: 'Cloudy 7~13°C',
+ tool_call_id: 'call_weather',
+ name: 'get_weather',
+ },
+ {
+ _getType: () => 'ai',
+ content: 'Tomorrow will be cloudy.',
+ additional_kwargs: { reasoning_content: 'Result received.' },
+ },
+ ],
+ 1,
+ );
+
+ expect(serialized).toEqual([
+ {
+ role: 'assistant',
+ content: 'Let me check that.',
+ reasoningContent: 'Need weather tool.',
+ toolCalls: [
+ {
+ id: 'call_weather',
+ name: 'get_weather',
+ args: { location: 'Hangzhou' },
+ type: 'tool_call',
+ },
+ ],
+ },
+ {
+ role: 'tool',
+ content: 'Cloudy 7~13°C',
+ toolCallId: 'call_weather',
+ name: 'get_weather',
+ },
+ {
+ role: 'assistant',
+ content: 'Tomorrow will be cloudy.',
+ },
+ ]);
+ });
+});
+
+describe('buildDeepSeekRequestMessages', () => {
+ it('preserves reasoning_content on assistant tool-call messages', () => {
+ const requestMessages = buildDeepSeekRequestMessages(
+ buildLangChainMessages([
+ { role: 'user', content: '如何支持Gitlab Repo' },
+ {
+ role: 'assistant',
+ content: '',
+ reasoningContent: 'I should inspect the repository support flow first.',
+ toolCalls: [
+ {
+ id: 'call_1',
+ name: 'search',
+ args: { query: 'Gitlab repo support' },
+ type: 'tool_call',
+ },
+ ],
+ },
+ {
+ role: 'tool',
+ content: 'No matches',
+ toolCallId: 'call_1',
+ name: 'search',
+ },
+ ]),
+ );
+
+ expect(requestMessages).toEqual([
+ { role: 'user', content: '如何支持Gitlab Repo' },
+ {
+ role: 'assistant',
+ content: '',
+ reasoning_content: 'I should inspect the repository support flow first.',
+ tool_calls: [
+ {
+ id: 'call_1',
+ type: 'function',
+ function: {
+ name: 'search',
+ arguments: '{"query":"Gitlab repo support"}',
+ },
+ },
+ ],
+ },
+ {
+ role: 'tool',
+ content: 'No matches',
+ name: 'search',
+ tool_call_id: 'call_1',
+ },
+ ]);
+ });
+});
+
+it('drops reasoning_content from assistant messages without tool calls', () => {
+ const messages = buildLangChainMessages([
+ { role: 'user', content: 'Hello' },
+ {
+ role: 'assistant',
+ content: 'Hi there',
+ reasoningContent: 'I should greet the user.',
+ },
+ ]);
+
+ const requestMessages = buildDeepSeekRequestMessages(messages);
+
+ expect(requestMessages).toEqual([
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi there' },
+ ]);
+});
+
+it('drops reasoningContent from serialized assistant messages without tool calls', () => {
+ const serialized = serializeAgentHistoryMessages(
+ [
+ {
+ _getType: () => 'ai',
+ content: 'Simple answer.',
+ additional_kwargs: { reasoning_content: 'Thinking about it.' },
+ },
+ ],
+ 0,
+ );
+
+ expect(serialized).toEqual([
+ {
+ role: 'assistant',
+ content: 'Simple answer.',
+ },
+ ]);
+});
+
+describe('createChatModel', () => {
+ it('keeps DeepSeek model subclasses on withConfig clones used for tool binding', () => {
+ const model = createChatModel({
+ provider: 'deepseek',
+ apiKey: 'test-key',
+ model: 'deepseek-v4-flash',
+ temperature: 0.1,
+ } as any) as any;
+
+ expect(model).toBeInstanceOf(DeepSeekChatOpenAI);
+ expect(model.completions).toBeInstanceOf(DeepSeekChatOpenAICompletions);
+
+ const clonedModel = model.withConfig({ tools: [] }) as any;
+
+ expect(clonedModel).toBeInstanceOf(DeepSeekChatOpenAI);
+ expect(clonedModel.completions).toBeInstanceOf(DeepSeekChatOpenAICompletions);
+ });
+
+ it('uses DeepSeek serialization on withConfig clones', async () => {
+ const model = createChatModel({
+ provider: 'deepseek',
+ apiKey: 'test-key',
+ model: 'deepseek-v4-flash',
+ temperature: 0.1,
+ } as any) as any;
+ const clonedModel = model.withConfig({ tools: [] }) as any;
+ clonedModel.completions.streaming = false;
+ let capturedRequest: any;
+
+ clonedModel.completions.client = {
+ chat: {
+ completions: {
+ create: async (request: any) => {
+ capturedRequest = request;
+ return {
+ choices: [
+ {
+ message: { role: 'assistant', content: 'ok' },
+ finish_reason: 'stop',
+ },
+ ],
+ };
+ },
+ },
+ },
+ };
+
+ await clonedModel.completions._generate(
+ buildLangChainMessages([
+ { role: 'user', content: 'Check the weather' },
+ {
+ role: 'assistant',
+ content: '',
+ reasoningContent: 'Need the weather tool.',
+ toolCalls: [
+ {
+ id: 'call_weather',
+ name: 'get_weather',
+ args: { location: 'Hangzhou' },
+ type: 'tool_call',
+ },
+ ],
+ },
+ {
+ role: 'tool',
+ content: 'Cloudy 7~13°C',
+ toolCallId: 'call_weather',
+ name: 'get_weather',
+ },
+ ]),
+ { stream: false },
+ );
+
+ expect(capturedRequest.messages[1].reasoning_content).toBe('Need the weather tool.');
+ expect(capturedRequest.messages[1].tool_calls[0].function.arguments).toBe(
+ '{"location":"Hangzhou"}',
+ );
+ expect(capturedRequest.messages[2].tool_call_id).toBe('call_weather');
+ });
+
+ it('preserves reasoning_content through the streaming path used by DeepSeek tool calls', async () => {
+ const model = createChatModel({
+ provider: 'deepseek',
+ apiKey: 'test-key',
+ model: 'deepseek-v4-flash',
+ temperature: 0.1,
+ } as any) as any;
+ model.completions.streaming = true;
+
+ async function* mockStream() {
+ yield {
+ id: 'chatcmpl-1',
+ model: 'deepseek-v4-flash',
+ choices: [
+ {
+ index: 0,
+ delta: {
+ role: 'assistant',
+ reasoning_content: 'Need the weather tool.',
+ },
+ },
+ ],
+ };
+ yield {
+ id: 'chatcmpl-1',
+ model: 'deepseek-v4-flash',
+ choices: [
+ {
+ index: 0,
+ delta: {
+ tool_calls: [
+ {
+ index: 0,
+ id: 'call_weather',
+ type: 'function',
+ function: {
+ name: 'get_weather',
+ arguments: '{"location":"Hangzhou"}',
+ },
+ },
+ ],
+ },
+ finish_reason: 'tool_calls',
+ },
+ ],
+ };
+ }
+
+ model.completions.client = {
+ chat: {
+ completions: {
+ create: async () => mockStream(),
+ },
+ },
+ };
+
+ let streamedMessage: any;
+ for await (const chunk of model.completions._streamResponseChunks(
+ buildLangChainMessages([{ role: 'user', content: 'Check the weather' }]),
+ {},
+ )) {
+ streamedMessage = streamedMessage ? streamedMessage.concat(chunk.message) : chunk.message;
+ }
+
+ expect(streamedMessage.additional_kwargs.reasoning_content).toBe('Need the weather tool.');
+ expect(streamedMessage.tool_calls).toEqual([
+ {
+ id: 'call_weather',
+ name: 'get_weather',
+ args: { location: 'Hangzhou' },
+ type: 'tool_call',
+ },
+ ]);
+
+ expect(serializeAgentHistoryMessages([streamedMessage], 0)).toEqual([
+ {
+ role: 'assistant',
+ content: '',
+ reasoningContent: 'Need the weather tool.',
+ toolCalls: [
+ {
+ id: 'call_weather',
+ name: 'get_weather',
+ args: { location: 'Hangzhou' },
+ type: 'tool_call',
+ },
+ ],
+ },
+ ]);
+ });
+
+ it('rejects overlapping DeepSeek requests before reusing active messages', async () => {
+ const model = createChatModel({
+ provider: 'deepseek',
+ apiKey: 'test-key',
+ model: 'deepseek-v4-flash',
+ temperature: 0.1,
+ } as any) as any;
+
+ model.completions.activeMessages = buildLangChainMessages([{ role: 'user', content: 'busy' }]);
+
+ await expect(
+ model.completions._generate(
+ buildLangChainMessages([{ role: 'user', content: 'Check the weather' }]),
+ { stream: false },
+ ),
+ ).rejects.toThrow('DeepSeekChatOpenAICompletions does not support overlapping requests');
+ });
+});
diff --git a/gitnexus-web/test/unit/settings-service.test.ts b/gitnexus-web/test/unit/settings-service.test.ts
index 17514725c..a9ded356f 100644
--- a/gitnexus-web/test/unit/settings-service.test.ts
+++ b/gitnexus-web/test/unit/settings-service.test.ts
@@ -8,6 +8,7 @@ import {
clearSettings,
getProviderDisplayName,
getAvailableModels,
+ getProviderCapabilities,
} from '../../src/core/llm/settings-service';
describe('loadSettings', () => {
@@ -104,6 +105,17 @@ describe('getActiveProviderConfig', () => {
expect(config!.provider).toBe('openai');
});
+ it('returns config for deepseek when API key is set', () => {
+ const settings = loadSettings();
+ settings.activeProvider = 'deepseek';
+ settings.deepseek = { ...settings.deepseek, apiKey: 'sk-deepseek-123' };
+ saveSettings(settings);
+
+ const config = getActiveProviderConfig();
+ expect(config).not.toBeNull();
+ expect(config!.provider).toBe('deepseek');
+ });
+
it('returns null for openrouter with empty API key', () => {
const settings = loadSettings();
settings.activeProvider = 'openrouter';
@@ -139,6 +151,7 @@ describe('getProviderDisplayName', () => {
expect(getProviderDisplayName('anthropic')).toBe('Anthropic');
expect(getProviderDisplayName('ollama')).toBe('Ollama (Local)');
expect(getProviderDisplayName('openrouter')).toBe('OpenRouter');
+ expect(getProviderDisplayName('deepseek')).toBe('DeepSeek');
});
});
@@ -147,9 +160,18 @@ describe('getAvailableModels', () => {
expect(getAvailableModels('openai').length).toBeGreaterThan(0);
expect(getAvailableModels('ollama').length).toBeGreaterThan(0);
expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514');
+ expect(getAvailableModels('deepseek')).toContain('deepseek-v4-flash');
});
it('returns empty array for unknown provider', () => {
expect(getAvailableModels('unknown' as any)).toEqual([]);
});
});
+
+describe('getProviderCapabilities', () => {
+ it('enables transcript replay only for providers that require it', () => {
+ expect(getProviderCapabilities('deepseek').preserveAssistantTranscript).toBe(true);
+ expect(getProviderCapabilities('openai').preserveAssistantTranscript).toBe(false);
+ expect(getProviderCapabilities('anthropic').preserveAssistantTranscript).toBe(false);
+ });
+});