diff --git a/packages/client-react/src/stores/session-store.ts b/packages/client-react/src/stores/session-store.ts index 6857d92e..3e5caffa 100644 --- a/packages/client-react/src/stores/session-store.ts +++ b/packages/client-react/src/stores/session-store.ts @@ -213,7 +213,7 @@ export const useSessionStore = create((set, get) => ({ }, sendMessage: async (content) => { - const { sessionId, entryAgentId, pendingQuestions, pendingAttachments } = get() + const { sessionId, entryAgentId, pendingQuestions, pendingAttachments, messageContext } = get() if (!sessionId || !entryAgentId || pendingQuestions.length > 0) return // Collect ready attachments for display @@ -252,7 +252,12 @@ export const useSessionStore = create((set, get) => ({ try { // Use content or a placeholder if only attachments const messageContent = content.trim() || (readyAttachments.length > 0 ? `[${readyAttachments.map((a) => a.filename).join(', ')}]` : '') - unwrap(await api.call('user-chat.sendMessage', { sessionId, agentId: AgentId(entryAgentId), content: messageContent })) + unwrap(await api.call('user-chat.sendMessage', { + sessionId, + agentId: AgentId(entryAgentId), + content: messageContent, + context: messageContext ?? undefined, + })) // Remove from pending on success and clear attachments const newPendingMessages = new Map(get().pendingMessages) newPendingMessages.delete(messageId) diff --git a/packages/sdk/src/plugins/user-chat/plugin.ts b/packages/sdk/src/plugins/user-chat/plugin.ts index 9388e04b..069bdee8 100644 --- a/packages/sdk/src/plugins/user-chat/plugin.ts +++ b/packages/sdk/src/plugins/user-chat/plugin.ts @@ -56,6 +56,7 @@ export const userChatEvents = createEventsFactory({ agentId: agentIdSchema, messageId: chatMessageIdSchema, content: z.string(), + context: z.string().optional(), timestamp: z.number(), }), user_chat_answer_received: z.object({ @@ -95,6 +96,8 @@ export interface UserChatMessage { type: "user_message"; messageId: ChatMessageId; content: string; + /** What the client knew when the user sent the message; the LLM sees it, the chat does not. */ + context?: string; timestamp: number; } @@ -384,6 +387,9 @@ function formatPendingForLLM( ? indexedMessage : findUserMessage(messages, msg.messageId); if (userMessage) parts.push(`[User]: ${userMessage.content}`); + if (userMessage?.context) { + parts.push(`\n${userMessage.context}\n`); + } } } return parts.join("\n"); @@ -469,6 +475,7 @@ export const userChatPlugin = definePlugin("user-chat") type: "user_message", messageId: event.messageId, content: event.content, + ...(event.context !== undefined && { context: event.context }), timestamp: event.timestamp, }; const pending: PendingInboundMessage = { @@ -676,6 +683,7 @@ export const userChatPlugin = definePlugin("user-chat") input: z.object({ agentId: agentIdSchema.optional(), content: z.string(), + context: z.string().optional(), }), output: z.object({ messageId: chatMessageIdSchema, @@ -688,10 +696,12 @@ export const userChatPlugin = definePlugin("user-chat") // Hard limit const tokenCount = estimateTokens(input.content); - if (tokenCount > MESSAGE_MAX_TOKENS) { + const totalTokenCount = + tokenCount + (input.context === undefined ? 0 : estimateTokens(input.context)); + if (totalTokenCount > MESSAGE_MAX_TOKENS) { return Err( ValidationErrors.invalid( - `Message too large: ~${tokenCount} tokens (max ${MESSAGE_MAX_TOKENS})`, + `Message too large: ~${totalTokenCount} tokens (max ${MESSAGE_MAX_TOKENS})`, ), ); } @@ -728,6 +738,7 @@ export const userChatPlugin = definePlugin("user-chat") agentId: agentId, messageId, content, + ...(input.context !== undefined && { context: input.context }), timestamp: Date.now(), }), ); diff --git a/packages/sdk/src/plugins/user-chat/user-chat.integration.test.ts b/packages/sdk/src/plugins/user-chat/user-chat.integration.test.ts index da872166..cca99fa2 100644 --- a/packages/sdk/src/plugins/user-chat/user-chat.integration.test.ts +++ b/packages/sdk/src/plugins/user-chat/user-chat.integration.test.ts @@ -62,6 +62,53 @@ describe('user-chat plugin', () => { await harness.shutdown() }) + it('sendMessage with context → LLM sees the context, chat history keeps it apart from content', async () => { + const harness = new TestHarness({ + presets: [createTestPreset()], + llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }), + }) + + const session = await harness.createSession('test') + const result = await session.callPluginMethod('user-chat.sendMessage', { + content: 'Make this heading bigger', + context: 'Page: /about', + }) + expect(result.ok).toBe(true) + await session.waitForIdle() + + const lastRequest = harness.llmProvider.getLastRequest() + const lastMessage = lastRequest!.messages[lastRequest!.messages.length - 1] + expect(lastMessage.content).toBe('[User]: Make this heading bigger\n\nPage: /about\n') + + const state = selectPluginState(session.state, 'messages') + expect(state?.messages).toContainEqual(expect.objectContaining({ + type: 'user_message', + content: 'Make this heading bigger', + context: 'Page: /about', + })) + + await harness.shutdown() + }) + + it('sendMessage without context → LLM gets no context block', async () => { + const harness = new TestHarness({ + presets: [createTestPreset()], + llmProvider: MockLLMProvider.withFixedResponse({ content: 'Ok', toolCalls: [] }), + }) + + const session = await harness.createSession('test') + await session.sendAndWaitForIdle('Hello world') + + const lastRequest = harness.llmProvider.getLastRequest() + const lastMessage = lastRequest!.messages[lastRequest!.messages.length - 1] + expect(lastMessage.content).toBe('[User]: Hello world') + + const events = await session.getEventsByType('user_chat_message_received') + expect(events[0]).not.toHaveProperty('context') + + await harness.shutdown() + }) + it('sendMessage to specific agent (non-entry) via sendMessageToAgent', async () => { const harness = new TestHarness({ presets: [createTestPreset()],