Files
mcpctl/src/mcpd/tests/chat-service.test.ts

1144 lines
46 KiB
TypeScript
Raw Normal View History

feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
import { describe, it, expect, vi } from 'vitest';
import { ChatService, MAX_ITERATIONS, TOOL_NAME_SEPARATOR, type ChatToolDispatcher } from '../src/services/chat.service.js';
import type { AgentService } from '../src/services/agent.service.js';
import type { LlmService } from '../src/services/llm.service.js';
import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js';
import type { LlmAdapter, NonStreamingResult, InferContext } from '../src/services/llm/types.js';
import type { IChatRepository } from '../src/repositories/chat.repository.js';
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
feat(mcpd): personality routes + chat system block overlay (Stage 3) End-to-end backend wiring for the agents-feature evolution. After this stage you can curl all the endpoints; CLI + Web UI follow. Routes (new): GET /api/v1/agents/:agentName/personalities POST /api/v1/agents/:agentName/personalities GET /api/v1/personalities/:id PUT /api/v1/personalities/:id DELETE /api/v1/personalities/:id GET /api/v1/personalities/:id/prompts POST /api/v1/personalities/:id/prompts DELETE /api/v1/personalities/:id/prompts/:promptId GET /api/v1/agents/:agentName/prompts (agent-direct) Routes (extended): POST /api/v1/prompts now resolves `agent: <name>` like `project: <name>` POST /api/v1/agents/:name/chat accepts `personality: <name>` RBAC: `personalities` segment maps to the `agents` resource so view/edit/create/delete on the parent agent governs personality access. No new RBAC roles — piggybacking keeps the surface flat. System block (chat.service.ts): agent.systemPrompt + agent-direct prompts (Prompt.agentId === agent.id, priority desc) + project prompts (existing behavior, priority desc) + personality prompts (PersonalityPrompt[chosen], priority desc) + systemAppend Personality is selected by request body `personality: <name>`, falling back to `agent.defaultPersonalityId` if unset. A typo'd flag throws 404 rather than silently dropping back to no overlay — failing loudly on misconfiguration is the only way users learn it didn't apply. Backwards-compatible by construction: when no agent-direct prompts exist and no personality is selected, the resulting block is byte- identical to the old layout (verified by a regression test). Tests: 5 new chat-service.test cases cover ordering, default- personality fallback, missing-personality 404, and the regression guard. mcpd suite: 801/801 (was 796). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:27:59 +01:00
import type { IPersonalityRepository } from '../src/repositories/personality.repository.js';
import type { ChatMessage, ChatThread, Prompt, Personality, PersonalityPrompt } from '@prisma/client';
feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
const NOW = new Date();
function mockChatRepo(): IChatRepository & { _msgs: ChatMessage[]; _threads: ChatThread[] } {
const msgs: ChatMessage[] = [];
const threads: ChatThread[] = [];
let idCounter = 1;
return {
_msgs: msgs,
_threads: threads,
createThread: vi.fn(async ({ agentId, ownerId, title }) => {
const t: ChatThread = {
id: `thread-${String(idCounter++)}`,
agentId,
ownerId,
title: title ?? '',
lastTurnAt: NOW,
createdAt: NOW,
updatedAt: NOW,
};
threads.push(t);
return t;
}),
findThread: vi.fn(async (id: string) => threads.find((t) => t.id === id) ?? null),
listThreadsByAgent: vi.fn(async (agentId: string) => threads.filter((t) => t.agentId === agentId)),
listMessages: vi.fn(async (threadId: string) =>
msgs.filter((m) => m.threadId === threadId).sort((a, b) => a.turnIndex - b.turnIndex)),
appendMessage: vi.fn(async (input) => {
const turnIndex = input.turnIndex ?? msgs.filter((m) => m.threadId === input.threadId).length;
const m: ChatMessage = {
id: `msg-${String(idCounter++)}`,
threadId: input.threadId,
turnIndex,
role: input.role,
content: input.content,
toolCalls: (input.toolCalls ?? null) as ChatMessage['toolCalls'],
toolCallId: input.toolCallId ?? null,
status: input.status ?? 'complete',
createdAt: NOW,
};
msgs.push(m);
return m;
}),
updateStatus: vi.fn(async (id: string, status) => {
const m = msgs.find((x) => x.id === id);
if (!m) throw new Error('not found');
m.status = status;
return m;
}),
markPendingAsError: vi.fn(async (threadId: string) => {
let n = 0;
for (const m of msgs) {
if (m.threadId === threadId && m.status === 'pending') {
m.status = 'error';
n += 1;
}
}
return n;
}),
touchThread: vi.fn(async () => undefined),
nextTurnIndex: vi.fn(async (threadId: string) =>
msgs.filter((m) => m.threadId === threadId).length),
};
}
function mockPromptRepo(rows: Prompt[] = []): IPromptRepository {
return {
findAll: vi.fn(async () => rows),
feat(mcpd): personality routes + chat system block overlay (Stage 3) End-to-end backend wiring for the agents-feature evolution. After this stage you can curl all the endpoints; CLI + Web UI follow. Routes (new): GET /api/v1/agents/:agentName/personalities POST /api/v1/agents/:agentName/personalities GET /api/v1/personalities/:id PUT /api/v1/personalities/:id DELETE /api/v1/personalities/:id GET /api/v1/personalities/:id/prompts POST /api/v1/personalities/:id/prompts DELETE /api/v1/personalities/:id/prompts/:promptId GET /api/v1/agents/:agentName/prompts (agent-direct) Routes (extended): POST /api/v1/prompts now resolves `agent: <name>` like `project: <name>` POST /api/v1/agents/:name/chat accepts `personality: <name>` RBAC: `personalities` segment maps to the `agents` resource so view/edit/create/delete on the parent agent governs personality access. No new RBAC roles — piggybacking keeps the surface flat. System block (chat.service.ts): agent.systemPrompt + agent-direct prompts (Prompt.agentId === agent.id, priority desc) + project prompts (existing behavior, priority desc) + personality prompts (PersonalityPrompt[chosen], priority desc) + systemAppend Personality is selected by request body `personality: <name>`, falling back to `agent.defaultPersonalityId` if unset. A typo'd flag throws 404 rather than silently dropping back to no overlay — failing loudly on misconfiguration is the only way users learn it didn't apply. Backwards-compatible by construction: when no agent-direct prompts exist and no personality is selected, the resulting block is byte- identical to the old layout (verified by a regression test). Tests: 5 new chat-service.test cases cover ordering, default- personality fallback, missing-personality 404, and the regression guard. mcpd suite: 801/801 (was 796). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:27:59 +01:00
findGlobal: vi.fn(async () => rows.filter((p) => p.projectId === null && p.agentId === null)),
findByAgent: vi.fn(async (agentId: string) => rows.filter((p) => p.agentId === agentId)),
feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
findById: vi.fn(async (id: string) => rows.find((p) => p.id === id) ?? null),
findByNameAndProject: vi.fn(async () => null),
feat(mcpd): personality routes + chat system block overlay (Stage 3) End-to-end backend wiring for the agents-feature evolution. After this stage you can curl all the endpoints; CLI + Web UI follow. Routes (new): GET /api/v1/agents/:agentName/personalities POST /api/v1/agents/:agentName/personalities GET /api/v1/personalities/:id PUT /api/v1/personalities/:id DELETE /api/v1/personalities/:id GET /api/v1/personalities/:id/prompts POST /api/v1/personalities/:id/prompts DELETE /api/v1/personalities/:id/prompts/:promptId GET /api/v1/agents/:agentName/prompts (agent-direct) Routes (extended): POST /api/v1/prompts now resolves `agent: <name>` like `project: <name>` POST /api/v1/agents/:name/chat accepts `personality: <name>` RBAC: `personalities` segment maps to the `agents` resource so view/edit/create/delete on the parent agent governs personality access. No new RBAC roles — piggybacking keeps the surface flat. System block (chat.service.ts): agent.systemPrompt + agent-direct prompts (Prompt.agentId === agent.id, priority desc) + project prompts (existing behavior, priority desc) + personality prompts (PersonalityPrompt[chosen], priority desc) + systemAppend Personality is selected by request body `personality: <name>`, falling back to `agent.defaultPersonalityId` if unset. A typo'd flag throws 404 rather than silently dropping back to no overlay — failing loudly on misconfiguration is the only way users learn it didn't apply. Backwards-compatible by construction: when no agent-direct prompts exist and no personality is selected, the resulting block is byte- identical to the old layout (verified by a regression test). Tests: 5 new chat-service.test cases cover ordering, default- personality fallback, missing-personality 404, and the regression guard. mcpd suite: 801/801 (was 796). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:27:59 +01:00
findByNameAndAgent: vi.fn(async () => null),
feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as unknown as IPromptRepository;
}
function mockTools(impl: Partial<ChatToolDispatcher> = {}): ChatToolDispatcher {
return {
listTools: impl.listTools ?? vi.fn(async () => []),
callTool: impl.callTool ?? vi.fn(async () => ({ ok: true })),
};
}
feat(mcpd): personality routes + chat system block overlay (Stage 3) End-to-end backend wiring for the agents-feature evolution. After this stage you can curl all the endpoints; CLI + Web UI follow. Routes (new): GET /api/v1/agents/:agentName/personalities POST /api/v1/agents/:agentName/personalities GET /api/v1/personalities/:id PUT /api/v1/personalities/:id DELETE /api/v1/personalities/:id GET /api/v1/personalities/:id/prompts POST /api/v1/personalities/:id/prompts DELETE /api/v1/personalities/:id/prompts/:promptId GET /api/v1/agents/:agentName/prompts (agent-direct) Routes (extended): POST /api/v1/prompts now resolves `agent: <name>` like `project: <name>` POST /api/v1/agents/:name/chat accepts `personality: <name>` RBAC: `personalities` segment maps to the `agents` resource so view/edit/create/delete on the parent agent governs personality access. No new RBAC roles — piggybacking keeps the surface flat. System block (chat.service.ts): agent.systemPrompt + agent-direct prompts (Prompt.agentId === agent.id, priority desc) + project prompts (existing behavior, priority desc) + personality prompts (PersonalityPrompt[chosen], priority desc) + systemAppend Personality is selected by request body `personality: <name>`, falling back to `agent.defaultPersonalityId` if unset. A typo'd flag throws 404 rather than silently dropping back to no overlay — failing loudly on misconfiguration is the only way users learn it didn't apply. Backwards-compatible by construction: when no agent-direct prompts exist and no personality is selected, the resulting block is byte- identical to the old layout (verified by a regression test). Tests: 5 new chat-service.test cases cover ordering, default- personality fallback, missing-personality 404, and the regression guard. mcpd suite: 801/801 (was 796). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:27:59 +01:00
function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService {
feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
return {
getByName: vi.fn(async (name: string) => ({
id: `agent-${name}`,
name,
description: 'desc',
systemPrompt: 'You are a helpful agent.',
llm: { id: 'llm-1', name: 'qwen3-thinking' },
project: name === 'no-project'
? null
: { id: 'proj-1', name: 'mcpctl-dev' },
feat(mcpd): personality routes + chat system block overlay (Stage 3) End-to-end backend wiring for the agents-feature evolution. After this stage you can curl all the endpoints; CLI + Web UI follow. Routes (new): GET /api/v1/agents/:agentName/personalities POST /api/v1/agents/:agentName/personalities GET /api/v1/personalities/:id PUT /api/v1/personalities/:id DELETE /api/v1/personalities/:id GET /api/v1/personalities/:id/prompts POST /api/v1/personalities/:id/prompts DELETE /api/v1/personalities/:id/prompts/:promptId GET /api/v1/agents/:agentName/prompts (agent-direct) Routes (extended): POST /api/v1/prompts now resolves `agent: <name>` like `project: <name>` POST /api/v1/agents/:name/chat accepts `personality: <name>` RBAC: `personalities` segment maps to the `agents` resource so view/edit/create/delete on the parent agent governs personality access. No new RBAC roles — piggybacking keeps the surface flat. System block (chat.service.ts): agent.systemPrompt + agent-direct prompts (Prompt.agentId === agent.id, priority desc) + project prompts (existing behavior, priority desc) + personality prompts (PersonalityPrompt[chosen], priority desc) + systemAppend Personality is selected by request body `personality: <name>`, falling back to `agent.defaultPersonalityId` if unset. A typo'd flag throws 404 rather than silently dropping back to no overlay — failing loudly on misconfiguration is the only way users learn it didn't apply. Backwards-compatible by construction: when no agent-direct prompts exist and no personality is selected, the resulting block is byte- identical to the old layout (verified by a regression test). Tests: 5 new chat-service.test cases cover ordering, default- personality fallback, missing-personality 404, and the regression guard. mcpd suite: 801/801 (was 796). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:27:59 +01:00
defaultPersonality: opts.defaultPersonality ?? null,
feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
proxyModelName: null,
defaultParams: { temperature: 0.5 },
extras: {},
ownerId: 'owner-1',
version: 1,
createdAt: NOW,
updatedAt: NOW,
})),
} as unknown as AgentService;
}
feat(db+mcpd): Agent lifecycle + chat.service kind=virtual branch (v3 Stage 1) Two pieces of v3 plumbing — schema + the latent v1 chat.service bug. Schema (db): - Agent gains kind/providerSessionId/lastHeartbeatAt/status/inactiveSince mirroring Llm's v1 lifecycle. Reuses LlmKind / LlmStatus enums; no new types. Existing rows backfill kind=public/status=active so v1 CRUD is unaffected. - @@index([kind, status]) for the GC sweep, @@index([providerSessionId]) for disconnect-cascade lookups. - 4 new prisma-level tests cover defaults, persisting virtual fields, the (kind, status) GC index, and providerSessionId lookups. Total agent-schema tests: 20/20. chat.service (mcpd) — fixes the v1 latent bug: - LlmView's kind is now plumbed through prepareContext as ctx.llmKind. - Two new private helpers, runOneInference / streamInference, branch on ctx.llmKind: 'public' goes through the existing adapter registry, 'virtual' relays through VirtualLlmService.enqueueInferTask (mirrors the route-handler branch from v1 Stage 3). - Streaming bridges VirtualLlmService's onChunk callback API to an async iterator via a small queue + wake pattern. - ChatService gains an optional virtualLlms constructor parameter; main.ts wires it in. Older test wirings without it raise a clear "virtualLlms dispatcher not wired" error when the row is virtual, rather than silently falling through to the public path against an empty URL. This unblocks any Agent (public OR future v3-virtual) pinned to a kind=virtual Llm. Pre-this-stage, those agents 502'd against the empty url field. Tests: 4 new chat-service-virtual-llm.test.ts cover the relay path non-streaming, streaming, missing-dispatcher error, and rejection surfacing. mcpd suite: 841/841 (was 833, +8 across stages 1+v3-Stage-1). Workspace: 2054/2054 across 153 files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:55:02 +01:00
function mockLlms(opts: { kind?: 'public' | 'virtual' } = {}): LlmService {
feat(mcpd+db): Llm.poolName + chat dispatcher pool failover (v4 Stage 1) Adds LB-pool-by-shared-name without introducing a new resource. The existing `Llm.name` stays globally unique; a new optional `poolName` column declares membership in a pool. Multiple Llms sharing a non-null `poolName` stack into one load-balanced pool that the chat dispatcher expands at request time. Effective pool key = `poolName ?? name`. Solo rows (poolName=null) are addressable as a "pool of 1" via their own name, so existing single-Llm agents and YAMLs keep working unchanged. A solo row whose name happens to match an explicit poolName joins the same pool — by design — so an operator can transparently promote an existing Llm to pool seed. Dispatcher (chat.service): prepareContext now resolves a randomly- shuffled list of viable pool candidates (status != inactive) once per turn. runOneInference and streamInference iterate the list on transport-level failure (network, virtual publisher disconnect) until one succeeds or the list is exhausted. Streaming failover only covers "failed before first chunk" — once we've yielded text, we're committed to that backend. Auth/4xx errors surfaced as result.status are NOT retried; siblings with the same key/model would fail identically. When the agent's pinned Llm is itself inactive but a sibling pool member is up, dispatch transparently uses the sibling — that's the whole point. When every member is inactive, prepareContext throws a clear "No active Llm in pool '<key>' (pinned: <name>)" error rather than letting the dispatcher's "exhausted" branch surface it. Tests: - 5 new chat-service tests for pool dispatch / failover / pinned-down / all-inactive (chat-service.test.ts). - 7 new db schema tests for the column, the unique-name invariant, the fallback-to-name semantics, and the solo-name-joins-explicit-pool edge case (llm-pool-schema.test.ts). - mcpd 865/865 (was 860; +5), db pool-schema 7/7, no regressions. Stage 2 (next): HTTP route /api/v1/llms/<name>/members + aggregate pool stats on the existing single-Llm route, CLI POOL column + describe block + --pool-name flag, yaml round-trip.
2026-04-27 22:02:41 +01:00
// v4: prepareContext now resolves a pool by effective key. For unit
// tests that pass a single agent.llm we return that one row twice —
// once for getByName (LlmView shape) and once for findByPoolName (raw
// Llm shape with the same name) so the dispatcher's poolCandidates
// ends up with exactly one member, matching pre-v4 behavior.
const baseRow = (name: string): Record<string, unknown> => ({
id: 'llm-1', name, type: 'openai', model: 'qwen3-thinking',
url: '', tier: 'fast', description: '',
apiKeySecretId: null, apiKeySecretKey: null,
extraConfig: {},
poolName: null,
kind: opts.kind ?? 'public',
providerSessionId: null,
status: 'active',
lastHeartbeatAt: null,
inactiveSince: null,
version: 1, createdAt: NOW, updatedAt: NOW,
});
feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
return {
getByName: vi.fn(async (name: string) => ({
feat(mcpd+db): Llm.poolName + chat dispatcher pool failover (v4 Stage 1) Adds LB-pool-by-shared-name without introducing a new resource. The existing `Llm.name` stays globally unique; a new optional `poolName` column declares membership in a pool. Multiple Llms sharing a non-null `poolName` stack into one load-balanced pool that the chat dispatcher expands at request time. Effective pool key = `poolName ?? name`. Solo rows (poolName=null) are addressable as a "pool of 1" via their own name, so existing single-Llm agents and YAMLs keep working unchanged. A solo row whose name happens to match an explicit poolName joins the same pool — by design — so an operator can transparently promote an existing Llm to pool seed. Dispatcher (chat.service): prepareContext now resolves a randomly- shuffled list of viable pool candidates (status != inactive) once per turn. runOneInference and streamInference iterate the list on transport-level failure (network, virtual publisher disconnect) until one succeeds or the list is exhausted. Streaming failover only covers "failed before first chunk" — once we've yielded text, we're committed to that backend. Auth/4xx errors surfaced as result.status are NOT retried; siblings with the same key/model would fail identically. When the agent's pinned Llm is itself inactive but a sibling pool member is up, dispatch transparently uses the sibling — that's the whole point. When every member is inactive, prepareContext throws a clear "No active Llm in pool '<key>' (pinned: <name>)" error rather than letting the dispatcher's "exhausted" branch surface it. Tests: - 5 new chat-service tests for pool dispatch / failover / pinned-down / all-inactive (chat-service.test.ts). - 7 new db schema tests for the column, the unique-name invariant, the fallback-to-name semantics, and the solo-name-joins-explicit-pool edge case (llm-pool-schema.test.ts). - mcpd 865/865 (was 860; +5), db pool-schema 7/7, no regressions. Stage 2 (next): HTTP route /api/v1/llms/<name>/members + aggregate pool stats on the existing single-Llm route, CLI POOL column + describe block + --pool-name flag, yaml round-trip.
2026-04-27 22:02:41 +01:00
...baseRow(name),
apiKeyRef: null,
feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
})),
feat(mcpd+db): Llm.poolName + chat dispatcher pool failover (v4 Stage 1) Adds LB-pool-by-shared-name without introducing a new resource. The existing `Llm.name` stays globally unique; a new optional `poolName` column declares membership in a pool. Multiple Llms sharing a non-null `poolName` stack into one load-balanced pool that the chat dispatcher expands at request time. Effective pool key = `poolName ?? name`. Solo rows (poolName=null) are addressable as a "pool of 1" via their own name, so existing single-Llm agents and YAMLs keep working unchanged. A solo row whose name happens to match an explicit poolName joins the same pool — by design — so an operator can transparently promote an existing Llm to pool seed. Dispatcher (chat.service): prepareContext now resolves a randomly- shuffled list of viable pool candidates (status != inactive) once per turn. runOneInference and streamInference iterate the list on transport-level failure (network, virtual publisher disconnect) until one succeeds or the list is exhausted. Streaming failover only covers "failed before first chunk" — once we've yielded text, we're committed to that backend. Auth/4xx errors surfaced as result.status are NOT retried; siblings with the same key/model would fail identically. When the agent's pinned Llm is itself inactive but a sibling pool member is up, dispatch transparently uses the sibling — that's the whole point. When every member is inactive, prepareContext throws a clear "No active Llm in pool '<key>' (pinned: <name>)" error rather than letting the dispatcher's "exhausted" branch surface it. Tests: - 5 new chat-service tests for pool dispatch / failover / pinned-down / all-inactive (chat-service.test.ts). - 7 new db schema tests for the column, the unique-name invariant, the fallback-to-name semantics, and the solo-name-joins-explicit-pool edge case (llm-pool-schema.test.ts). - mcpd 865/865 (was 860; +5), db pool-schema 7/7, no regressions. Stage 2 (next): HTTP route /api/v1/llms/<name>/members + aggregate pool stats on the existing single-Llm route, CLI POOL column + describe block + --pool-name flag, yaml round-trip.
2026-04-27 22:02:41 +01:00
findByPoolName: vi.fn(async (poolName: string) => [baseRow(poolName)]),
feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
resolveApiKey: vi.fn(async () => 'fake-key'),
} as unknown as LlmService;
}
/** Adapter that yields a scripted sequence of canned responses, one per call. */
function scriptedAdapter(responses: NonStreamingResult[]): LlmAdapter {
let i = 0;
return {
kind: 'scripted',
infer: vi.fn(async (_ctx: InferContext) => {
const r = responses[i] ?? responses[responses.length - 1];
i += 1;
if (r === undefined) throw new Error('no scripted response');
return r;
}),
stream: async function*(_ctx: InferContext) {
yield { data: '[DONE]', done: true };
},
};
}
function adapterRegistry(adapter: LlmAdapter): LlmAdapterRegistry {
return { get: () => adapter } as unknown as LlmAdapterRegistry;
}
function chatCompletion(content: string): NonStreamingResult {
return {
status: 200,
body: {
id: 'cmpl-1',
object: 'chat.completion',
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
},
};
}
function toolCall(name: string, args: Record<string, unknown>): NonStreamingResult {
return {
status: 200,
body: {
id: 'cmpl-1',
object: 'chat.completion',
choices: [{
index: 0,
message: {
role: 'assistant',
content: '',
tool_calls: [{
id: `call-${name}`,
type: 'function',
function: { name, arguments: JSON.stringify(args) },
}],
},
finish_reason: 'tool_calls',
}],
},
};
}
describe('ChatService', () => {
it('plain text turn — persists user + assistant rows and returns the reply', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('hello back')]);
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const result = await svc.chat({
agentName: 'reviewer',
userMessage: 'hi',
ownerId: 'owner-1',
});
expect(result.assistant).toBe('hello back');
const stored = chatRepo._msgs.filter((m) => m.threadId === result.threadId);
expect(stored.map((m) => m.role)).toEqual(['user', 'assistant']);
expect(stored[1]?.status).toBe('complete');
});
it('runs a full tool-use round-trip and ends with a text reply', async () => {
const chatRepo = mockChatRepo();
const tools = mockTools({
listTools: vi.fn(async () => [{
name: `grafana${TOOL_NAME_SEPARATOR}query`,
description: 'query grafana',
parameters: { type: 'object', properties: {} },
}]),
callTool: vi.fn(async () => ({ rows: [{ value: 42 }] })),
});
const adapter = scriptedAdapter([
toolCall(`grafana${TOOL_NAME_SEPARATOR}query`, { q: 'cpu' }),
chatCompletion('the answer is 42'),
]);
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), tools,
);
const result = await svc.chat({
agentName: 'reviewer',
userMessage: 'what is cpu?',
ownerId: 'owner-1',
});
expect(result.assistant).toBe('the answer is 42');
expect(tools.callTool).toHaveBeenCalledWith({
projectId: 'proj-1',
serverName: 'grafana',
toolName: 'query',
args: { q: 'cpu' },
});
const stored = chatRepo._msgs.filter((m) => m.threadId === result.threadId);
expect(stored.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant']);
// No `pending` rows leaked.
expect(stored.every((m) => m.status === 'complete')).toBe(true);
// Tool turn's toolCallId links back.
const toolTurn = stored.find((m) => m.role === 'tool');
expect(toolTurn?.toolCallId).toBe(`call-grafana${TOOL_NAME_SEPARATOR}query`);
});
it('caps the loop at MAX_ITERATIONS when the model never settles', async () => {
const chatRepo = mockChatRepo();
const tools = mockTools({
listTools: vi.fn(async () => [{
name: `g${TOOL_NAME_SEPARATOR}t`,
description: '',
parameters: { type: 'object' },
}]),
callTool: vi.fn(async () => ({})),
});
// Always return a tool_call → the loop never reaches a terminal turn.
const adapter = scriptedAdapter([toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})]);
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), tools,
);
await expect(svc.chat({
agentName: 'reviewer',
userMessage: 'loop forever',
ownerId: 'owner-1',
})).rejects.toThrow(new RegExp(`exceeded ${String(MAX_ITERATIONS)}`));
// After failure, no row should remain `pending`.
expect(chatRepo._msgs.every((m) => m.status !== 'pending')).toBe(true);
});
it('flips pending rows to error when the adapter throws mid-loop', async () => {
const chatRepo = mockChatRepo();
const tools = mockTools({
listTools: vi.fn(async () => [{
name: `g${TOOL_NAME_SEPARATOR}t`, description: '', parameters: {},
}]),
callTool: vi.fn(async () => ({})),
});
const adapter: LlmAdapter = {
kind: 'fail-after-one',
infer: vi.fn()
.mockResolvedValueOnce(toolCall(`g${TOOL_NAME_SEPARATOR}t`, {}))
.mockRejectedValueOnce(new Error('upstream blew up')),
stream: async function*() { yield { data: '[DONE]', done: true }; },
};
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), tools,
);
await expect(svc.chat({
agentName: 'reviewer',
userMessage: 'go',
ownerId: 'owner-1',
})).rejects.toThrow('upstream blew up');
expect(chatRepo._msgs.some((m) => m.status === 'error')).toBe(false);
expect(chatRepo._msgs.every((m) => m.status !== 'pending')).toBe(true);
});
it('merges per-call params over agent.defaultParams (override wins)', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
await svc.chat({
agentName: 'reviewer',
userMessage: 'hi',
ownerId: 'owner-1',
params: { temperature: 0.9, max_tokens: 256 },
});
const ctx = inferSpy.mock.calls[0][0] as InferContext;
expect(ctx.body.temperature).toBe(0.9);
expect(ctx.body.max_tokens).toBe(256);
});
it('forwards `extra` keys into the body for provider-specific knobs', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
await svc.chat({
agentName: 'reviewer',
userMessage: 'hi',
ownerId: 'owner-1',
params: { extra: { metadata: { user_id: 'abc' }, repetition_penalty: 1.05 } },
});
const ctx = inferSpy.mock.calls[0][0] as InferContext;
expect((ctx.body as Record<string, unknown>)['repetition_penalty']).toBe(1.05);
expect((ctx.body as Record<string, unknown>)['metadata']).toEqual({ user_id: 'abc' });
});
it('builds a system block from agent.systemPrompt + project prompts (priority desc)', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const prompts: Prompt[] = [
{
id: 'p1', name: 'low', content: 'LOW prompt',
projectId: 'proj-1', priority: 1, summary: null, chapters: null,
linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW,
},
{
id: 'p2', name: 'high', content: 'HIGH prompt',
projectId: 'proj-1', priority: 9, summary: null, chapters: null,
linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW,
},
];
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(prompts), mockTools(),
);
await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
const ctx = inferSpy.mock.calls[0][0] as InferContext;
const sys = ctx.body.messages.find((m) => m.role === 'system');
expect(typeof sys?.content).toBe('string');
const text = sys?.content as string;
// High-priority prompt comes before low-priority.
expect(text.indexOf('HIGH prompt')).toBeLessThan(text.indexOf('LOW prompt'));
// Agent's own system prompt leads.
expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('HIGH prompt'));
});
it('refuses tool calls when the agent has no project attached', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([toolCall(`x${TOOL_NAME_SEPARATOR}y`, {})]);
const tools = mockTools({
listTools: vi.fn(async () => [{ name: `x${TOOL_NAME_SEPARATOR}y`, description: '', parameters: {} }]),
});
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), tools,
);
await expect(svc.chat({
agentName: 'no-project',
userMessage: 'go',
ownerId: 'owner-1',
})).rejects.toThrow(/Project/);
});
it('honours tools_allowlist (filters tools before sending to adapter)', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const tools = mockTools({
listTools: vi.fn(async () => [
{ name: `s1${TOOL_NAME_SEPARATOR}a`, description: '', parameters: {} },
{ name: `s1${TOOL_NAME_SEPARATOR}b`, description: '', parameters: {} },
]),
});
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), tools,
);
await svc.chat({
agentName: 'reviewer',
userMessage: 'hi',
ownerId: 'owner-1',
params: { tools_allowlist: [`s1${TOOL_NAME_SEPARATOR}a`] },
});
const ctx = inferSpy.mock.calls[0][0] as InferContext;
expect(ctx.body.tools).toHaveLength(1);
expect(ctx.body.tools?.[0]?.function.name).toBe(`s1${TOOL_NAME_SEPARATOR}a`);
});
fix(agents): close gaps from /gstack-review P1 — thread reads now enforce ownership ======================================== chat.service.ts / routes/agent-chat.ts GET /api/v1/threads/:id/messages was previously RBAC-mapped to view:agents (no resourceName scope) with the route comment promising "service-level owner check enforces fine-grained access" — but the service didn't actually check. Any caller with view:agents could read another user's thread by guessing/learning the threadId. CUIDs are hard to brute-force but they leak: SSE `final` chunks, agents-plugin `_meta.threadId`, and several response bodies surface them. Now ChatService.listMessages(threadId, ownerId) loads the thread, returns 404 (not 403, to avoid id-enumeration via differential status codes) if ownerId doesn't match. Regression test in chat-service.test.ts covers Alice/Bob isolation + nonexistent-thread same-shape 404. P2 — AgentChatRequestSchema strict mode ======================================== validation/agent.schema.ts `.merge()` does NOT inherit `.strict()` from AgentChatParamsSchema. Typo'd fields (e.g. `temprature`) silently fell through and the agent silently used the default — debuggable only by reading the LLM call payload. Re-applied `.strict()` on the merged schema. P2 — per-agent maxIterations override + clamp ============================================== chat.service.ts Loop cap was a hard-coded module constant (12), wrong for both research-style agents (need higher) and cheap-probe agents (could opt lower). Now reads `agent.extras.maxIterations`, clamps 1..50, falls back to 12 default. The clamp is the soft-DoS guard: a hostile agent definition with `maxIterations:1000000` can't burn unbounded LLM calls per request. Both chat() and chatStream() use ctx.maxIterations now. Regression test covers low-cap override (rejects with `exceeded 2`) and hostile-value clamp (rejects with `exceeded 50`). P3 — SSE write to closed socket ================================ routes/agent-chat.ts When the upstream adapter throws after some chunks were already written AND the client disconnected, the catch block tried to flush more chunks to a closed socket. Without an `on('error')` handler Node emits unhandled error events; once Pino is wired to alerts this'd page on every disconnect-mid-stream. writeSseChunk now checks `reply.raw.destroyed || writableEnded` before write. P3 — BACKEND_TOKEN_DEAD preserves original stack ================================================= services/secret-backend-rotator.service.ts When wrapping mintRoleToken/lookupSelf failures as BACKEND_TOKEN_DEAD, the new Error() discarded the original throw — hard to tell whether the inner failure was a network blip vs an OpenBao API mismatch vs DNS. Now uses `new Error(msg, { cause: err })` so the inner stack survives. P3 — .gitignore .claude/scheduled_tasks.lock ============================================= This persisted state file was leaking into every `git status`. Tests ===== mcpd 761/761 (+2 regression tests). mcplocal 715/715. cli 430/430. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:53:19 +01:00
feat(chat): surface reasoning_content as `thinking` chunks; fix --no-stream timeout Reasoning models (qwen3-thinking, deepseek-reasoner, OpenAI o1 family) emit their scratchpad as `delta.reasoning_content` (or `delta.reasoning`, or `delta.provider_specific_fields.reasoning_content` when LiteLLM passes through from vLLM) — separate from `delta.content`. Before this commit mcpd's parseStreamingChunk only watched `content`, so the model's 30-90s reasoning phase looked like dead air to the REPL: streaming connection open, no chunks, no progress. Caught during the agents-feature shakedown when qwen3-thinking sat silent for 90s on a docmost__list_pages call. mcpd ==== chat.service.ts - parseStreamingChunk extracts a `reasoningDelta` from the chunk body, accepting all four spellings (reasoning_content / reasoning / provider_specific_fields.{reasoning_content,reasoning}). Future providers can add their own field names by extending the fallback chain. - chatStream yields `{ type: 'thinking', delta }` chunks as reasoning arrives, alongside the existing `{ type: 'text', delta }` for content. - Reasoning is intentionally NOT persisted to the thread. It's the model's scratchpad, not part of the conversation. Subsequent turns don't see it. - Adds 'thinking' to the ChatStreamChunk.type union. CLI === chat.ts - streamOnce handles 'thinking' chunks: writes them dim+italic to stderr (ANSI 2;3m) so the model's reasoning visually flows like a quote block while the final answer streams to stdout. Plain text when stderr isn't a TTY (pipe to file → no escape codes leak). - chatRequestNonStream replaces the shared ApiClient.post() for the --no-stream path. ApiClient defaults to a 10s timeout, way too tight for any chat that calls a tool: LLM round + tool dispatch + LLM summary easily exceeds 10s. The new helper uses the same 600s timeout the streaming path has been using all along. Tests: chat-service.test.ts (+2): - reasoning_content deltas surface as `thinking` chunks (not text); reasoning is NOT persisted to the assistant turn's content. - LiteLLM's provider_specific_fields.reasoning_content shape parses identically to the vendor-native shape. mcpd 777/777, cli 430/430. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:04:01 +01:00
// Regression: reasoning_content (qwen3-thinking, deepseek-reasoner, o1)
// streams as `thinking` chunks, separate from `text`.
// Without this, the model's 30-90s reasoning phase looks like dead air to
// the REPL — caught by user feedback during the agents-feature shakedown.
it('chatStream surfaces reasoning_content deltas as `thinking` chunks', async () => {
const chatRepo = mockChatRepo();
// Adapter that yields a sequence of openai-format chunks: 2 reasoning
// deltas, then 1 content delta, then [DONE].
const adapter: LlmAdapter = {
kind: 'scripted-thinking',
infer: vi.fn(),
stream: async function*() {
yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'Let me think... ' }, finish_reason: null }] }) };
yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'OK, ready.' }, finish_reason: null }] }) };
yield { data: JSON.stringify({ choices: [{ delta: { content: 'DONE' }, finish_reason: 'stop' }] }) };
yield { data: '[DONE]', done: true };
},
};
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const chunks: Array<{ type: string; delta?: string }> = [];
for await (const chunk of svc.chatStream({
agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1',
})) {
chunks.push({ type: chunk.type, delta: chunk.delta });
}
// Expect: 2 thinking + 1 text + 1 final
expect(chunks.filter((c) => c.type === 'thinking').map((c) => c.delta))
.toEqual(['Let me think... ', 'OK, ready.']);
expect(chunks.filter((c) => c.type === 'text').map((c) => c.delta)).toEqual(['DONE']);
expect(chunks.find((c) => c.type === 'final')).toBeDefined();
// Reasoning is NOT persisted to the thread — only assistant content.
const assistantTurn = chatRepo._msgs.find((m) => m.role === 'assistant');
expect(assistantTurn?.content).toBe('DONE');
expect(assistantTurn?.content).not.toContain('Let me think');
});
fix(chat): real fixes for thinking-model + URL conventions, not test tweaks Five real bugs surfaced by the agent-chat smoke against live qwen3-thinking. None of these are fixed by changing the test — the test was right to fail. 1. openai-passthrough adapter doubled `/v1` in the request URL. The adapter hard-codes `/v1/chat/completions` after the configured base, but every OpenAI-compat provider documents its base URL with a trailing `/v1` (api.openai.com/v1, llm.example.com/v1, …). Users pasting that conventional shape produced `https://x/v1/v1/chat/completions` → 404. endpointUrl now strips a trailing `/v1` so both forms canonicalize. `/v1beta` (Anthropic-style) is preserved. 2. Non-streaming chat returned an empty assistant when thinking models (qwen3-thinking, deepseek-reasoner, OpenAI o1) emitted only `reasoning_content` with `content: null`. extractChoice now also pulls reasoning (every spelling the streaming parser already knows about), and a new pickAssistantText helper falls back to it when content is empty. A `[response truncated by max_tokens]` marker is appended when finish_reason is `length`, so users see the cut-off instead of guessing why the answer is short. Symmetric streaming fix: the chatStream loop accumulates reasoning and yields ONE synthesized `text` frame at the end when content stayed empty, keeping the CLI's stdout (which only prints `text` deltas) in sync with the persisted thread message. 3. `mcpctl get agent X -o yaml` emitted `kind: public` (the v3 lifecycle field) instead of `kind: agent` (apply envelope), so round-tripping through `apply -f` failed. Same fix shape as the v1 Llm strip in toApplyDocs — drop kind/status/lastHeartbeatAt/ inactiveSince/providerSessionId for the agents resource too. 4. Non-streaming `mcpctl chat` printed `thread:<cuid>` (no space) on stderr; streaming printed `(thread: <cuid>)` (with space). Tests and any other regex watching for one form missed the other. Standardize on `thread: <cuid>` (single space) in both paths. 5. agent-chat.smoke's `run()` used `execSync`, which discards stderr on success — making any `expect(stderr).toMatch(...)` assertion structurally impossible to satisfy in the happy path. Switch to `spawnSync` so stderr is actually captured. Includes a small shell-style argv splitter so the existing call sites with quoted multi-word values (`--system-prompt "..."`) keep working. Tests: +6 new mcpd unit tests (4 chat-service for the reasoning fallback / truncation marker / content-preference / streaming synth; 2 llm-adapters for the URL strip + /v1beta preservation). Full mcpd + mcplocal + smoke green: 860/860 + 723/723 + 139/139.
2026-04-27 18:39:01 +01:00
// Regression: thinking models with a tight max_tokens budget produce
// `reasoning_content` only and leave `content` null. Without falling back
// to reasoning, the assistant turn was empty and the smoke test saw an
// empty stdout. This covers BOTH chat() (non-streaming) and chatStream()
// (synthetic final text frame so the CLI's stdout matches what's
// persisted to the thread).
it('chat falls back to reasoning_content when content is null', async () => {
const chatRepo = mockChatRepo();
const adapter: LlmAdapter = {
kind: 'thinking-truncated',
infer: vi.fn(async () => ({
status: 200,
body: {
id: 'cmpl-1',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: null, reasoning_content: 'Thinking out loud about the answer' },
finish_reason: 'stop',
}],
},
})),
stream: async function*() { yield { data: '[DONE]', done: true }; },
};
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
expect(result.assistant).toBe('Thinking out loud about the answer');
const stored = chatRepo._msgs.find((m) => m.role === 'assistant');
expect(stored?.content).toBe('Thinking out loud about the answer');
});
it('chat appends [response truncated by max_tokens] when finish_reason is "length"', async () => {
const chatRepo = mockChatRepo();
const adapter: LlmAdapter = {
kind: 'thinking-clipped',
infer: vi.fn(async () => ({
status: 200,
body: {
choices: [{
index: 0,
message: { role: 'assistant', content: null, reasoning_content: 'partial reasoning that ran out of' },
finish_reason: 'length',
}],
},
})),
stream: async function*() { yield { data: '[DONE]', done: true }; },
};
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
expect(result.assistant).toContain('partial reasoning that ran out of');
expect(result.assistant).toContain('[response truncated by max_tokens]');
});
it('chat prefers content when both content and reasoning_content are present', async () => {
// Thinking models that DO produce content shouldn't see the reasoning
// bleed into the response — that's what the streaming path's
// text/thinking split is for, and the non-streaming path should match.
const chatRepo = mockChatRepo();
const adapter: LlmAdapter = {
kind: 'thinking-with-content',
infer: vi.fn(async () => ({
status: 200,
body: {
choices: [{
index: 0,
message: { role: 'assistant', content: 'real answer', reasoning_content: 'background thinking' },
finish_reason: 'stop',
}],
},
})),
stream: async function*() { yield { data: '[DONE]', done: true }; },
};
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
expect(result.assistant).toBe('real answer');
expect(result.assistant).not.toContain('background thinking');
});
it('chatStream emits a synthetic text frame and persists reasoning when content is empty', async () => {
const chatRepo = mockChatRepo();
const adapter: LlmAdapter = {
kind: 'thinking-only-stream',
infer: vi.fn(),
stream: async function*() {
yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'thinking ' }, finish_reason: null }] }) };
yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'more.' }, finish_reason: 'stop' }] }) };
yield { data: '[DONE]', done: true };
},
};
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const chunks: Array<{ type: string; delta?: string }> = [];
for await (const c of svc.chatStream({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' })) {
chunks.push({ type: c.type, delta: c.delta });
}
// 2 thinking deltas (live), 1 synthesized text frame, 1 final.
expect(chunks.filter((c) => c.type === 'thinking').map((c) => c.delta)).toEqual(['thinking ', 'more.']);
expect(chunks.filter((c) => c.type === 'text').map((c) => c.delta)).toEqual(['thinking more.']);
// The thread message captures the synthesized text so resumed chats see
// a coherent assistant turn (rather than blank).
const stored = chatRepo._msgs.find((m) => m.role === 'assistant');
expect(stored?.content).toBe('thinking more.');
});
feat(chat): surface reasoning_content as `thinking` chunks; fix --no-stream timeout Reasoning models (qwen3-thinking, deepseek-reasoner, OpenAI o1 family) emit their scratchpad as `delta.reasoning_content` (or `delta.reasoning`, or `delta.provider_specific_fields.reasoning_content` when LiteLLM passes through from vLLM) — separate from `delta.content`. Before this commit mcpd's parseStreamingChunk only watched `content`, so the model's 30-90s reasoning phase looked like dead air to the REPL: streaming connection open, no chunks, no progress. Caught during the agents-feature shakedown when qwen3-thinking sat silent for 90s on a docmost__list_pages call. mcpd ==== chat.service.ts - parseStreamingChunk extracts a `reasoningDelta` from the chunk body, accepting all four spellings (reasoning_content / reasoning / provider_specific_fields.{reasoning_content,reasoning}). Future providers can add their own field names by extending the fallback chain. - chatStream yields `{ type: 'thinking', delta }` chunks as reasoning arrives, alongside the existing `{ type: 'text', delta }` for content. - Reasoning is intentionally NOT persisted to the thread. It's the model's scratchpad, not part of the conversation. Subsequent turns don't see it. - Adds 'thinking' to the ChatStreamChunk.type union. CLI === chat.ts - streamOnce handles 'thinking' chunks: writes them dim+italic to stderr (ANSI 2;3m) so the model's reasoning visually flows like a quote block while the final answer streams to stdout. Plain text when stderr isn't a TTY (pipe to file → no escape codes leak). - chatRequestNonStream replaces the shared ApiClient.post() for the --no-stream path. ApiClient defaults to a 10s timeout, way too tight for any chat that calls a tool: LLM round + tool dispatch + LLM summary easily exceeds 10s. The new helper uses the same 600s timeout the streaming path has been using all along. Tests: chat-service.test.ts (+2): - reasoning_content deltas surface as `thinking` chunks (not text); reasoning is NOT persisted to the assistant turn's content. - LiteLLM's provider_specific_fields.reasoning_content shape parses identically to the vendor-native shape. mcpd 777/777, cli 430/430. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:04:01 +01:00
// Regression: provider_specific_fields.reasoning_content shape (LiteLLM
// passthrough from vLLM) is also recognized.
it('chatStream recognizes LiteLLM provider_specific_fields.reasoning_content', async () => {
const chatRepo = mockChatRepo();
const adapter: LlmAdapter = {
kind: 'scripted-litellm',
infer: vi.fn(),
stream: async function*() {
yield { data: JSON.stringify({ choices: [{ delta: { provider_specific_fields: { reasoning_content: 'thinking via litellm...' } }, finish_reason: null }] }) };
yield { data: JSON.stringify({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }) };
yield { data: '[DONE]', done: true };
},
};
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const chunks: Array<{ type: string; delta?: string }> = [];
for await (const chunk of svc.chatStream({
agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1',
})) {
chunks.push({ type: chunk.type, delta: chunk.delta });
}
expect(chunks.filter((c) => c.type === 'thinking').map((c) => c.delta))
.toEqual(['thinking via litellm...']);
});
feat(mcpd+db): Llm.poolName + chat dispatcher pool failover (v4 Stage 1) Adds LB-pool-by-shared-name without introducing a new resource. The existing `Llm.name` stays globally unique; a new optional `poolName` column declares membership in a pool. Multiple Llms sharing a non-null `poolName` stack into one load-balanced pool that the chat dispatcher expands at request time. Effective pool key = `poolName ?? name`. Solo rows (poolName=null) are addressable as a "pool of 1" via their own name, so existing single-Llm agents and YAMLs keep working unchanged. A solo row whose name happens to match an explicit poolName joins the same pool — by design — so an operator can transparently promote an existing Llm to pool seed. Dispatcher (chat.service): prepareContext now resolves a randomly- shuffled list of viable pool candidates (status != inactive) once per turn. runOneInference and streamInference iterate the list on transport-level failure (network, virtual publisher disconnect) until one succeeds or the list is exhausted. Streaming failover only covers "failed before first chunk" — once we've yielded text, we're committed to that backend. Auth/4xx errors surfaced as result.status are NOT retried; siblings with the same key/model would fail identically. When the agent's pinned Llm is itself inactive but a sibling pool member is up, dispatch transparently uses the sibling — that's the whole point. When every member is inactive, prepareContext throws a clear "No active Llm in pool '<key>' (pinned: <name>)" error rather than letting the dispatcher's "exhausted" branch surface it. Tests: - 5 new chat-service tests for pool dispatch / failover / pinned-down / all-inactive (chat-service.test.ts). - 7 new db schema tests for the column, the unique-name invariant, the fallback-to-name semantics, and the solo-name-joins-explicit-pool edge case (llm-pool-schema.test.ts). - mcpd 865/865 (was 860; +5), db pool-schema 7/7, no regressions. Stage 2 (next): HTTP route /api/v1/llms/<name>/members + aggregate pool stats on the existing single-Llm route, CLI POOL column + describe block + --pool-name flag, yaml round-trip.
2026-04-27 22:02:41 +01:00
// ── v4: LB pool by shared name ──
// Helper: build a multi-member mock LlmService where all members share an
// effective pool key. `nameToFail` lets a test mark specific names as
// throwing on dispatch, exercising failover.
function mockLlmsPool(opts: {
pinnedName: string;
poolName: string | null;
members: Array<{ name: string; status?: 'active' | 'inactive'; kind?: 'public' | 'virtual' }>;
}): LlmService {
const baseRow = (m: { name: string; status?: 'active' | 'inactive'; kind?: 'public' | 'virtual' }): Record<string, unknown> => ({
id: `id-${m.name}`,
name: m.name,
type: 'openai',
model: 'qwen3-thinking',
url: '',
tier: 'fast',
description: '',
apiKeySecretId: null,
apiKeySecretKey: null,
extraConfig: {},
poolName: opts.poolName,
kind: m.kind ?? 'public',
providerSessionId: null,
status: m.status ?? 'active',
lastHeartbeatAt: null,
inactiveSince: null,
version: 1,
createdAt: NOW,
updatedAt: NOW,
});
return {
getByName: vi.fn(async (name: string) => {
const m = opts.members.find((x) => x.name === name) ?? opts.members[0]!;
return { ...baseRow(m), apiKeyRef: null };
}),
findByPoolName: vi.fn(async () => opts.members.map(baseRow)),
resolveApiKey: vi.fn(async () => 'fake-key'),
} as unknown as LlmService;
}
it('chat dispatches to a pool member and persists the reply (pool size N, primary returns)', async () => {
// Three healthy members; the (random) primary wins on first try.
// Adapter returns the same canned reply regardless of which member
// got picked because in this test we don't differentiate by name —
// we just assert that dispatch goes through and the agent gets a
// reply. Per-member assertion is covered by the failover test below.
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('hello from pool')]);
const svc = new ChatService(
mockAgents(),
mockLlmsPool({
pinnedName: 'qwen-prod-1',
poolName: 'qwen-pool',
members: [
{ name: 'qwen-prod-1' },
{ name: 'qwen-prod-2' },
{ name: 'qwen-prod-3' },
],
}),
adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
expect(result.assistant).toBe('hello from pool');
expect(adapter.infer).toHaveBeenCalledTimes(1);
});
it('chat fails over to the next pool member when the first throws on dispatch', async () => {
// 3 members; first 2 throw, 3rd succeeds. Verify exactly 3 dispatches
// and the final reply propagates.
const chatRepo = mockChatRepo();
let call = 0;
const adapter: LlmAdapter = {
kind: 'flaky',
infer: vi.fn(async () => {
call += 1;
if (call < 3) throw new Error(`transport-error-${String(call)}`);
return chatCompletion('survived to the third try').body !== undefined
? { status: 200, body: chatCompletion('survived to the third try').body }
: (() => { throw new Error('unreachable'); })();
}),
stream: async function*() { yield { data: '[DONE]', done: true }; },
};
const svc = new ChatService(
mockAgents(),
mockLlmsPool({
pinnedName: 'qwen-prod-1',
poolName: 'qwen-pool',
members: [
{ name: 'qwen-prod-1' },
{ name: 'qwen-prod-2' },
{ name: 'qwen-prod-3' },
],
}),
adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
expect(result.assistant).toBe('survived to the third try');
expect(adapter.infer).toHaveBeenCalledTimes(3);
});
it('chat throws when every pool member throws (exhausted)', async () => {
const chatRepo = mockChatRepo();
const adapter: LlmAdapter = {
kind: 'all-broken',
infer: vi.fn(async () => { throw new Error('transport-down'); }),
stream: async function*() { yield { data: '[DONE]', done: true }; },
};
const svc = new ChatService(
mockAgents(),
mockLlmsPool({
pinnedName: 'qwen-prod-1',
poolName: 'qwen-pool',
members: [{ name: 'qwen-prod-1' }, { name: 'qwen-prod-2' }],
}),
adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
await expect(svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }))
.rejects.toThrow(/transport-down/);
expect(adapter.infer).toHaveBeenCalledTimes(2);
});
it('chat refuses with 404 when every pool member is inactive', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('should never run')]);
const svc = new ChatService(
mockAgents(),
mockLlmsPool({
pinnedName: 'qwen-prod-1',
poolName: 'qwen-pool',
members: [
{ name: 'qwen-prod-1', status: 'inactive' },
{ name: 'qwen-prod-2', status: 'inactive' },
],
}),
adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
await expect(svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }))
.rejects.toThrow(/No active Llm in pool/);
expect(adapter.infer).not.toHaveBeenCalled();
});
it('chat picks a healthy sibling when the pinned Llm is itself inactive', async () => {
// The agent pins to qwen-prod-1 which is inactive. qwen-prod-2 is
// active. Pool dispatch must skip the dead pinned row and use the
// sibling instead — that's the whole point of v4.
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('via sibling')]);
const svc = new ChatService(
mockAgents(),
mockLlmsPool({
pinnedName: 'qwen-prod-1',
poolName: 'qwen-pool',
members: [
{ name: 'qwen-prod-1', status: 'inactive' },
{ name: 'qwen-prod-2', status: 'active' },
],
}),
adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
);
const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
expect(result.assistant).toBe('via sibling');
expect(adapter.infer).toHaveBeenCalledTimes(1);
});
fix(agents): close gaps from /gstack-review P1 — thread reads now enforce ownership ======================================== chat.service.ts / routes/agent-chat.ts GET /api/v1/threads/:id/messages was previously RBAC-mapped to view:agents (no resourceName scope) with the route comment promising "service-level owner check enforces fine-grained access" — but the service didn't actually check. Any caller with view:agents could read another user's thread by guessing/learning the threadId. CUIDs are hard to brute-force but they leak: SSE `final` chunks, agents-plugin `_meta.threadId`, and several response bodies surface them. Now ChatService.listMessages(threadId, ownerId) loads the thread, returns 404 (not 403, to avoid id-enumeration via differential status codes) if ownerId doesn't match. Regression test in chat-service.test.ts covers Alice/Bob isolation + nonexistent-thread same-shape 404. P2 — AgentChatRequestSchema strict mode ======================================== validation/agent.schema.ts `.merge()` does NOT inherit `.strict()` from AgentChatParamsSchema. Typo'd fields (e.g. `temprature`) silently fell through and the agent silently used the default — debuggable only by reading the LLM call payload. Re-applied `.strict()` on the merged schema. P2 — per-agent maxIterations override + clamp ============================================== chat.service.ts Loop cap was a hard-coded module constant (12), wrong for both research-style agents (need higher) and cheap-probe agents (could opt lower). Now reads `agent.extras.maxIterations`, clamps 1..50, falls back to 12 default. The clamp is the soft-DoS guard: a hostile agent definition with `maxIterations:1000000` can't burn unbounded LLM calls per request. Both chat() and chatStream() use ctx.maxIterations now. Regression test covers low-cap override (rejects with `exceeded 2`) and hostile-value clamp (rejects with `exceeded 50`). P3 — SSE write to closed socket ================================ routes/agent-chat.ts When the upstream adapter throws after some chunks were already written AND the client disconnected, the catch block tried to flush more chunks to a closed socket. Without an `on('error')` handler Node emits unhandled error events; once Pino is wired to alerts this'd page on every disconnect-mid-stream. writeSseChunk now checks `reply.raw.destroyed || writableEnded` before write. P3 — BACKEND_TOKEN_DEAD preserves original stack ================================================= services/secret-backend-rotator.service.ts When wrapping mintRoleToken/lookupSelf failures as BACKEND_TOKEN_DEAD, the new Error() discarded the original throw — hard to tell whether the inner failure was a network blip vs an OpenBao API mismatch vs DNS. Now uses `new Error(msg, { cause: err })` so the inner stack survives. P3 — .gitignore .claude/scheduled_tasks.lock ============================================= This persisted state file was leaking into every `git status`. Tests ===== mcpd 761/761 (+2 regression tests). mcplocal 715/715. cli 430/430. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:53:19 +01:00
// Regression: per-agent maxIterations override + clamp.
// Found by /gstack-review on 2026-04-25.
// Without the clamp, a hostile agent definition with `extras.maxIterations:1000000`
// could spin the loop into a near-infinite tool-call burn.
it('per-agent extras.maxIterations clamps below default and refuses absurd values', async () => {
const chatRepo = mockChatRepo();
const tools = mockTools({
listTools: vi.fn(async () => [{
name: `g${TOOL_NAME_SEPARATOR}t`, description: '', parameters: {},
}]),
callTool: vi.fn(async () => ({})),
});
// Agent with maxIterations=2 — only 2 tool-call rounds allowed before bail.
const agentsLowCap = {
getByName: vi.fn(async () => ({
id: 'agent-low', name: 'low', description: '', systemPrompt: '',
llm: { id: 'llm-1', name: 'qwen3-thinking' },
project: { id: 'proj-1', name: 'mcpctl-dev' },
proxyModelName: null, defaultParams: {},
extras: { maxIterations: 2 },
ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW,
})),
} as unknown as AgentService;
const adapter = scriptedAdapter([toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})]);
const svc = new ChatService(
agentsLowCap, mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), tools,
);
await expect(svc.chat({
agentName: 'low', userMessage: 'spin', ownerId: 'owner-1',
})).rejects.toThrow(/exceeded 2 iterations/);
// Hostile agent with maxIterations=1000000 — must clamp to 50, not iterate forever.
const agentsHostile = {
getByName: vi.fn(async () => ({
id: 'agent-bad', name: 'bad', description: '', systemPrompt: '',
llm: { id: 'llm-1', name: 'qwen3-thinking' },
project: { id: 'proj-1', name: 'mcpctl-dev' },
proxyModelName: null, defaultParams: {},
extras: { maxIterations: 1_000_000 },
ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW,
})),
} as unknown as AgentService;
const adapter2 = scriptedAdapter([toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})]);
const chatRepo2 = mockChatRepo();
const svc2 = new ChatService(
agentsHostile, mockLlms(), adapterRegistry(adapter2),
chatRepo2, mockPromptRepo(), tools,
);
await expect(svc2.chat({
agentName: 'bad', userMessage: 'spin', ownerId: 'owner-1',
})).rejects.toThrow(/exceeded 50 iterations/);
});
// Regression: thread message reads must enforce ownership.
// Found by /gstack-review on 2026-04-25.
// Without this, any caller with `view:agents` could read another user's thread
// by guessing/learning the threadId (CUIDs leak through SSE chunks + tool _meta).
it('listMessages refuses a thread owned by another user (404, not 403, to avoid id-enumeration)', async () => {
const chatRepo = mockChatRepo();
// Pre-seed a thread owned by 'alice'
await chatRepo.createThread({ agentId: 'agent-x', ownerId: 'alice' });
const aliceThread = chatRepo._threads[0]!;
await chatRepo.appendMessage({
threadId: aliceThread.id,
role: 'user',
content: 'private to alice',
});
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(scriptedAdapter([chatCompletion('ok')])),
chatRepo, mockPromptRepo(), mockTools(),
);
// Bob requests Alice's thread by id — must 404.
await expect(svc.listMessages(aliceThread.id, 'bob'))
.rejects.toThrow(/not found/i);
// Alice gets her own messages.
const aliceMessages = await svc.listMessages(aliceThread.id, 'alice');
expect(aliceMessages.map((m) => m.content)).toEqual(['private to alice']);
// Genuinely missing thread — same 404 shape (no oracle leak).
await expect(svc.listMessages('cnonexistent000000000000000', 'alice'))
.rejects.toThrow(/not found/i);
});
feat(mcpd): personality routes + chat system block overlay (Stage 3) End-to-end backend wiring for the agents-feature evolution. After this stage you can curl all the endpoints; CLI + Web UI follow. Routes (new): GET /api/v1/agents/:agentName/personalities POST /api/v1/agents/:agentName/personalities GET /api/v1/personalities/:id PUT /api/v1/personalities/:id DELETE /api/v1/personalities/:id GET /api/v1/personalities/:id/prompts POST /api/v1/personalities/:id/prompts DELETE /api/v1/personalities/:id/prompts/:promptId GET /api/v1/agents/:agentName/prompts (agent-direct) Routes (extended): POST /api/v1/prompts now resolves `agent: <name>` like `project: <name>` POST /api/v1/agents/:name/chat accepts `personality: <name>` RBAC: `personalities` segment maps to the `agents` resource so view/edit/create/delete on the parent agent governs personality access. No new RBAC roles — piggybacking keeps the surface flat. System block (chat.service.ts): agent.systemPrompt + agent-direct prompts (Prompt.agentId === agent.id, priority desc) + project prompts (existing behavior, priority desc) + personality prompts (PersonalityPrompt[chosen], priority desc) + systemAppend Personality is selected by request body `personality: <name>`, falling back to `agent.defaultPersonalityId` if unset. A typo'd flag throws 404 rather than silently dropping back to no overlay — failing loudly on misconfiguration is the only way users learn it didn't apply. Backwards-compatible by construction: when no agent-direct prompts exist and no personality is selected, the resulting block is byte- identical to the old layout (verified by a regression test). Tests: 5 new chat-service.test cases cover ordering, default- personality fallback, missing-personality 404, and the regression guard. mcpd suite: 801/801 (was 796). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:27:59 +01:00
// ── Agent-direct prompts + personality overlay (Stage 3 system block) ──
it('injects agent-direct prompts BETWEEN agent.systemPrompt and project prompts', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const prompts: Prompt[] = [
// Project prompt
{
id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT',
projectId: 'proj-1', agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
},
// Agent-direct prompt
{
id: 'p-direct', name: 'direct', content: 'AGENT_DIRECT_TEXT',
projectId: null, agentId: 'agent-reviewer', priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
},
];
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(prompts), mockTools(),
);
await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
const text = sys?.content as string;
expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('AGENT_DIRECT_TEXT'));
expect(text.indexOf('AGENT_DIRECT_TEXT')).toBeLessThan(text.indexOf('PROJECT_TEXT'));
});
it('appends personality-bound prompts after project prompts when --personality is passed', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const projectPrompt: Prompt = {
id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT',
projectId: 'proj-1', agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
};
const personalityPrompt: Prompt = {
id: 'p-pers', name: 'pers', content: 'PERSONALITY_TEXT',
projectId: null, agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
};
const personalities = mockPersonalityRepo({
'pers-grumpy': {
personality: makePersonality({ id: 'pers-grumpy', name: 'grumpy', agentId: 'agent-reviewer' }),
bindings: [{ promptId: personalityPrompt.id, priority: 5 }],
},
}, [projectPrompt, personalityPrompt]);
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo([projectPrompt, personalityPrompt]), mockTools(),
personalities,
);
await svc.chat({
agentName: 'reviewer',
userMessage: 'hi',
ownerId: 'owner-1',
personalityName: 'grumpy',
});
const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
const text = sys?.content as string;
expect(text.indexOf('PROJECT_TEXT')).toBeLessThan(text.indexOf('PERSONALITY_TEXT'));
});
it('falls back to agent.defaultPersonality when --personality is omitted', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const personalityPrompt: Prompt = {
id: 'p-pers', name: 'pers', content: 'DEFAULT_PERSONALITY_TEXT',
projectId: null, agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
};
const personalities = mockPersonalityRepo({
'pers-default': {
personality: makePersonality({ id: 'pers-default', name: 'default', agentId: 'agent-reviewer' }),
bindings: [{ promptId: personalityPrompt.id, priority: 5 }],
},
}, [personalityPrompt]);
const svc = new ChatService(
mockAgents({ defaultPersonality: { id: 'pers-default', name: 'default' } }),
mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo([personalityPrompt]), mockTools(),
personalities,
);
await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
expect(sys?.content as string).toContain('DEFAULT_PERSONALITY_TEXT');
});
it('throws when --personality references a name the agent does not own', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const personalities = mockPersonalityRepo({});
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
personalities,
);
await expect(svc.chat({
agentName: 'reviewer',
userMessage: 'hi',
ownerId: 'owner-1',
personalityName: 'ghost',
})).rejects.toThrow(/Personality not found/);
});
it('preserves today\'s system block when no personality and no agent-direct prompts exist', async () => {
// Regression guard: backwards-compatible by construction.
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const projectPrompt: Prompt = {
id: 'p-proj', name: 'proj', content: 'ONLY_PROJECT_TEXT',
projectId: 'proj-1', agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
};
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo([projectPrompt]), mockTools(),
);
await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
const text = sys?.content as string;
expect(text).toContain('You are a helpful agent.');
expect(text).toContain('ONLY_PROJECT_TEXT');
});
feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2) Layers the persistence-side logic on top of the Stage 1 schema. AgentService mirrors LlmService's CRUD shape with name-resolved llm/project references and yaml round-trip support; ChatService is the orchestrator that drives one chat turn end-to-end: build the merged system block (agent.systemPrompt + project Prompts ordered by priority desc + per-call systemAppend), persist the user turn, run the adapter, dispatch any tool_calls through an injected ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop until the model returns terminal text. Per-call params resolve LiteLLM-style: request body → agent.defaultParams → adapter default. The escape hatch `extra` is forwarded as-is so each adapter can cherry-pick provider-specific knobs (Anthropic metadata, vLLM repetition_penalty, etc.) without code changes here. Persistence is non-transactional across the loop because tool calls can take minutes; long-held DB transactions would starve other writers. Instead each in-flight assistant turn is written `pending` and flipped to `complete` only after its tool results land. On failure or max-iter overrun, every `pending` row in the thread is flipped to `error` so the trail is auditable. Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at dispatch time; `tools_allowlist` filters before the model sees the list. Tests: agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict on duplicate, llm switch, project detach, listByProject filtering, upsertByName branch coverage. chat-service.test.ts (9) — plain text turn, full text→tool→text loop with toolCallId linkage, max-iter cap leaves zero pending, adapter-throws leaves zero pending, body→defaultParams merge, `extra` passthrough, project-Prompt priority ordering in the system block, tool-without- project rejection, tools_allowlist filtering. All 16 green; full mcpd suite still 737/737. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:38 +01:00
});
feat(mcpd): personality routes + chat system block overlay (Stage 3) End-to-end backend wiring for the agents-feature evolution. After this stage you can curl all the endpoints; CLI + Web UI follow. Routes (new): GET /api/v1/agents/:agentName/personalities POST /api/v1/agents/:agentName/personalities GET /api/v1/personalities/:id PUT /api/v1/personalities/:id DELETE /api/v1/personalities/:id GET /api/v1/personalities/:id/prompts POST /api/v1/personalities/:id/prompts DELETE /api/v1/personalities/:id/prompts/:promptId GET /api/v1/agents/:agentName/prompts (agent-direct) Routes (extended): POST /api/v1/prompts now resolves `agent: <name>` like `project: <name>` POST /api/v1/agents/:name/chat accepts `personality: <name>` RBAC: `personalities` segment maps to the `agents` resource so view/edit/create/delete on the parent agent governs personality access. No new RBAC roles — piggybacking keeps the surface flat. System block (chat.service.ts): agent.systemPrompt + agent-direct prompts (Prompt.agentId === agent.id, priority desc) + project prompts (existing behavior, priority desc) + personality prompts (PersonalityPrompt[chosen], priority desc) + systemAppend Personality is selected by request body `personality: <name>`, falling back to `agent.defaultPersonalityId` if unset. A typo'd flag throws 404 rather than silently dropping back to no overlay — failing loudly on misconfiguration is the only way users learn it didn't apply. Backwards-compatible by construction: when no agent-direct prompts exist and no personality is selected, the resulting block is byte- identical to the old layout (verified by a regression test). Tests: 5 new chat-service.test cases cover ordering, default- personality fallback, missing-personality 404, and the regression guard. mcpd suite: 801/801 (was 796). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:27:59 +01:00
// ── Helpers for personality-overlay tests ──
function makePersonality(overrides: Partial<Personality> = {}): Personality {
return {
id: `pers-${Math.random().toString(36).slice(2, 8)}`,
name: 'p',
description: '',
agentId: 'agent-reviewer',
priority: 5,
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
interface MockPersonalityFixture {
personality: Personality;
bindings: Array<{ promptId: string; priority: number }>;
}
function mockPersonalityRepo(
fixtures: Record<string, MockPersonalityFixture>,
prompts: Prompt[] = [],
): IPersonalityRepository {
const byId = new Map<string, MockPersonalityFixture>(Object.entries(fixtures));
const promptsById = new Map<string, Prompt>(prompts.map((p) => [p.id, p]));
return {
findAll: vi.fn(async () => [...byId.values()].map((f) => f.personality)),
findByAgent: vi.fn(async (agentId: string) =>
[...byId.values()].filter((f) => f.personality.agentId === agentId).map((f) => f.personality)),
findById: vi.fn(async (id: string) => byId.get(id)?.personality ?? null),
findByNameAndAgent: vi.fn(async (name: string, agentId: string) => {
for (const f of byId.values()) {
if (f.personality.name === name && f.personality.agentId === agentId) {
return f.personality;
}
}
return null;
}),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
listPrompts: vi.fn(async (personalityId: string) => {
const fixture = byId.get(personalityId);
if (!fixture) return [];
return fixture.bindings.map<PersonalityPrompt & { prompt: Prompt }>((b) => ({
id: `bind-${b.promptId}`,
personalityId,
promptId: b.promptId,
priority: b.priority,
createdAt: NOW,
prompt: promptsById.get(b.promptId) ?? ({
id: b.promptId, name: 'p', content: '',
projectId: null, agentId: null, priority: b.priority,
summary: null, chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
} as Prompt),
}));
}),
attachPrompt: vi.fn(),
detachPrompt: vi.fn(),
findBinding: vi.fn(async () => null),
};
}
// ── Failover chain + "which model answered" reporting ──
describe('ChatService — failover chain', () => {
const NOW2 = new Date();
function llmRow(name: string, model: string, fallbacks: string[] = []): Record<string, unknown> {
return {
id: `id-${name}`, name, type: 'openai', model, url: '', tier: 'fast', description: '',
apiKeySecretId: null, apiKeySecretKey: null,
extraConfig: fallbacks.length > 0 ? { fallbacks } : {},
poolName: null, kind: 'public', providerSessionId: null, status: 'active',
lastHeartbeatAt: null, inactiveSince: null, version: 1, createdAt: NOW2, updatedAt: NOW2,
};
}
function failoverLlms(): LlmService {
const rows: Record<string, Record<string, unknown>> = {
'primary-llm': llmRow('primary-llm', 'primary-model', ['fallback-llm']),
'fallback-llm': llmRow('fallback-llm', 'fallback-model'),
};
return {
getByName: vi.fn(async (name: string) => ({ ...rows[name], apiKeyRef: null })),
findByPoolName: vi.fn(async (pool: string) => [rows[pool]]),
resolveApiKey: vi.fn(async () => 'k'),
} as unknown as LlmService;
}
function agentPinnedTo(llmName: string): AgentService {
return {
getByName: vi.fn(async (name: string) => ({
id: `agent-${name}`, name, description: '', systemPrompt: 'sys',
llm: { id: 'x', name: llmName }, project: null, defaultPersonality: null,
proxyModelName: null, defaultParams: {}, extras: {}, ownerId: 'o', version: 1, createdAt: NOW2, updatedAt: NOW2,
})),
} as unknown as AgentService;
}
const status400 = (): NonStreamingResult => ({ status: 400, body: { error: 'model not served' } });
it('fails over to the fallback Llm on a primary 400 and reports the answering model', async () => {
const adapter = scriptedAdapter([status400(), chatCompletion('fallback answer')]);
const svc = new ChatService(agentPinnedTo('primary-llm'), failoverLlms(), adapterRegistry(adapter), mockChatRepo(), mockPromptRepo(), mockTools());
const res = await svc.chat({ agentName: 'a', userMessage: 'hi', ownerId: 'o' });
expect(res.assistant).toBe('fallback answer');
expect(res.failedOver).toBe(true);
expect(res.llm).toBe('fallback-llm');
expect(res.model).toBe('fallback-model');
});
it('reports the primary model + failedOver=false when the primary answers', async () => {
const adapter = scriptedAdapter([chatCompletion('primary answer')]);
const svc = new ChatService(agentPinnedTo('primary-llm'), failoverLlms(), adapterRegistry(adapter), mockChatRepo(), mockPromptRepo(), mockTools());
const res = await svc.chat({ agentName: 'a', userMessage: 'hi', ownerId: 'o' });
expect(res.assistant).toBe('primary answer');
expect(res.failedOver).toBe(false);
expect(res.llm).toBe('primary-llm');
expect(res.model).toBe('primary-model');
});
it('throws a clear aggregated error naming the model when every candidate fails', async () => {
const adapter = scriptedAdapter([status400(), status400()]);
const svc = new ChatService(agentPinnedTo('primary-llm'), failoverLlms(), adapterRegistry(adapter), mockChatRepo(), mockPromptRepo(), mockTools());
await expect(svc.chat({ agentName: 'a', userMessage: 'hi', ownerId: 'o' })).rejects.toThrow(/HTTP 400/);
});
});