Skip to content

feat: in-app AI chat assistant backed by the MCP server tools (JEF-12) - #159

Merged
mankatcheung merged 3 commits into
mainfrom
worktree-jef-12-ai-chat-assistant
Jul 31, 2026
Merged

mankatcheung merged 3 commits into
mainfrom
worktree-jef-12-ai-chat-assistant

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Jul 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds an in-app AI chat assistant that lets users ask natural-language questions about their applications, contacts, and interview rounds — powered by the same 5 tools already exposed by the MCP server (list_applications, get_application, list_notes, list_contacts, list_interview_rounds), reused in-process rather than called over HTTP.

  • ILLMProvider extended with tool-calling support (completeWithTools) alongside the existing single-shot complete(). New provider-agnostic types (LLMMessage, LLMToolCall, LLMToolDefinition, LLMCompletionResult) let the orchestration use-case stay fully provider-agnostic while each concrete provider owns translation to/from its own wire format:
    • OpenAICompatibleLLMProvider — covers 7 of 9 BYOK providers sharing OpenAI's /chat/completions shape (OpenAI, OpenRouter, Mistral, Groq, xAI, DeepSeek, Custom).
    • AnthropicLLMProvider — Messages API tool_use/tool_result content blocks.
    • GoogleAILLMProvider — Gemini functionDeclarations/functionCall/functionResponse. Caveat: implemented from best understanding of the API, not verified against a live Gemini key — needs a smoke test before relying on it in production.
  • ChatWithAssistantUseCase — a bounded agentic loop (max 5 LLM↔tool round-trips per turn) that feeds the 5 MCP tools to the LLM, executes whichever the model calls, and returns a final text answer. Tool-execution errors (e.g. a bad applicationId) are caught and fed back to the model as an error result rather than thrown, so the model can explain the problem instead of the request 500ing.
  • Ephemeral, frontend-held conversation history — no new DB table/entity. The frontend resends the full {role, content} history each turn; tool-call/tool-result messages exist only within a single backend execute() call and are never persisted or surfaced to the client.
  • Security: client-supplied history is validated to only contain role: 'user' | 'assistant' — otherwise a crafted role: 'system' entry could inject a fake system message.
  • Rate limiting: new chat:${userId} key, 20 messages / 5 minutes, reusing the existing IRateLimiter.
  • New GraphQL surface: sendChatMessage(history: [ChatMessageInput!]!, message: String!): String mutation.
  • New frontend /assistant page: chat UI with suggested-question chips, message bubbles, a "Thinking…" state, and an AI_NOT_CONFIGURED → Account settings error banner (same pattern as the existing cover-letter/resume-match features). Added to both the desktop sidebar and mobile bottom nav.

Ref: Linear JEF-12.

Test plan

  • pnpm --filter @job-finder/api test — 821 tests passing (new: ChatWithAssistantUseCase.test.ts with 15 tests covering rate limiting, role validation, AI_NOT_CONFIGURED, direct responses, tool dispatch for all 5 tools, unknown-tool/tool-error handling, multi-round loops, and max-iteration exhaustion; plus new completeWithTools test blocks across all 3 LLM provider test files)
  • pnpm --filter @job-finder/api typecheck and pnpm --filter @job-finder/web typecheck — clean
  • pnpm --filter @job-finder/api build and pnpm --filter @job-finder/web build — clean
  • pnpm lint (both apps) — clean
  • Manual verification against a live local dev server (curl): confirmed AI_NOT_CONFIGURED with no key configured, VALIDATION for a role: "system" history entry, correct pipeline wiring after saving a BYOK key (fails only on the fake key upstream, proving the request reaches the real provider call), and rate limiting triggering exactly on the 21st request in a 5-minute window.
  • Not yet verified: Google Gemini's function-calling wire format against a live API key — flagged for follow-up smoke testing before production use.
  • One pre-existing, unrelated flaky test (DrizzleDocumentRepository.test.ts timestamp-ordering test) reproduced in full-suite runs but passes in isolation — not caused by this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N2PBmsuzPhrmNnfZf6C3BM

Summary by CodeRabbit

  • New Features

    • Added an authenticated Assistant chat page with suggested questions, conversation history, loading states and error handling.
    • Added Assistant access to desktop and mobile navigation.
    • Enabled AI responses to retrieve relevant application-tracking information.
    • Added tool-enabled responses across supported AI providers.
    • Added chat request rate limiting and safeguards for lengthy interactions.
  • Tests

    • Added comprehensive coverage for chat behaviour, provider integrations, tool execution, errors and limits.

Adds a chat UI letting users ask natural-language questions across
their applications, contacts, and interview rounds. Reuses the MCP
server's tool schemas and the use-cases it delegates to
(GetApplicationsUseCase, GetApplicationUseCase, GetNotesUseCase,
GetContactsUseCase, GetInterviewRoundsUseCase) in-process with normal
session auth, rather than round-tripping through the MCP HTTP
endpoint (which is API-token authenticated, a separate concern).

- ILLMProvider gains completeWithTools(messages, tools, maxTokens?),
  implemented in all 3 provider classes: OpenAICompatibleLLMProvider
  (OpenAI tools/tool_calls shape — covers 7 of 9 BYOK providers),
  AnthropicLLMProvider (tools/tool_use/tool_result), GoogleAILLMProvider
  (functionDeclarations/functionCall/functionResponse). Each
  translates the generic LLMMessage[]/tool types to/from its own wire
  format internally.
- ChatWithAssistantUseCase runs a bounded tool-call loop (max 5
  round-trips) against completeWithTools, dispatching each requested
  tool to its matching use-case and feeding results back until the
  LLM returns a final answer. Rate-limited per user (20 messages / 5
  min, reusing the existing IRateLimiter). AI_NOT_CONFIGURED when no
  BYOK key is set, same as the other 3 AI features. Rejects any
  history entry with a role other than user/assistant, since allowing
  arbitrary roles would let a client inject a fake system message.
  Conversation history is ephemeral (frontend-held, resent each
  turn) — no new DB table for v1.
- sendChatMessage(history, message) GraphQL mutation with a new
  ChatMessageInput type (first nested-list input in this codebase).
- Frontend: new /assistant page (own sidebar/mobile-nav item) with a
  message list, suggested-question chips, and the same
  AI_NOT_CONFIGURED -> Account settings prompt as the other AI
  features.

Note: Gemini's function-calling wire format (systemInstruction /
functionCall / functionResponse with a "function" role) is implemented
to the best of my knowledge but not verified against a live Google AI
key — worth a real smoke test before relying on it in production.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2PBmsuzPhrmNnfZf6C3BM
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 96eb6215-c04b-4029-b423-11aa7a0fb8d0

📥 Commits

Reviewing files that changed from the base of the PR and between 7987205 and d53b754.

📒 Files selected for processing (4)
  • apps/api/src/__tests__/helpers/mocks.ts
  • apps/api/src/constants.ts
  • apps/api/src/http/container.ts
  • apps/api/src/http/schema/index.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/api/src/http/schema/index.ts
  • apps/api/src/constants.ts
  • apps/api/src/tests/helpers/mocks.ts
  • apps/api/src/http/container.ts

Walkthrough

Adds an authenticated assistant chat UI backed by a GraphQL mutation and a rate-limited chat use case. The API supports iterative LLM tool calling, application-related tool dispatch, and Anthropic, Gemini, and OpenAI-compatible provider serialisation.

Changes

Assistant chat

Layer / File(s) Summary
LLM tool-calling contract and provider adapters
apps/api/src/use-cases/ports/ILLMProvider.ts, apps/api/src/infrastructure/llm/*, apps/api/src/__tests__/infrastructure/llm/*, apps/api/src/__tests__/helpers/mocks.ts
LLM messages, tool definitions, completion results, provider adapters, and provider tests now support structured tool calls and tool results.
Chat use case and tool execution
apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts, apps/api/src/constants.ts, apps/api/src/__tests__/application/chat/*
The chat use case validates history, applies rate limits, resolves the user’s provider, executes supported tools across multiple rounds, and enforces an iteration cap with tested fallbacks and errors.
GraphQL mutation and dependency wiring
apps/api/src/http/schema/types/inputs/ChatInputs.ts, apps/api/src/http/schema/mutations/chatMutations.ts, apps/api/src/http/schema/index.ts, apps/api/src/http/container.ts
Registers the authenticated sendChatMessage mutation and wires its rate limiter and transient use case dependencies.
Authenticated assistant page and navigation
apps/web/src/routes/_authenticated/assistant.tsx, apps/web/src/routes/_authenticated/route.tsx, apps/web/src/routeTree.gen.ts
Adds the assistant route, chat interface, mutation handling, loading and configuration errors, suggested questions, and desktop and mobile navigation entries.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant AssistantPage
  participant sendChatMessage
  participant ChatWithAssistantUseCase
  participant LLMProvider
  participant ApplicationUseCase
  User->>AssistantPage: submit message
  AssistantPage->>sendChatMessage: send history and message
  sendChatMessage->>ChatWithAssistantUseCase: execute authenticated request
  ChatWithAssistantUseCase->>LLMProvider: completeWithTools
  LLMProvider-->>ChatWithAssistantUseCase: return tool call
  ChatWithAssistantUseCase->>ApplicationUseCase: execute selected tool
  ApplicationUseCase-->>ChatWithAssistantUseCase: return tool result
  ChatWithAssistantUseCase->>LLMProvider: completeWithTools with tool result
  LLMProvider-->>ChatWithAssistantUseCase: return final content
  ChatWithAssistantUseCase-->>sendChatMessage: return response
  sendChatMessage-->>AssistantPage: return assistant message
  AssistantPage-->>User: render response
Loading

Possibly related PRs

Poem

A rabbit sends a chat request,
Tools return each needed fact.
Providers parse the calls anew,
The assistant page displays the view.
Five rounds guide the answer bright. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarises the new in-app AI chat assistant and its MCP server tool integration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-jef-12-ai-chat-assistant

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (6)
apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts (1)

33-42: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

complete() doesn't convert 'tool'/tool-call messages, unlike completeWithTools() and OpenAICompatibleLLMProvider.complete().

conversation.map((m) => ({ role: m.role, content: m.content })) passes m.role straight through, whereas completeWithTools uses toWireMessages to properly convert 'tool' role entries into tool_result blocks and assistant tool-calls into tool_use blocks. Anthropic's Messages API only accepts user/assistant roles — a literal role: 'tool' would be rejected. If complete() is ever invoked with a history that contains prior tool-call turns (e.g. a future caller reusing conversation state built for completeWithTools), this will fail with a 400 from Anthropic. OpenAICompatibleLLMProvider.complete() already routes through its toWireMessages, so this is an inconsistency between the two providers.

🐛 Suggested fix
     const { system, conversation } = this.splitSystem(messages);
     const json = await this.post({
       model: this.model,
       max_tokens: maxTokens,
       ...(system ? { system } : {}),
-      messages: conversation.map((m) => ({ role: m.role, content: m.content })),
+      messages: this.toWireMessages(conversation),
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts` around lines 33 -
42, Update AnthropicLLMProvider.complete() to convert conversation messages
through the existing toWireMessages helper instead of mapping m.role and
m.content directly. Preserve the current system-message handling and request
structure while ensuring tool roles become tool_result blocks and assistant tool
calls become tool_use blocks, consistent with completeWithTools() and
OpenAICompatibleLLMProvider.complete().
apps/api/src/use-cases/ports/ILLMProvider.ts (1)

7-14: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

LLMMessage isn't a discriminated union, allowing invalid combinations.

role, toolCallId, and toolCalls are all independent optional fields, so nothing at the type level prevents constructing e.g. a 'tool' message without toolCallId, or a 'user' message with toolCalls set. Both AnthropicLLMProvider.toWireMessages and OpenAICompatibleLLMProvider.toWireMessages silently fall back to ''/undefined for toolCallId when it's missing rather than failing fast, which would produce a malformed request to the provider if this invariant is ever violated upstream.

Consider a discriminated union to make invalid states unrepresentable:

♻️ Suggested discriminated union
export type LLMMessage =
  | { role: 'system' | 'user'; content: string }
  | { role: 'assistant'; content: string; toolCalls?: LLMToolCall[] }
  | { role: 'tool'; content: string; toolCallId: string };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/ports/ILLMProvider.ts` around lines 7 - 14, Replace
the independent-field LLMMessage interface with a discriminated union:
system/user messages should only contain content, assistant messages may
optionally contain toolCalls, and tool messages must require toolCallId. Update
dependent construction and handling in AnthropicLLMProvider.toWireMessages and
OpenAICompatibleLLMProvider.toWireMessages as needed so they rely on the
enforced invariant instead of silently defaulting missing toolCallId values.
apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts (2)

91-94: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Sequential tool execution could be parallelized.

Multiple tool calls in a single LLM turn are awaited one at a time. Since executeTool already scopes each call to userId independently, running them concurrently with Promise.all would reduce latency without changing behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts` around lines 91 -
94, Update the tool-call handling around executeTool to run all result.toolCalls
concurrently with Promise.all, passing input.userId to each call, then append
each corresponding tool result to messages with its original call.id. Preserve
the existing result ordering and message structure.

78-83: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

LLM provider call isn't wrapped for graceful degradation.

executeTool catches errors and feeds them back to the model, but completeWithTools (the outer LLM call) is unguarded. A network failure or non-OK provider response bubbles up as a generic uncoded Error, which fromCodedError will map to a plain "Internal server error" rather than the existing ERROR_CODES.SERVICE_UNAVAILABLE, losing useful signal for the client/UI.

Consider catching provider-level errors here and re-throwing with ERROR_CODES.SERVICE_UNAVAILABLE for a clearer, more actionable error surfaced to the user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts` around lines 78 -
83, Wrap the completeWithTools call inside the ChatWithAssistantUseCase
iteration loop with error handling, and rethrow provider-level failures using
the existing ERROR_CODES.SERVICE_UNAVAILABLE coded error. Preserve the current
tool-call processing and response fallback behavior for successful completions,
while ensuring network or non-OK provider errors retain the service-unavailable
signal.
apps/api/src/http/schema/types/inputs/ChatInputs.ts (1)

3-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a GraphQL enum for role instead of a free-form string.

role accepts any string here; the actual user/assistant restriction is enforced later in ChatWithAssistantUseCase.execute and cast unsafely (m.role as 'user' | 'assistant') in chatMutations.ts. A Pothos enum type would make the accepted values self-documenting in the schema and let GraphQL reject invalid roles before they reach the resolver, removing the need for the cast.

+export const ChatMessageRole = builder.enumType('ChatMessageRole', {
+  values: ['user', 'assistant'] as const,
+});
+
 export const ChatMessageInput = builder.inputType('ChatMessageInput', {
   fields: (t) => ({
-    role: t.string({ required: true }),
+    role: t.field({ type: ChatMessageRole, required: true }),
     content: t.string({ required: true }),
   }),
 });

This would also need a corresponding client-side type/enum update in the web app, since the frontend already sends this input.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/http/schema/types/inputs/ChatInputs.ts` around lines 3 - 8,
Replace the free-form role field in ChatMessageInput with a Pothos GraphQL enum
restricted to user and assistant, and update chatMutations.ts to consume the
typed enum without an unsafe cast. Add the corresponding client-side enum/type
update in the web app so its ChatMessageInput values match the schema.
apps/api/src/infrastructure/llm/GoogleAILLMProvider.ts (1)

42-95: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Tool-call round trip logic and response parsing look sound.

systemInstruction extraction, the idToName correlation map, and the functionCall/text parsing all correctly implement the LLMCompletionResult contract (content/toolCalls). Minor optional note: newer Gemini API responses can carry an id on functionCall/functionResponse for correlating parallel calls of the same tool name — worth keeping in mind if/when the live-key smoke test surfaces issues with multiple simultaneous same-name tool calls, but not blocking given the current single-tool-per-turn usage pattern in ChatWithAssistantUseCase.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/llm/GoogleAILLMProvider.ts` around lines 42 - 95,
The reviewed implementation is correct; no code changes are required. Preserve
the existing completeWithTools parsing and single-tool-per-turn behavior, while
treating function-call IDs as a future enhancement only if parallel same-name
tool calls become necessary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts`:
- Around line 112-129: Update the AnthropicLLMProvider.post method to apply a
finite timeout to the outbound fetch request, using the project’s existing
timeout configuration or convention if available. Ensure the timeout signal is
passed in the fetch options and that timeout failures propagate as errors
instead of allowing the request to hang indefinitely.

In `@apps/api/src/infrastructure/llm/OpenAICompatibleLLMProvider.ts`:
- Around line 110-126: Update OpenAICompatibleLLMProvider.post to bound the
outbound fetch with an AbortController timeout, matching the timeout behavior
used by AnthropicLLMProvider.post. Pass the controller’s signal to fetch and
ensure the timeout is cleaned up after completion while preserving existing
response and error handling.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts`:
- Around line 57-76: In ChatWithAssistantUseCase, add validation before
llmProvider creation and message construction to enforce configured maximum
lengths for input.message and input.history, including a cap on each history
entry’s content if the existing validation model supports it. Reject oversized
inputs with ERROR_CODES.VALIDATION, while preserving the existing role
validation and normal flow for inputs within limits.

In `@apps/web/src/routes/_authenticated/assistant.tsx`:
- Around line 37-41: Update the mutationFn request in the assistant route to
send only a capped recent window of complete conversation turns rather than all
messages, preserving chronological order and avoiding partial turns. Apply the
same history limit in the server-side sendChatMessage handling so clients cannot
bypass the bound.
- Line 67: Update the assistant page container height in the main layout div to
reserve space for both the mobile header and the fixed bottom navigation, using
the existing responsive desktop height unchanged. Ensure the composer remains
visible above the mobile navigation while preserving the current desktop layout.

---

Nitpick comments:
In `@apps/api/src/http/schema/types/inputs/ChatInputs.ts`:
- Around line 3-8: Replace the free-form role field in ChatMessageInput with a
Pothos GraphQL enum restricted to user and assistant, and update
chatMutations.ts to consume the typed enum without an unsafe cast. Add the
corresponding client-side enum/type update in the web app so its
ChatMessageInput values match the schema.

In `@apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts`:
- Around line 33-42: Update AnthropicLLMProvider.complete() to convert
conversation messages through the existing toWireMessages helper instead of
mapping m.role and m.content directly. Preserve the current system-message
handling and request structure while ensuring tool roles become tool_result
blocks and assistant tool calls become tool_use blocks, consistent with
completeWithTools() and OpenAICompatibleLLMProvider.complete().

In `@apps/api/src/infrastructure/llm/GoogleAILLMProvider.ts`:
- Around line 42-95: The reviewed implementation is correct; no code changes are
required. Preserve the existing completeWithTools parsing and
single-tool-per-turn behavior, while treating function-call IDs as a future
enhancement only if parallel same-name tool calls become necessary.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts`:
- Around line 91-94: Update the tool-call handling around executeTool to run all
result.toolCalls concurrently with Promise.all, passing input.userId to each
call, then append each corresponding tool result to messages with its original
call.id. Preserve the existing result ordering and message structure.
- Around line 78-83: Wrap the completeWithTools call inside the
ChatWithAssistantUseCase iteration loop with error handling, and rethrow
provider-level failures using the existing ERROR_CODES.SERVICE_UNAVAILABLE coded
error. Preserve the current tool-call processing and response fallback behavior
for successful completions, while ensuring network or non-OK provider errors
retain the service-unavailable signal.

In `@apps/api/src/use-cases/ports/ILLMProvider.ts`:
- Around line 7-14: Replace the independent-field LLMMessage interface with a
discriminated union: system/user messages should only contain content, assistant
messages may optionally contain toolCalls, and tool messages must require
toolCallId. Update dependent construction and handling in
AnthropicLLMProvider.toWireMessages and
OpenAICompatibleLLMProvider.toWireMessages as needed so they rely on the
enforced invariant instead of silently defaulting missing toolCallId values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fc97fbf4-b58b-4e9b-84e2-1371039525ac

📥 Commits

Reviewing files that changed from the base of the PR and between 72e10bf and 9564b03.

📒 Files selected for processing (18)
  • apps/api/src/__tests__/application/chat/ChatWithAssistantUseCase.test.ts
  • apps/api/src/__tests__/helpers/mocks.ts
  • apps/api/src/__tests__/infrastructure/llm/AnthropicLLMProvider.test.ts
  • apps/api/src/__tests__/infrastructure/llm/GoogleAILLMProvider.test.ts
  • apps/api/src/__tests__/infrastructure/llm/OpenAICompatibleLLMProvider.test.ts
  • apps/api/src/constants.ts
  • apps/api/src/http/container.ts
  • apps/api/src/http/schema/index.ts
  • apps/api/src/http/schema/mutations/chatMutations.ts
  • apps/api/src/http/schema/types/inputs/ChatInputs.ts
  • apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts
  • apps/api/src/infrastructure/llm/GoogleAILLMProvider.ts
  • apps/api/src/infrastructure/llm/OpenAICompatibleLLMProvider.ts
  • apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts
  • apps/api/src/use-cases/ports/ILLMProvider.ts
  • apps/web/src/routeTree.gen.ts
  • apps/web/src/routes/_authenticated/assistant.tsx
  • apps/web/src/routes/_authenticated/route.tsx

Comment on lines +112 to 129
private async post(body: Record<string, unknown>): Promise<AnthropicWireResponse> {
const response = await fetch(LLM.ANTHROPIC_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': LLM.ANTHROPIC_VERSION,
},
body: JSON.stringify({
model: this.model,
max_tokens: maxTokens,
...(system ? { system } : {}),
messages: conversation,
}),
body: JSON.stringify(body),
});

if (!response.ok) {
const body = await response.text();
throw new Error(`Anthropic error ${response.status}: ${body}`);
const text = await response.text();
throw new Error(`Anthropic error ${response.status}: ${text}`);
}

const json = (await response.json()) as {
content?: Array<{ type: string; text?: string }>;
};

return json.content?.find((block) => block.type === 'text')?.text ?? '';
return response.json() as Promise<AnthropicWireResponse>;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No timeout on the outbound Anthropic fetch call.

If the Anthropic API hangs, this fetch will wait indefinitely, tying up the request for the chat mutation with no bound. Given this now backs an interactive, rate-limited user-facing chat feature (ChatWithAssistantUseCase), an unresponsive upstream can leave requests hanging and degrade the whole feature.

🛡️ Suggested fix
     const response = await fetch(LLM.ANTHROPIC_API_URL, {
       method: 'POST',
       headers: {
         'Content-Type': 'application/json',
         'x-api-key': this.apiKey,
         'anthropic-version': LLM.ANTHROPIC_VERSION,
       },
       body: JSON.stringify(body),
+      signal: AbortSignal.timeout(30_000),
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async post(body: Record<string, unknown>): Promise<AnthropicWireResponse> {
const response = await fetch(LLM.ANTHROPIC_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': LLM.ANTHROPIC_VERSION,
},
body: JSON.stringify({
model: this.model,
max_tokens: maxTokens,
...(system ? { system } : {}),
messages: conversation,
}),
body: JSON.stringify(body),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Anthropic error ${response.status}: ${body}`);
const text = await response.text();
throw new Error(`Anthropic error ${response.status}: ${text}`);
}
const json = (await response.json()) as {
content?: Array<{ type: string; text?: string }>;
};
return json.content?.find((block) => block.type === 'text')?.text ?? '';
return response.json() as Promise<AnthropicWireResponse>;
}
private async post(body: Record<string, unknown>): Promise<AnthropicWireResponse> {
const response = await fetch(LLM.ANTHROPIC_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': LLM.ANTHROPIC_VERSION,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Anthropic error ${response.status}: ${text}`);
}
return response.json() as Promise<AnthropicWireResponse>;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/llm/AnthropicLLMProvider.ts` around lines 112 -
129, Update the AnthropicLLMProvider.post method to apply a finite timeout to
the outbound fetch request, using the project’s existing timeout configuration
or convention if available. Ensure the timeout signal is passed in the fetch
options and that timeout failures propagate as errors instead of allowing the
request to hang indefinitely.

Comment on lines +110 to 126
private async post(body: Record<string, unknown>): Promise<OpenAIWireResponse> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `${AUTH_HEADER.BEARER_PREFIX}${this.apiKey}`,
},
body: JSON.stringify({ model: this.model, messages, max_tokens: maxTokens }),
body: JSON.stringify(body),
});

if (!response.ok) {
const body = await response.text();
throw new Error(`LLM provider error ${response.status}: ${body}`);
const text = await response.text();
throw new Error(`LLM provider error ${response.status}: ${text}`);
}

const json = (await response.json()) as {
choices: Array<{ message: { content: string } }>;
};

return json.choices[0]?.message?.content ?? '';
return response.json() as Promise<OpenAIWireResponse>;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No timeout on the outbound provider fetch call.

Same concern as AnthropicLLMProvider.post: a hung upstream response will block this request indefinitely with no bound, which is risky for an interactive chat feature.

🛡️ Suggested fix
     const response = await fetch(this.baseUrl, {
       method: 'POST',
       headers: {
         'Content-Type': 'application/json',
         Authorization: `${AUTH_HEADER.BEARER_PREFIX}${this.apiKey}`,
       },
       body: JSON.stringify(body),
+      signal: AbortSignal.timeout(30_000),
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async post(body: Record<string, unknown>): Promise<OpenAIWireResponse> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `${AUTH_HEADER.BEARER_PREFIX}${this.apiKey}`,
},
body: JSON.stringify({ model: this.model, messages, max_tokens: maxTokens }),
body: JSON.stringify(body),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM provider error ${response.status}: ${body}`);
const text = await response.text();
throw new Error(`LLM provider error ${response.status}: ${text}`);
}
const json = (await response.json()) as {
choices: Array<{ message: { content: string } }>;
};
return json.choices[0]?.message?.content ?? '';
return response.json() as Promise<OpenAIWireResponse>;
}
private async post(body: Record<string, unknown>): Promise<OpenAIWireResponse> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `${AUTH_HEADER.BEARER_PREFIX}${this.apiKey}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`LLM provider error ${response.status}: ${text}`);
}
return response.json() as Promise<OpenAIWireResponse>;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/llm/OpenAICompatibleLLMProvider.ts` around lines
110 - 126, Update OpenAICompatibleLLMProvider.post to bound the outbound fetch
with an AbortController timeout, matching the timeout behavior used by
AnthropicLLMProvider.post. Pass the controller’s signal to fetch and ensure the
timeout is cleaned up after completion while preserving existing response and
error handling.

Comment on lines +57 to +76
// Only 'user'/'assistant' are accepted from the client — otherwise a
// crafted `role: "system"` entry could inject a fake system message.
if (input.history.some((m) => m.role !== 'user' && m.role !== 'assistant')) {
throw Object.assign(new Error('Invalid message role in conversation history'), {
code: ERROR_CODES.VALIDATION,
});
}

const llmProvider = await this.deps.llmProviderFactory.forUser(input.userId);
if (!llmProvider) {
throw Object.assign(new Error('Add your AI API key in Settings to use this feature'), {
code: ERROR_CODES.AI_NOT_CONFIGURED,
});
}

const messages: LLMMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...input.history.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user', content: input.message },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

No size/length limit on message or history before calling the LLM.

The only guard against abuse is the 20-messages-per-5-minutes rate limiter; there's no cap on input.message length or input.history size. A user (or malicious client) can stay under the rate limit while sending very large messages/histories repeatedly, each triggering up to CHAT.MAX_TOOL_ITERATIONS paid LLM calls — this is an unbounded cost/latency exposure and can also blow past provider context-window limits, causing the whole turn to fail with an unhandled error.

🛡️ Suggested guard
+    const MAX_MESSAGE_LENGTH = 4000;
+    const MAX_HISTORY_LENGTH = 50;
+    if (input.message.length > MAX_MESSAGE_LENGTH || input.history.length > MAX_HISTORY_LENGTH) {
+      throw Object.assign(new Error('Message or conversation history is too long'), {
+        code: ERROR_CODES.VALIDATION,
+      });
+    }
     // Only 'user'/'assistant' are accepted from the client — otherwise a
     // crafted `role: "system"` entry could inject a fake system message.
     if (input.history.some((m) => m.role !== 'user' && m.role !== 'assistant')) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Only 'user'/'assistant' are accepted from the client — otherwise a
// crafted `role: "system"` entry could inject a fake system message.
if (input.history.some((m) => m.role !== 'user' && m.role !== 'assistant')) {
throw Object.assign(new Error('Invalid message role in conversation history'), {
code: ERROR_CODES.VALIDATION,
});
}
const llmProvider = await this.deps.llmProviderFactory.forUser(input.userId);
if (!llmProvider) {
throw Object.assign(new Error('Add your AI API key in Settings to use this feature'), {
code: ERROR_CODES.AI_NOT_CONFIGURED,
});
}
const messages: LLMMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...input.history.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user', content: input.message },
];
const MAX_MESSAGE_LENGTH = 4000;
const MAX_HISTORY_LENGTH = 50;
if (input.message.length > MAX_MESSAGE_LENGTH || input.history.length > MAX_HISTORY_LENGTH) {
throw Object.assign(new Error('Message or conversation history is too long'), {
code: ERROR_CODES.VALIDATION,
});
}
// Only 'user'/'assistant' are accepted from the client — otherwise a
// crafted `role: "system"` entry could inject a fake system message.
if (input.history.some((m) => m.role !== 'user' && m.role !== 'assistant')) {
throw Object.assign(new Error('Invalid message role in conversation history'), {
code: ERROR_CODES.VALIDATION,
});
}
const llmProvider = await this.deps.llmProviderFactory.forUser(input.userId);
if (!llmProvider) {
throw Object.assign(new Error('Add your AI API key in Settings to use this feature'), {
code: ERROR_CODES.AI_NOT_CONFIGURED,
});
}
const messages: LLMMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...input.history.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user', content: input.message },
];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/chat/ChatWithAssistantUseCase.ts` around lines 57 -
76, In ChatWithAssistantUseCase, add validation before llmProvider creation and
message construction to enforce configured maximum lengths for input.message and
input.history, including a cap on each history entry’s content if the existing
validation model supports it. Reject oversized inputs with
ERROR_CODES.VALIDATION, while preserving the existing role validation and normal
flow for inputs within limits.

Comment on lines +37 to +41
mutationFn: (message: string) =>
gqlClient.request<{ sendChatMessage: string }>(SEND_CHAT_MESSAGE, {
history: messages.map((m) => ({ role: m.role, content: m.content })),
message,
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Bound the client-sent conversation history.

Line 39 serialises every prior message on every request. A long session will continually grow payloads and provider context/token work. Send a capped recent window of complete turns; enforce the same limit server-side.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/_authenticated/assistant.tsx` around lines 37 - 41,
Update the mutationFn request in the assistant route to send only a capped
recent window of complete conversation turns rather than all messages,
preserving chronological order and avoiding partial turns. Apply the same
history limit in the server-side sendChatMessage handling so clients cannot
bypass the bound.

};

return (
<div className="flex flex-col h-[calc(100vh-3.5rem)] lg:h-screen max-w-3xl mx-auto p-4 sm:p-8">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep the composer above the mobile navigation.

This height subtracts only the 3.5rem header, while apps/web/src/routes/_authenticated/route.tsx overlays a fixed 4rem bottom nav on mobile. The composer is therefore covered by that nav. Reserve both bars.

Proposed fix
-    <div className="flex flex-col h-[calc(100vh-3.5rem)] lg:h-screen max-w-3xl mx-auto p-4 sm:p-8">
+    <div className="flex flex-col h-[calc(100dvh-7.5rem)] lg:h-screen max-w-3xl mx-auto p-4 sm:p-8">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex flex-col h-[calc(100vh-3.5rem)] lg:h-screen max-w-3xl mx-auto p-4 sm:p-8">
<div className="flex flex-col h-[calc(100dvh-7.5rem)] lg:h-screen max-w-3xl mx-auto p-4 sm:p-8">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/routes/_authenticated/assistant.tsx` at line 67, Update the
assistant page container height in the main layout div to reserve space for both
the mobile header and the fixed bottom navigation, using the existing responsive
desktop height unchanged. Ensure the composer remains visible above the mobile
navigation while preserving the current desktop layout.

…(JEF-12)

Sending a message can involve multiple tool-call round-trips server-side
before an answer comes back, so a static "Thinking..." bubble can read as
stalled. Cycle through a few status phrases every 3s instead.
…at-assistant

# Conflicts:
#	apps/api/src/constants.ts
#	apps/api/src/http/container.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant