import { beforeEach, describe, expect, mock, test } from "bun:test"; import { CompactionCircuit } from "../agent/compaction-circuit.js"; import type { AgentEvent } from "../agent/loop.js"; import type { AssistantEvent } from "../api/index.js"; import type { Message, ProviderResponse } from "../providers/types.js"; // --------------------------------------------------------------------------- // Mocks — must precede the Conversation import so Bun applies them at load time. // --------------------------------------------------------------------------- mock.module("../providers/registry.js", () => ({ getProvider: () => ({ name: "mock-provider" }), initializeProviders: async () => {}, })); mock.module("../prompts/system-prompt.js", () => ({ buildSystemPrompt: () => "system prompt", })); mock.module("../permissions/trust-store.js", () => ({ clearCache: () => {}, })); mock.module("../security/secret-allowlist.js", () => ({ resetAllowlist: () => {}, })); const addMessageCalls: Array<{ convId: string; role: string; content: string; }> = []; mock.module("../persistence/conversation-crud.js", () => ({ setConversationProcessingStartedAt: () => {}, isConversationProcessing: () => false, setConversationOriginChannelIfUnset: () => {}, updateConversationContextWindow: () => {}, deleteMessageById: () => {}, provenanceFromTrustContext: () => ({ source: "user", trustContext: undefined, }), getConversationOriginInterface: () => null, getConversationOriginChannel: () => null, getMessages: () => [], getConversation: () => ({ id: "conv-1", contextSummary: null, contextCompactedMessageCount: 0, totalInputTokens: 0, totalOutputTokens: 0, totalEstimatedCost: 0, }), createConversation: () => ({ id: "conv-1" }), addMessage: (convId: string, role: string, content: string) => { addMessageCalls.push({ convId, role, content }); return { id: `msg-${Date.now()}` }; }, updateConversationUsage: () => {}, updateConversationTitle: () => {}, getMessageById: () => null, getLastUserTimestampBefore: () => 0, reserveMessage: mock(async () => ({ id: "msg-reserve" })), updateMessageContent: mock(() => {}), })); mock.module("../persistence/conversation-queries.js", () => ({ listConversations: () => [], })); mock.module("../memory/retriever.js", () => ({ buildMemoryRecall: async () => ({ enabled: false, degraded: false, injectedText: "", semanticHits: 0, injectedTokens: 0, latencyMs: 0, }), injectMemoryRecallAsUserBlock: (msgs: Message[]) => msgs, })); mock.module("../plugins/defaults/compaction/window-manager.js", () => ({ ContextWindowManager: class { estimateInputTokens() { return 0; } get tokenCountInputs() { return { systemPrompt: "", tools: undefined }; } constructor() {} updateConfig() {} shouldCompact() { return { needed: false, estimatedTokens: 0 }; } async maybeCompact() { return { compacted: false }; } resetOverflowRecovery() {} }, createContextSummaryMessage: () => ({ role: "user", content: [{ type: "text", text: "summary" }], }), getSummaryFromContextMessage: () => null, })); // Mock skill catalog — "start-the-day" and "browser" are available mock.module("../config/skills.js", () => ({ loadSkillCatalog: () => [ { id: "start-the-day", name: "Start the Day", displayName: "Start the Day", description: "Morning routine skill", directoryPath: "/skills/start-the-day", skillFilePath: "/skills/start-the-day/SKILL.md", source: "managed", }, { id: "browser", name: "Browser", displayName: "Browser", description: "Navigate and interact with web pages using a headless browser", directoryPath: "/skills/browser", skillFilePath: "/skills/browser/SKILL.md", source: "bundled", }, ], loadSkillBySelector: () => null, ensureSkillIcon: () => {}, })); mock.module("../config/skill-state.js", () => ({ resolveSkillStates: (catalog: Record[]) => catalog.map((s) => ({ summary: s, state: "enabled", })), })); // Avoid real workspace-git initialization on /tmp — on CI runners, // `git add -A` under /tmp hits permission errors on systemd-private dirs, // which blocks `runAgentLoopImpl` for long enough to trip the test's // 5s timeout before `AgentLoop.run` is invoked. mock.module("../workspace/git-service.js", () => ({ getWorkspaceGitService: () => ({ ensureInitialized: async () => {}, }), })); mock.module("../workspace/turn-commit.js", () => ({ commitTurnChanges: async () => {}, })); // --------------------------------------------------------------------------- // AgentLoop mock — tracks whether run() was called // --------------------------------------------------------------------------- let agentLoopRunCalled = false; mock.module("../agent/loop.js", () => ({ AgentLoop: class { compactionCircuit = new CompactionCircuit("test-conv"); constructor() {} getToolTokenBudget() { return 0; } getResolvedTools() { return []; } getActiveModel() { return undefined; } async run(options: { messages: Message[]; onEvent: (event: AgentEvent) => void; }): Promise { const { messages, onEvent } = options; // Prime the assistant row anchor — production code emits this from // `AgentLoop.run` just before `provider.sendMessage`. await onEvent({ type: "llm_call_started" }); agentLoopRunCalled = true; const assistantMsg: Message = { role: "assistant", content: [{ type: "text", text: "reply" }], }; onEvent({ type: "usage", inputTokens: 10, outputTokens: 5, model: "mock", providerDurationMs: 100, }); onEvent({ type: "message_complete", message: assistantMsg }); return [...messages, assistantMsg]; } }, })); // --------------------------------------------------------------------------- // Import Conversation AFTER mocks are registered. // --------------------------------------------------------------------------- import { Conversation } from "../daemon/conversation.js"; function makeConversation(): Conversation { const provider = { name: "mock", async sendMessage(): Promise { return { content: [], model: "mock", usage: { inputTokens: 0, outputTokens: 0 }, stopReason: "end_turn", }; }, }; return new Conversation( "conv-1", provider, "system prompt", () => {}, "/tmp", { maxTokens: 4096 }, ); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("Conversation slash command — passthrough for unknown tokens", () => { beforeEach(() => { agentLoopRunCalled = false; addMessageCalls.length = 0; }); test("unknown slash-like input passes through to agent loop", async () => { const conversation = makeConversation(); await conversation.processMessage({ content: "/not-a-skill", attachments: [], }); // Should go through the normal agent loop path expect(agentLoopRunCalled).toBe(true); }); test("normal messages still go through standard path", async () => { const conversation = makeConversation(); const events: AssistantEvent[] = []; await conversation.processMessage({ content: "hello world", attachments: [], onEvent: (msg) => events.push(msg), }); expect(agentLoopRunCalled).toBe(true); }); });