diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31a38bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + env: + # prisma generate (server postinstall) only needs the var set, not a live DB. + DATABASE_URL: postgresql://ci:ci@localhost:5432/ci + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bun run typecheck + + - name: Lint + run: bun run lint + + # Hard gate — merge is blocked if any test fails. + - name: Test + run: bun run test diff --git a/AGENTS.md b/AGENTS.md index df9a049..8f2a779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,39 @@ Follow [Karpathy behavioral guidelines](.cursor/rules/karpathy-behavioral-guidel think before coding, minimum diff, verifiable success criteria. Every changed line must trace directly to the request. +## Build & verify — run before claiming done + +Bun monorepo. From the repo root: + +- `bun run check` — runs typecheck + lint + test in one shot. **Run this after every change.** +- `bun run test` — `bun test` across `shared`, `server`, `cli`. **Must be 0 failures.** +- `bun run typecheck` — `tsc --noEmit` per package (production sources; `**/*.test.ts` excluded). **Must be 0 errors.** +- `bun run lint` — Biome linter (config: `biome.json`; formatter is intentionally **off**). **Must be 0 errors.** + +The blocking gates are `bun run typecheck`, `bun run lint`, and `bun run test`. Do **not** mass-fix or +reformat unrelated code as a drive-by; fix only what your change touches. + +## Package boundaries — do not violate + +Dependency direction is one-way. Never add an import that points the wrong way. + +- `@mocode/shared` — lowest layer. Tool contracts, Zod schemas, model catalog live **here**. + Must **not** import from `cli`, `server`, or `database`. +- `@mocode/database` — Prisma client. Code under `packages/database/generated/` is + **auto-generated — never hand-edit**. Change `schema.prisma`, then + `bun run --cwd packages/database db:generate`. +- `@mocode/server` — depends on `shared` + `database`. +- `@mocode/cli` — depends on `shared` (uses `server` only for types). **Nothing may import from `cli`.** + +## Definition of Done + +A change is done only when **all** hold: + +1. `bun run test` passes (0 failures). +2. `bun run lint` passes (0 errors). +3. `bun run typecheck` passes (0 errors). +4. Every changed line traces directly to the request (no drive-by edits, no reformatting). + ## UI standard — read before touching the CLI **[`DESIGN.md`](./DESIGN.md) is the single source of truth for all terminal UI.** diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..698df59 --- /dev/null +++ b/biome.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": [ + "packages/**/src/**/*.ts", + "packages/**/src/**/*.tsx", + "!**/dist", + "!**/node_modules", + "!packages/database/generated" + ] + }, + "formatter": { "enabled": false }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "a11y": { + "noStaticElementInteractions": "off" + }, + "style": { + "noNonNullAssertion": "off", + "useNodejsImportProtocol": "off", + "useImportType": "off" + } + } + } +} diff --git a/bun.lock b/bun.lock index edf8d79..7d18810 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,9 @@ "dependencies": { "dotenv": "^17.4.2", }, + "devDependencies": { + "@biomejs/biome": "2.5.1", + }, }, "packages/cli": { "name": "@mocode/cli", @@ -19,6 +22,7 @@ "@ai-sdk/google": "^3.0.30", "@ai-sdk/groq": "^3.0.21", "@ai-sdk/openai": "^3.0.72", + "@ai-sdk/provider-utils": "^4.0.30", "@ai-sdk/react": "^3.0.210", "@mocode/shared": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", @@ -122,6 +126,24 @@ "@ai-sdk/react": ["@ai-sdk/react@3.0.210", "https://registry.npmmirror.com/@ai-sdk/react/-/react-3.0.210.tgz", { "dependencies": { "@ai-sdk/provider-utils": "4.0.30", "ai": "6.0.208", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-VprBlyc8yvwlpIdcIICL307vRncXrLlmcRTY/7JR5ND9+LvUcxJU16yW6prClFahqEWVza8Vr8P4Bgk5ZBtp4A=="], + "@biomejs/biome": ["@biomejs/biome@2.5.1", "https://registry.npmmirror.com/@biomejs/biome/-/biome-2.5.1.tgz", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.1", "@biomejs/cli-darwin-x64": "2.5.1", "@biomejs/cli-linux-arm64": "2.5.1", "@biomejs/cli-linux-arm64-musl": "2.5.1", "@biomejs/cli-linux-x64": "2.5.1", "@biomejs/cli-linux-x64-musl": "2.5.1", "@biomejs/cli-win32-arm64": "2.5.1", "@biomejs/cli-win32-x64": "2.5.1" }, "bin": { "biome": "bin/biome" } }, "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.1", "https://registry.npmmirror.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.1", "https://registry.npmmirror.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.1", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.1", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.1", "https://registry.npmmirror.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.1", "https://registry.npmmirror.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.1", "https://registry.npmmirror.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.1", "https://registry.npmmirror.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg=="], + "@clerk/backend": ["@clerk/backend@3.8.2", "https://registry.npmmirror.com/@clerk/backend/-/backend-3.8.2.tgz", { "dependencies": { "@clerk/shared": "^4.20.0", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-E/o2t3sEyRfB0/YvKd29cHHT/qxdvUwO35AHOFIcCwZ3GxcbfPjyvytsJf+ozwJd+3H89ROy5zcSEPPGnZa/zA=="], "@clerk/shared": ["@clerk/shared@4.20.0", "https://registry.npmmirror.com/@clerk/shared/-/shared-4.20.0.tgz", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-5t3YEnjwEwaect59N0Ycb5aDBbVGe3hICmy21X+n00IR1skmSTXPmPk/67bTktgEbGHxot2RGqhQbi2bFQV6AA=="], diff --git a/package.json b/package.json index cf216f2..645cb33 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,16 @@ "dev:cli": "bun run --watch packages/cli/src/index.tsx", "dev:server": "bun run --hot packages/server/src/index.ts", "build:cli": "bun run --filter @mocode/cli build", - "link:cli": "bun run build:cli && cd packages/cli && bun link" + "link:cli": "bun run build:cli && cd packages/cli && bun link", + "typecheck": "bunx tsc -p packages/shared/tsconfig.json --noEmit && bunx tsc -p packages/server/tsconfig.json --noEmit && bunx tsc -p packages/cli/tsconfig.json --noEmit && bunx tsc -p packages/database/tsconfig.json --noEmit", + "lint": "biome lint", + "test": "bun test packages/shared packages/server packages/cli", + "check": "bun run typecheck && bun run lint && bun run test" }, "dependencies": { "dotenv": "^17.4.2" + }, + "devDependencies": { + "@biomejs/biome": "2.5.1" } } \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json index 0244143..0e10305 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,6 +25,7 @@ "@ai-sdk/google": "^3.0.30", "@ai-sdk/groq": "^3.0.21", "@ai-sdk/openai": "^3.0.72", + "@ai-sdk/provider-utils": "^4.0.30", "@ai-sdk/react": "^3.0.210", "@mocode/shared": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/packages/cli/src/components/command-menu/use-command-menu.ts b/packages/cli/src/components/command-menu/use-command-menu.ts index 2a59d69..78e36fd 100644 --- a/packages/cli/src/components/command-menu/use-command-menu.ts +++ b/packages/cli/src/components/command-menu/use-command-menu.ts @@ -25,7 +25,7 @@ export function useCommandMenu():UseCommandMenuReturn { const [selectedIndex,setSelectedIndex] = useState(0); const [showCommandMenu,setShowCommandMenu] = useState(false); - const { push, pop, isTopLayer, setResponder } = useKeyboardLayer(); + const { push, pop, isTopLayer } = useKeyboardLayer(); // Closing the menu also releases the "command" keyboard layer. const close = ()=>{ diff --git a/packages/cli/src/components/dialogs/mcp-dialog.tsx b/packages/cli/src/components/dialogs/mcp-dialog.tsx index 5b03369..5b0da33 100644 --- a/packages/cli/src/components/dialogs/mcp-dialog.tsx +++ b/packages/cli/src/components/dialogs/mcp-dialog.tsx @@ -145,7 +145,7 @@ export function McpDialogContent() { const interval = setInterval(() => bump(), 1000); return () => clearInterval(interval); - }, [servers, busyServers, bump]); + }, [servers, bump]); const handleReconnect = useCallback( async (server: McpServerStatus) => { diff --git a/packages/cli/src/components/input-bar.tsx b/packages/cli/src/components/input-bar.tsx index ad4e365..ee066ba 100644 --- a/packages/cli/src/components/input-bar.tsx +++ b/packages/cli/src/components/input-bar.tsx @@ -1,6 +1,6 @@ import { readdir } from "node:fs/promises"; import { isAbsolute, relative, resolve } from "node:path"; -import { useRef, useState, useCallback, useEffect, type RefObject } from "react"; +import { useRef, useState, useCallback, useEffect, useMemo, type RefObject } from "react"; import { TextAttributes } from "@opentui/core"; import type { TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"; import { useKeyboard, useRenderer } from "@opentui/react"; @@ -381,13 +381,6 @@ export function InputBar({ syncMentionMenu(nextText, textarea.cursorOffset); }, [mentionCandidates, syncMentionMenu]); - const handleTextareaCursorChange = useCallback(() => { - const textarea = textareaRef.current; - if (!textarea) return; - - syncMentionMenu(textarea.plainText, textarea.cursorOffset); - }, [syncMentionMenu]); - const handleCommand = useCallback(( command: Command | undefined ) => { @@ -412,7 +405,7 @@ export function InputBar({ (message) => toast.show({ variant: "error", message }), ); } else { - textarea.insertText(command.value + " "); + textarea.insertText(`${command.value} `); } }, [renderer, toast, dialog, navigate, mode, model, setMode, setModel]); @@ -507,12 +500,20 @@ export function InputBar({ return () => setResponder("base", null); }, [disabled, setResponder]); + const composerRestore = useMemo( + () => + composerRestoreText != null + ? { text: composerRestoreText, token: composerRestoreToken } + : null, + [composerRestoreText, composerRestoreToken], + ); + useEffect(() => { - if (!composerRestoreText) return; + if (!composerRestore) return; const textarea = textareaRef.current; if (!textarea) return; - textarea.setText(composerRestoreText); - }, [composerRestoreText, composerRestoreToken]); + textarea.setText(composerRestore.text); + }, [composerRestore]); useKeyboard((key) => { if (disabled) return; diff --git a/packages/cli/src/components/messages/bot-message.tsx b/packages/cli/src/components/messages/bot-message.tsx index 08fb09b..ae81675 100644 --- a/packages/cli/src/components/messages/bot-message.tsx +++ b/packages/cli/src/components/messages/bot-message.tsx @@ -10,6 +10,7 @@ import { shouldShowDurationInFooter, shouldShowGeneratingInFooter, } from "../../lib/bot-message-footer"; +import { groupConsecutiveParts, partRenderKey } from "../../lib/bot-message-parts"; import type { LanguageModelUsage } from "ai"; import prettyMs from "pretty-ms"; import { EmptyBorder } from "../border"; @@ -86,32 +87,6 @@ function formatBashToolDisplay(input: unknown): BashToolDisplay | null { return { command: record.command, description }; } -type PartGroup = { - type: ClientMessagePart["type"]; - parts: ClientMessagePart[]; - key: string; -}; - -/** Merge adjacent parts of the same type so reasoning/tool blocks stack cleanly. */ -function groupConsecutiveParts(parts: ClientMessagePart[]): PartGroup[] { - const groups: PartGroup[] = []; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]!; - const lastGroup = groups[groups.length - 1]; - - if (lastGroup && lastGroup.type === part.type) { - lastGroup.parts.push(part); - } else { - const key = - isToolPart(part) ? `group-tc-${part.toolCallId}` : `group-${part.type}-${i}`; - groups.push({ type: part.type, parts: [part], key }); - } - } - - return groups; -}; - export function BotMessage({ parts, model, @@ -139,11 +114,11 @@ export function BotMessage({ {groupConsecutiveParts(parts).map((group, i) => ( - {group.parts.map((part, j) => { + {group.parts.map((part, partIndex) => { if (part.type === "reasoning") { return ( + {part.text} ); diff --git a/packages/cli/src/hooks/use-chat.ts b/packages/cli/src/hooks/use-chat.ts index fedecff..ff9b827 100644 --- a/packages/cli/src/hooks/use-chat.ts +++ b/packages/cli/src/hooks/use-chat.ts @@ -20,6 +20,7 @@ import { useMemo, useCallback, useRef, useEffect, useState } from "react"; import { useChat as useAiChat } from "@ai-sdk/react"; import { DefaultChatTransport, + type ChatTransport, type InferUITools, lastAssistantMessageIsCompleteWithToolCalls, type LanguageModelUsage, @@ -176,11 +177,11 @@ export function useChat( const transport = useMemo(() => { // BYOK: inference + tool schema merge run in-process; MCP execution stays in onToolCall below. if (isLocalMode()) { - return new LocalChatTransport({ + return new LocalChatTransport({ resolveModel: resolveChatModel, getMcpManager, buildSystemPrompt, - }); + }) as ChatTransport; } const chatFetch = (async (input, init) => { @@ -403,7 +404,7 @@ export function useChat( if (pending.length === 0) return; chat.setMessages((msgs) => finalizeInterruptedAssistant(msgs)); - }, [turnInterrupted, chat.status, chat.messages, chat.setMessages]); + }, [turnInterrupted, chat.messages, chat.setMessages]); const interrupt = useCallback(() => { turnInterruptedRef.current = true; @@ -464,7 +465,7 @@ export function useChat( await chat.regenerate({ messageId: lastUser.id }); } }, - [chat.messages, chat.regenerate, chat.setMessages, chat.status, chat.clearError], + [chat], ); const resumeStream = useCallback(async () => { @@ -476,7 +477,7 @@ export function useChat( if (pruned.length === chat.messages.length) return; chat.setMessages(pruned); - }, [chat.error, chat.status, chat.messages, chat.setMessages]); + }, [chat.messages, chat.setMessages]); const submit = useCallback( (params: { userText: string; mode: ModeType; model: SupportedChatModelId }) => { diff --git a/packages/cli/src/lib/bot-message-parts.test.ts b/packages/cli/src/lib/bot-message-parts.test.ts new file mode 100644 index 0000000..4567d36 --- /dev/null +++ b/packages/cli/src/lib/bot-message-parts.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { groupConsecutiveParts, partRenderKey } from "./bot-message-parts"; + +describe("groupConsecutiveParts", () => { + test("merges adjacent reasoning parts into one group", () => { + const groups = groupConsecutiveParts([ + { type: "reasoning", text: "a" }, + { type: "reasoning", text: "b" }, + { type: "text", text: "answer" }, + ] as never); + + expect(groups).toHaveLength(2); + expect(groups[0]?.type).toBe("reasoning"); + expect(groups[0]?.parts).toHaveLength(2); + expect(groups[1]?.type).toBe("text"); + }); +}); + +describe("partRenderKey", () => { + test("stays unique when duplicate text appears in the same group", () => { + const groupKey = "group-reasoning-0"; + const keys = [0, 1].map((index) => partRenderKey(groupKey, "reasoning", index)); + + expect(new Set(keys).size).toBe(2); + expect(keys).toEqual(["group-reasoning-0-reasoning-0", "group-reasoning-0-reasoning-1"]); + }); +}); diff --git a/packages/cli/src/lib/bot-message-parts.ts b/packages/cli/src/lib/bot-message-parts.ts new file mode 100644 index 0000000..e513e62 --- /dev/null +++ b/packages/cli/src/lib/bot-message-parts.ts @@ -0,0 +1,40 @@ +import type { Message } from "../hooks/use-chat"; + +type ClientMessagePart = Message["parts"][number]; + +export type PartGroup = { + type: ClientMessagePart["type"]; + parts: ClientMessagePart[]; + key: string; +}; + +function isToolPart(part: ClientMessagePart): boolean { + return part.type === "dynamic-tool" || part.type.startsWith("tool-"); +} + +/** Merge adjacent parts of the same type so reasoning/tool blocks stack cleanly. */ +export function groupConsecutiveParts(parts: ClientMessagePart[]): PartGroup[] { + const groups: PartGroup[] = []; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]!; + const lastGroup = groups[groups.length - 1]; + + if (lastGroup && lastGroup.type === part.type) { + lastGroup.parts.push(part); + } else { + const key = + isToolPart(part) && "toolCallId" in part + ? `group-tc-${part.toolCallId}` + : `group-${part.type}-${i}`; + groups.push({ type: part.type, parts: [part], key }); + } + } + + return groups; +} + +/** Stable React key for a part inside a grouped render batch. */ +export function partRenderKey(groupKey: string, partType: string, index: number): string { + return `${groupKey}-${partType}-${index}`; +} diff --git a/packages/cli/src/lib/hydrate-message-parts.ts b/packages/cli/src/lib/hydrate-message-parts.ts deleted file mode 100644 index c3a7052..0000000 --- a/packages/cli/src/lib/hydrate-message-parts.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { messagePartsSchema } from "@mocode/shared"; -import type { ClientMessagePart } from "../hooks/use-chat"; - -/** Hydrates persisted DB parts; falls back to plain text content for legacy rows. */ -export function hydrateClientParts( - parts: unknown | null, - content: string, -): ClientMessagePart[] { - const parsedParts = parts === null ? null : messagePartsSchema.safeParse(parts); - - if (parsedParts?.success) { - return parsedParts.data.map((part) => - part.type === "tool-call" || part.type === "tool_call" - ? { - type: "tool-call", - id: part.id, - name: part.name, - args: part.args, - ...(part.result !== undefined ? { result: part.result } : {}), - status: "done" as const, - } - : part, - ); - } - - return content ? [{ type: "text", text: content }] : []; -} diff --git a/packages/cli/src/lib/local-chat-transport.test.ts b/packages/cli/src/lib/local-chat-transport.test.ts index 301ff06..2198b20 100644 --- a/packages/cli/src/lib/local-chat-transport.test.ts +++ b/packages/cli/src/lib/local-chat-transport.test.ts @@ -138,7 +138,7 @@ describe("reconnectToStream (D-12 BYOK)", () => { buildSystemPrompt: () => "test system prompt", }); - const stream = await transport.sendMessages({ + const _stream = await transport.sendMessages({ trigger: "submit-message", chatId: "session-reconnect", messageId: undefined, diff --git a/packages/cli/src/lib/local-chat-transport.ts b/packages/cli/src/lib/local-chat-transport.ts index 6f3e180..00755c2 100644 --- a/packages/cli/src/lib/local-chat-transport.ts +++ b/packages/cli/src/lib/local-chat-transport.ts @@ -21,7 +21,7 @@ import { type UIMessage, type UIMessageChunk, } from "ai"; -import { Mode, type ModeType } from "@mocode/shared"; +import { Mode, type ModeType, type SupportedChatModelId } from "@mocode/shared"; import { loadMergedMcpConfig } from "../mcp/config"; import type { McpManager } from "../mcp/manager"; import { @@ -33,6 +33,28 @@ import type { ResolvedModel } from "./local-model"; import { formatChatStreamError } from "./stream-error"; import { hasVisibleAssistantContent } from "@mocode/shared"; +type LocalChatMetadata = { + mode?: ModeType; + model?: SupportedChatModelId | string; + durationMs?: number; + usage?: LanguageModelUsage; +}; + +function readMetadata(message: UIMessage): LocalChatMetadata | undefined { + return message.metadata as LocalChatMetadata | undefined; +} + +function findLastMetadataValue( + messages: UIMessage[], + key: K, +): LocalChatMetadata[K] | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const value = readMetadata(messages[i]!)?.[key]; + if (value !== undefined) return value; + } + return undefined; +} + /** Last user-visible text in the outgoing batch — used for MCP routing heuristics. */ function lastUserText(messages: UIMessage[]): string { const message = messages.findLast((entry) => entry.role === "user"); @@ -82,7 +104,7 @@ export type LocalChatTransportOptions = { * In-process transport for BYOK `--local` sessions. * Implements AI SDK `ChatTransport` so `useChat` can share the same hook for SaaS and local. */ -export class LocalChatTransport +export class LocalChatTransport implements ChatTransport { private activeStream: { @@ -103,11 +125,8 @@ export class LocalChatTransport }: Parameters["sendMessages"]>[0]): Promise< ReadableStream > { - const mode = - messages.findLast((message) => message.metadata?.mode)?.metadata?.mode ?? - Mode.BUILD; - const modelId = messages.findLast((message) => message.metadata?.model)?.metadata - ?.model; + const mode = findLastMetadataValue(messages, "mode") ?? Mode.BUILD; + const modelId = findLastMetadataValue(messages, "model"); if (!modelId || typeof modelId !== "string") { throw new Error("Missing model in message metadata"); @@ -130,7 +149,7 @@ export class LocalChatTransport const messagesForValidation = stripIncompleteAssistantMessages(messages); const nextMessages = await validateUIMessages({ messages: messagesForValidation, - tools, + tools: tools as Parameters>[0]["tools"], }); const modelMessages = await convertToModelMessages(nextMessages, { tools }); @@ -154,7 +173,7 @@ export class LocalChatTransport onError: formatChatStreamError, messageMetadata({ part }) { if (part.type === "start") { - return { mode, model: modelId }; + return { mode, model: modelId } as never; } if (part.type !== "finish") return undefined; @@ -164,7 +183,7 @@ export class LocalChatTransport model: modelId, durationMs: Date.now() - startTime, ...(completedUsage ? { usage: completedUsage } : {}), - }; + } as never; }, async onFinish(event) { if (self.activeStream?.stream === stream) self.activeStream = null; diff --git a/packages/cli/src/lib/mcp-tool-call.ts b/packages/cli/src/lib/mcp-tool-call.ts index d60cee4..8be67ba 100644 --- a/packages/cli/src/lib/mcp-tool-call.ts +++ b/packages/cli/src/lib/mcp-tool-call.ts @@ -19,6 +19,7 @@ import { normalizeMcpToolName, parseMcpToolName, requiresMcpWriteApproval, + type McpToolConfigOverride, } from "../mcp/heuristics"; import type { DialogContextValue } from "../providers/dialog"; import type { McpApprovalVerdict } from "./mcp-approval-ui"; @@ -80,7 +81,7 @@ export async function executeMcpToolCall( addToolOutput, } = deps; - let toolConfig; + let toolConfig: McpToolConfigOverride | undefined; try { const config = loadMergedMcpConfig(process.cwd()); toolConfig = config.mcpServers[server]?.tools?.[tool]; diff --git a/packages/cli/src/lib/stream-interrupt-resume.test.ts b/packages/cli/src/lib/stream-interrupt-resume.test.ts index 42d24e6..de6d26a 100644 --- a/packages/cli/src/lib/stream-interrupt-resume.test.ts +++ b/packages/cli/src/lib/stream-interrupt-resume.test.ts @@ -118,4 +118,27 @@ describe("resolveAutoResumeRequest", () => { expect(request).toBeNull(); }); + + test("falls back when last user metadata has an unsupported model id", () => { + const request = resolveAutoResumeRequest({ + messages: [ + { + id: "u1", + role: "user", + parts: [{ type: "text", text: "hello" }], + metadata: { mode: Mode.PLAN, model: "retired-model-v99" }, + }, + ] as never, + status: "ready", + hasAutoResumed: false, + initialPromptPending: false, + fallbackMode: Mode.BUILD, + fallbackModel: "gpt-5.4", + }); + + expect(request).toEqual({ + mode: Mode.PLAN, + model: "gpt-5.4", + }); + }); }); diff --git a/packages/cli/src/lib/stream-interrupt.ts b/packages/cli/src/lib/stream-interrupt.ts index a49dcc4..5cbc7d3 100644 --- a/packages/cli/src/lib/stream-interrupt.ts +++ b/packages/cli/src/lib/stream-interrupt.ts @@ -1,5 +1,10 @@ import type { UIMessage } from "ai"; -import { hasVisibleAssistantContent, type ModeType } from "@mocode/shared"; +import { + findSupportedChatModel, + hasVisibleAssistantContent, + type ModeType, + type SupportedChatModelId, +} from "@mocode/shared"; import { stripIncompleteAssistantMessages as stripFromTransport } from "./local-chat-transport"; export const INTERRUPTED_TOOL_ERROR_TEXT = "Interrupted by user"; @@ -58,6 +63,7 @@ export function finalizeInterruptedAssistant( if (lastIndex === -1) return messages; const last = messages[lastIndex]; + if (last === undefined) return messages; if (!Array.isArray(last.parts) || last.parts.length === 0) return messages; const updated = { @@ -87,7 +93,8 @@ export function detectResumeEligibility( if (chatStatus === "streaming" || chatStatus === "submitted") return "none"; if (messages.length === 0) return "none"; - const last = messages[messages.length - 1]; + const last = messages.at(-1); + if (!last) return "none"; if (last.role === "user") return "user-only"; if (last.role === "assistant" && hasVisibleAssistantContent(last)) { return "partial-assistant"; @@ -122,17 +129,21 @@ export function shouldSkipInterruptedToolOutput( /** Trim transcript for /resume — only the last user message gets new mode/model metadata. */ export function trimMessagesForRegenerate( messages: UI_MESSAGE[], - params: { mode: ModeType; model: string }, + params: { mode: ModeType; model: SupportedChatModelId }, ): UI_MESSAGE[] | null { const lastUser = messages.findLast((message) => message.role === "user"); if (!lastUser) return null; const userIndex = messages.findIndex((message) => message.id === lastUser.id); const trimmed = messages.slice(0, userIndex + 1); + const priorMetadata = + lastUser.metadata && typeof lastUser.metadata === "object" + ? (lastUser.metadata as Record) + : {}; trimmed[trimmed.length - 1] = { ...lastUser, metadata: { - ...lastUser.metadata, + ...priorMetadata, mode: params.mode, model: params.model, }, @@ -140,6 +151,16 @@ export function trimMessagesForRegenerate( return trimmed; } +function resolveStoredModel( + stored: unknown, + fallback: SupportedChatModelId, +): SupportedChatModelId { + if (typeof stored === "string" && findSupportedChatModel(stored) != null) { + return stored as SupportedChatModelId; + } + return fallback; +} + /** Derive auto-resume params from normalized chat state (not raw initialMessages). */ export function resolveAutoResumeRequest(params: { messages: UIMessage[]; @@ -147,8 +168,8 @@ export function resolveAutoResumeRequest(params: { hasAutoResumed: boolean; initialPromptPending: boolean; fallbackMode: ModeType; - fallbackModel: string; -}): { mode: ModeType; model: string } | null { + fallbackModel: SupportedChatModelId; +}): { mode: ModeType; model: SupportedChatModelId } | null { const normalized = stripIncompleteAssistantMessages(params.messages); const eligibility = detectResumeEligibility(normalized, params.status); const shouldAuto = shouldAutoResumeOnMount({ @@ -159,8 +180,9 @@ export function resolveAutoResumeRequest(params: { if (!shouldAuto) return null; const lastUser = normalized.findLast((message) => message.role === "user"); + const lastUserMetadata = lastUser?.metadata as { mode?: ModeType; model?: unknown } | undefined; return { - mode: (lastUser?.metadata?.mode as ModeType | undefined) ?? params.fallbackMode, - model: (lastUser?.metadata?.model as string | undefined) ?? params.fallbackModel, + mode: lastUserMetadata?.mode ?? params.fallbackMode, + model: resolveStoredModel(lastUserMetadata?.model, params.fallbackModel), }; } diff --git a/packages/cli/src/mcp/tools.ts b/packages/cli/src/mcp/tools.ts index b6ae48b..ea12132 100644 --- a/packages/cli/src/mcp/tools.ts +++ b/packages/cli/src/mcp/tools.ts @@ -63,7 +63,7 @@ function jsonSchemaToInputSchema(schema: unknown) { export function mcpToolsToDynamicTools( serverName: string, tools: Array, - serverConfig?: McpServerEntry, + _serverConfig?: McpServerEntry, ): ToolSet { const result: ToolSet = {}; @@ -72,7 +72,7 @@ export function mcpToolsToDynamicTools( result[fullName] = dynamicTool({ description: tool.description ?? `MCP tool ${tool.name} from ${serverName}`, inputSchema: jsonSchemaToInputSchema(tool.inputSchema), - }); + } as never); } return result; diff --git a/packages/cli/src/providers/dialog/index.tsx b/packages/cli/src/providers/dialog/index.tsx index 20e7617..e9bb54e 100644 --- a/packages/cli/src/providers/dialog/index.tsx +++ b/packages/cli/src/providers/dialog/index.tsx @@ -54,7 +54,7 @@ export function DialogProvider({ children }: DialogProviderProps) { close(); return true; }); - },[close,pop]); + },[close, pop, push]); const value: DialogContextValue = useMemo( () => ({ diff --git a/packages/cli/src/providers/keyboard-layer/index.tsx b/packages/cli/src/providers/keyboard-layer/index.tsx index 32e514e..b1a89bd 100644 --- a/packages/cli/src/providers/keyboard-layer/index.tsx +++ b/packages/cli/src/providers/keyboard-layer/index.tsx @@ -73,7 +73,7 @@ export function KeyboardLayerProvider({ children }: { children: React.ReactNode for(let i= currentStack.length - 1; i >= 0; i--){ const layerId = currentStack[i]!; const responder = responders.current.get(layerId); - if(responder && responder()){ + if(responder?.()){ return; } } diff --git a/packages/cli/src/providers/theme/index.tsx b/packages/cli/src/providers/theme/index.tsx index 26fc1bb..c7fbf7c 100644 --- a/packages/cli/src/providers/theme/index.tsx +++ b/packages/cli/src/providers/theme/index.tsx @@ -20,7 +20,7 @@ function getInitialTheme(): Theme { const savedTheme = THEMES.find((t) => t.name === preferences.themeName); return savedTheme ?? DEFAULT_THEME; - }catch(error){ + }catch{ return DEFAULT_THEME; } } diff --git a/packages/cli/src/providers/toast/index.tsx b/packages/cli/src/providers/toast/index.tsx index 6730771..b1ee6d6 100644 --- a/packages/cli/src/providers/toast/index.tsx +++ b/packages/cli/src/providers/toast/index.tsx @@ -77,7 +77,7 @@ type ToastProps = { } function Toast({ currentToast }: ToastProps) { - const {width,height} = useTerminalDimensions(); + const {width} = useTerminalDimensions(); const { colors } = useTheme(); if(!currentToast) return null; diff --git a/packages/cli/src/screens/new-session.tsx b/packages/cli/src/screens/new-session.tsx index 6bfa7d6..ed3f952 100644 --- a/packages/cli/src/screens/new-session.tsx +++ b/packages/cli/src/screens/new-session.tsx @@ -7,7 +7,7 @@ import { useToast } from "../providers/toast"; import { useDialog } from "../providers/dialog"; import { apiClient } from "../lib/api-client"; import { getErrorMessage } from "../lib/http-errors"; -import { findSupportedChatModel, Mode, modeSchema } from "@mocode/shared"; +import { findSupportedChatModel, modeSchema } from "@mocode/shared"; import { isLocalMode } from "../lib/local-mode"; import { createLocalSession } from "../lib/local-sessions"; import { hasRequiredKeys } from "../lib/keys"; diff --git a/packages/cli/src/screens/session.tsx b/packages/cli/src/screens/session.tsx index ea2b82a..b135413 100644 --- a/packages/cli/src/screens/session.tsx +++ b/packages/cli/src/screens/session.tsx @@ -30,7 +30,7 @@ import { SessionChatActionsProvider, useRegisterSessionChatActions, } from "../providers/session-chat-actions"; -import { scrollToBottomAfterLayout } from "../utils/list-scroll-nav"; +import { scrollToBottomAfterLayout, streamingTranscriptScrollSignal } from "../utils/list-scroll-nav"; /** * Phase 11 session screen. @@ -129,7 +129,7 @@ function SessionChat({ if (statusRef.current !== "submitted" && statusRef.current !== "streaming") return; void abortRef.current(); }; - }, [session.id]); + }, []); useEffect(() => { if (hasAutoResumedRef.current) return; @@ -198,14 +198,16 @@ function SessionChat({ const pendingTranscriptReply = isLoading && lastMessage?.role === "user"; const pendingMode = lastMessage?.metadata?.mode ?? mode; const pendingModel = lastMessage?.metadata?.model ?? model; + const transcriptScrollSignal = streamingTranscriptScrollSignal(isLoading, messages); + // biome-ignore lint/correctness/useExhaustiveDependencies: transcriptScrollSignal re-scrolls during token streaming while isLoading stays true useLayoutEffect(() => { if (!isLoading) return; const scrollbox = transcriptScrollRef.current; if (!scrollbox) return; return scrollToBottomAfterLayout(scrollbox); - }, [messages.length, isLoading, status]); + }, [isLoading, transcriptScrollSignal]); return ( { + test("returns 0 when not loading", () => { + expect( + streamingTranscriptScrollSignal(false, [{ parts: [{ type: "text", text: "hello" }] }]), + ).toBe(0); + }); + + test("grows when tail text lengthens without changing message count", () => { + const oneToken = streamingTranscriptScrollSignal(true, [ + { parts: [{ type: "text", text: "hi" }] }, + ]); + const moreTokens = streamingTranscriptScrollSignal(true, [ + { parts: [{ type: "text", text: "hello world" }] }, + ]); + + expect(moreTokens).toBeGreaterThan(oneToken); + }); + + test("increments when a new message is appended", () => { + const single = streamingTranscriptScrollSignal(true, [ + { parts: [{ type: "text", text: "a" }] }, + ]); + const pair = streamingTranscriptScrollSignal(true, [ + { parts: [{ type: "text", text: "a" }] }, + { parts: [{ type: "text", text: "b" }] }, + ]); + + expect(pair).toBeGreaterThan(single); + }); +}); diff --git a/packages/cli/src/utils/list-scroll-nav.ts b/packages/cli/src/utils/list-scroll-nav.ts index 6bee85f..034b61f 100644 --- a/packages/cli/src/utils/list-scroll-nav.ts +++ b/packages/cli/src/utils/list-scroll-nav.ts @@ -67,3 +67,31 @@ export function scrollToBottomAfterLayout(scrollbox: ScrollboxLike): () => void export function visibleItemCount(itemCount: number, maxVisible: number): number { return Math.min(itemCount, maxVisible); } + +type TranscriptMessage = { + parts?: unknown; +}; + +/** + * Monotonic signal for follow-scroll while streaming — grows when the tail message + * gains text even if `messages.length` and `isLoading` stay constant. + */ +export function streamingTranscriptScrollSignal( + isLoading: boolean, + messages: ReadonlyArray, +): number { + if (!isLoading) return 0; + + const last = messages.at(-1); + if (!last || !Array.isArray(last.parts)) return messages.length; + + let signal = messages.length; + for (const part of last.parts) { + if (part && typeof part === "object" && "text" in part && typeof part.text === "string") { + signal += part.text.length; + } else { + signal += 1; + } + } + return signal; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 3a69d02..a3baa2c 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx", - "jsxImportSource": "@opentui/react" - } -} \ No newline at end of file + "jsxImportSource": "@opentui/react", + "types": ["bun"] + }, + "exclude": ["**/*.test.ts"] +} diff --git a/packages/server/src/lib/stream-interrupt.ts b/packages/server/src/lib/stream-interrupt.ts index 68cb558..15ef86d 100644 --- a/packages/server/src/lib/stream-interrupt.ts +++ b/packages/server/src/lib/stream-interrupt.ts @@ -44,6 +44,7 @@ export function finalizeInterruptedAssistant( if (lastIndex === -1) return messages; const last = messages[lastIndex]; + if (last === undefined) return messages; if (!Array.isArray(last.parts) || last.parts.length === 0) return messages; const updated = { diff --git a/packages/server/src/routes/chat.ts b/packages/server/src/routes/chat.ts index a174c0d..57629d3 100644 --- a/packages/server/src/routes/chat.ts +++ b/packages/server/src/routes/chat.ts @@ -200,7 +200,7 @@ const app = new Hono() }, consumeSseStream: ({ stream }) => { registerStreamBuffer(id, userId, replayBuffer); - replayBuffer.ingest(stream); + replayBuffer.ingest(stream.pipeThrough(new TextEncoderStream())); }, async onFinish(event) { try { @@ -216,7 +216,8 @@ const app = new Hono() isAborted: event.isAborted, messagesToPersist, responseMessage: event.responseMessage, - hasPendingToolCalls, + hasPendingToolCalls: (message) => + hasPendingToolCalls(message as MocodeUIMessage), }) ) { return; diff --git a/packages/server/src/system-prompt.test.ts b/packages/server/src/system-prompt.test.ts index 8745a07..9ab8b93 100644 --- a/packages/server/src/system-prompt.test.ts +++ b/packages/server/src/system-prompt.test.ts @@ -77,7 +77,7 @@ describe("buildSystemPrompt", () => { test("does not list bash in available tools", () => { const toolsSection = prompt.match( - /# Available Tools \(PLAN Mode\)([\s\S]*?)(?=\n \*\*Tool Rules:\*\*|\n # )/, + /# Available Tools \(PLAN Mode\)([\s\S]*?)(?=\n {2}\*\*Tool Rules:\*\*|\n {2}# )/, )?.[1]; expect(toolsSection).toBeDefined(); expect(toolsSection!).not.toMatch(/\bbash\b/); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 07ed128..b800608 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -4,5 +4,5 @@ "types": ["bun"] }, "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] + "exclude": ["dist", "node_modules", "**/*.test.ts"] } \ No newline at end of file diff --git a/packages/shared/src/mcp-tools.ts b/packages/shared/src/mcp-tools.ts index 8fd7391..145337a 100644 --- a/packages/shared/src/mcp-tools.ts +++ b/packages/shared/src/mcp-tools.ts @@ -41,7 +41,7 @@ export function deserializeMcpToolsToDynamic(mcpTools?: SerializedMcpTool[]): To result[tool.name] = dynamicTool({ description: tool.description ?? tool.name, inputSchema: jsonSchemaToInputSchema(tool.inputSchema), - }); + } as never); } return result; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 8ff1666..08f1815 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,3 +1,4 @@ { - "extends": "../../tsconfig.base.json", -} \ No newline at end of file + "extends": "../../tsconfig.base.json", + "exclude": ["**/*.test.ts"] +}