diff --git a/.changeset/bound-long-session-memory.md b/.changeset/bound-long-session-memory.md new file mode 100644 index 000000000000..69f94d4af170 --- /dev/null +++ b/.changeset/bound-long-session-memory.md @@ -0,0 +1,5 @@ +--- +"@reddb-io/redcode": patch +--- + +Stop long sessions from growing without bound and being OOM-killed: turn diffs no longer embed a whole copy of every large file they touch, concurrent turn summaries collapse into one run instead of hydrating the session several times over, edit tool metadata carries diagnostics only for the files it touched, and the TUI mirrors a session's messages only once something asks for that session. Also documents installing and upgrading with mise. diff --git a/.changeset/lsp-cap-npm-deadline-trim.md b/.changeset/lsp-cap-npm-deadline-trim.md new file mode 100644 index 000000000000..b7c676b89573 --- /dev/null +++ b/.changeset/lsp-cap-npm-deadline-trim.md @@ -0,0 +1,5 @@ +--- +"@reddb-io/redcode": patch +--- + +Cap how many language servers run at once (`REDCODE_LSP_MAX_CLIENTS`, default 8) so a monorepo with per-package linter configs stops spawning one server per package, put a deadline on the npm install that plugin loading holds a cross-process lock across, and trim the whitespace around a typed message so a trailing newline is not part of what you said and a blank input is not sent at all. diff --git a/.changeset/self-healing-startup.md b/.changeset/self-healing-startup.md new file mode 100644 index 000000000000..31496b9d99eb --- /dev/null +++ b/.changeset/self-healing-startup.md @@ -0,0 +1,5 @@ +--- +"@reddb-io/redcode": patch +--- + +Recover from stuck states without the user having to diagnose them: a worker thread that throws or dies now fails the waiting call instead of freezing the UI, a lock whose owning process is gone is taken over immediately rather than after a minute, startup no longer waits forever on a stalled home directory, a piped stdin that never closes, a hung git, or an unbounded musl probe, and language servers close documents past an open-file cap instead of holding every file the session ever touched. diff --git a/README.md b/README.md index 8ce3c572b1a2..de74128b8e7c 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,34 @@ Bun, pnpm, and Yarn work too. The install resolves one native package for your p variants where the architecture needs them. Each package contains `redcode` and the matching `redcode-rpc-sidecar` companion. +### With mise + +mise installs the release binary straight from GitHub, no Node required. This is what +[red-dev](https://github.com/reddb-io/red-dev) sets up, so a machine provisioned by it already has +Redcode this way. + +```bash +mise use -g github:reddb-io/redcode@latest +redcode +``` + +Upgrade the same install with: + +```bash +mise upgrade github:reddb-io/redcode +``` + +Two notes worth knowing: + +- Pin `@latest` rather than an exact version. `mise upgrade` keeps whatever range the tool was + installed with, so an exact pin never moves on its own. +- If an upgrade reports success but `redcode --version` does not change, mise served a cached + version list. Run `mise cache clear github:reddb-io/redcode` and upgrade again. + +Keep one installation method per machine. An npm global and a mise install can both provide +`redcode`, and then `$PATH` order decides which one runs — updating the one you are not running +looks like an update that did nothing. `which redcode` tells you which copy is live. + There is no beta channel, no container image, no desktop build, no package-manager tap, and no hosted deployment. See [What Ships And What Doesn't](#what-ships-and-what-doesnt) for why that is enforced rather than merely intended. diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 83eb5cc8bfca..708a08191e3d 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -45,6 +45,8 @@ export const Flag = { REDCODE_MODELS_URL: process.env["REDCODE_MODELS_URL"], REDCODE_MODELS_PATH: process.env["REDCODE_MODELS_PATH"], REDCODE_DB: process.env["REDCODE_DB"], + REDCODE_LSP_OPEN_FILE_LIMIT: process.env["REDCODE_LSP_OPEN_FILE_LIMIT"], + REDCODE_LSP_MAX_CLIENTS: process.env["REDCODE_LSP_MAX_CLIENTS"], REDCODE_WORKSPACE_ID: process.env["REDCODE_WORKSPACE_ID"], REDCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("REDCODE_EXPERIMENTAL_WORKSPACES"), diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index ff8782958d66..5842014d38fb 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -37,14 +37,33 @@ export const Path = paths Flock.setGlobal({ state }) -await Promise.all([ - fs.mkdir(Path.data, { recursive: true }), - fs.mkdir(Path.config, { recursive: true }), - fs.mkdir(Path.state, { recursive: true }), - fs.mkdir(Path.tmp, { recursive: true }), - fs.mkdir(Path.log, { recursive: true }), - fs.mkdir(Path.bin, { recursive: true }), - fs.mkdir(Path.repos, { recursive: true }), +// This is module-level, so it runs before argv is even parsed — `redcode --version` +// included. When the home directory sits on a stalled mount (a dropped network drive, a +// Windows filesystem seen through WSL) these calls block with nothing printed and no way +// to tell what is wrong. Say what is stuck and let the process continue: whatever needs a +// directory will fail with its own error, which is far easier to act on than a freeze. +const MKDIR_DEADLINE_MS = 10_000 + +await Promise.race([ + Promise.all([ + fs.mkdir(Path.data, { recursive: true }), + fs.mkdir(Path.config, { recursive: true }), + fs.mkdir(Path.state, { recursive: true }), + fs.mkdir(Path.tmp, { recursive: true }), + fs.mkdir(Path.log, { recursive: true }), + fs.mkdir(Path.bin, { recursive: true }), + fs.mkdir(Path.repos, { recursive: true }), + ]), + new Promise((resolve) => { + const timer = setTimeout(() => { + process.stderr.write( + `redcode: still waiting on ${redcodeHome} after ${MKDIR_DEADLINE_MS / 1000}s — ` + + `the filesystem holding it may be unavailable. Set REDCODE_TEST_HOME or HOME to a local path.\n`, + ) + resolve() + }, MKDIR_DEADLINE_MS) + timer.unref?.() + }), ]) export class Service extends Context.Service()("@redcode/Global") {} diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 875db328c09d..fc30df9251b1 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -69,6 +69,9 @@ interface ArboristTree { edgesOut: Map } +// Generous: a cold install of a large plugin over a slow link is minutes, not seconds. +const REIFY_DEADLINE = "5 minutes" + const layer = Layer.effect( Service, Effect.gen(function* () { @@ -105,7 +108,22 @@ const layer = Layer.effect( add, dir: input.dir, }), - }) as Effect.Effect + }).pipe( + // The install lock is held across this, and the heartbeat keeps refreshing while it + // runs, so a registry connection that never answers looks alive forever: the stale + // breaker never fires and every other process waits out the full lock timeout. A + // deadline turns that into a failure this caller can report and retry. + Effect.timeout(REIFY_DEADLINE), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new InstallFailedError({ + cause: new Error(`npm install in ${input.dir} exceeded ${REIFY_DEADLINE}`), + add, + dir: input.dir, + }), + ), + ), + ) as Effect.Effect }).pipe( Effect.withSpan("Npm.reify", { attributes: input, diff --git a/packages/core/src/util/flock.ts b/packages/core/src/util/flock.ts index 958bd9fd1da6..ff5af2df686f 100644 --- a/packages/core/src/util/flock.ts +++ b/packages/core/src/util/flock.ts @@ -124,9 +124,45 @@ export namespace Flock { } } + async function readMeta(metaPath: string) { + try { + const parsed: unknown = JSON.parse(await readFile(metaPath, "utf8")) + if (!parsed || typeof parsed !== "object") return + const { pid, hostname } = parsed as { pid?: unknown; hostname?: unknown } + if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return + if (typeof hostname !== "string") return + return { pid, hostname } + } catch { + return + } + } + + // Signal 0 tests for existence without delivering anything. EPERM means the process is + // alive and owned by someone else, which still counts as alive. + function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch (err) { + return code(err) === "EPERM" + } + } + + // A lock whose owner is gone is stale now, not staleMs from now. Waiting out the + // heartbeat window after a crash or a kill is a minute of a frozen app for nothing. + // Only decided on this host: a pid from another machine says nothing about this one. + async function abandoned(metaPath: string) { + const meta = await readMeta(metaPath) + if (!meta) return false + if (meta.hostname !== os.hostname()) return false + if (meta.pid === process.pid) return false + return !alive(meta.pid) + } + async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) { // Stale detection allows automatic recovery after crashed owners. const now = wall() + if (await abandoned(metaPath)) return true const heartbeat = await stats(heartbeatPath) if (heartbeat) { return now - heartbeat.mtimeMs > staleMs diff --git a/packages/core/test/util/flock.test.ts b/packages/core/test/util/flock.test.ts index 6a917c03da54..7d3f6a415536 100644 --- a/packages/core/test/util/flock.test.ts +++ b/packages/core/test/util/flock.test.ts @@ -215,6 +215,58 @@ describe("util.flock", () => { expect(hit).toBe(true) }, 20_000) + test("takes over immediately when the owning process is gone, without waiting out the heartbeat", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:dead-owner" + const ready = path.join(tmp.path, "ready") + // A long stale window is exactly the case this covers: waiting it out after a crash + // is a frozen app for no reason, so recovery must come from the owner being gone. + const proc = spawnWorker({ key, dir, ready, holdMs: 60_000, staleMs: 60_000, timeoutMs: 90_000 }) + + await wait(ready, 5_000) + await stopWorker(proc) + + const started = Date.now() + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { dir, staleMs: 60_000, timeoutMs: 20_000 }, + ) + + expect(hit).toBe(true) + expect(Date.now() - started).toBeLessThan(10_000) + }, 40_000) + + test("leaves a live owner's lock alone even when its pid is recorded", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "locks") + const key = "flock:live-owner" + const ready = path.join(tmp.path, "ready") + const proc = spawnWorker({ key, dir, ready, holdMs: 3_000, staleMs: 60_000, timeoutMs: 30_000 }) + + await wait(ready, 5_000) + const meta = await readJson<{ pid: number }>(path.join(lock(dir, key), "meta.json")) + expect(meta.pid).toBeGreaterThan(0) + + // The holder is alive, so this must wait for it to finish rather than break in. + const started = Date.now() + let hit = false + await Flock.withLock( + key, + async () => { + hit = true + }, + { dir, staleMs: 60_000, timeoutMs: 20_000 }, + ) + expect(hit).toBe(true) + expect(Date.now() - started).toBeGreaterThan(1_000) + await stopWorker(proc) + }, 40_000) + test("breaks stale lock dirs when heartbeat is missing", async () => { await using tmp = await tmpdir() const dir = path.join(tmp.path, "locks") diff --git a/packages/redcode/bin/redcode b/packages/redcode/bin/redcode index 62aa8d603214..864d7617656f 100755 --- a/packages/redcode/bin/redcode +++ b/packages/redcode/bin/redcode @@ -144,7 +144,10 @@ const names = (() => { } try { - const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + // Bounded like the darwin and windows probes above. Under WSL a spawn whose PATH + // resolution crosses /mnt/c or a dropped network mount can block in uninterruptible + // IO, and this runs before anything else, so an unbounded probe hangs even `-v`. + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8", timeout: 1500 }) const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase() if (text.includes("musl")) return true } catch { diff --git a/packages/redcode/src/cli/cmd/tui.ts b/packages/redcode/src/cli/cmd/tui.ts index c39c52f5eadb..8f8369fd4ae4 100644 --- a/packages/redcode/src/cli/cmd/tui.ts +++ b/packages/redcode/src/cli/cmd/tui.ts @@ -56,8 +56,16 @@ async function target() { return new URL("../tui/worker.ts", import.meta.url) } +// A non-TTY stdin is not always a finite pipe: launched from a wrapper, an editor task or a +// Windows console shim, the child can inherit a pipe nobody ever closes, and reading it to +// EOF then blocks the TUI before its first frame with nothing on screen. Waiting briefly and +// carrying on costs a caller who really did pipe a slow prompt; hanging costs the session. +const STDIN_DEADLINE_MS = 2000 + async function input(value?: string) { - const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text() + const piped = process.stdin.isTTY + ? undefined + : await withTimeout(Bun.stdin.text(), STDIN_DEADLINE_MS, "stdin").catch(() => undefined) if (!value) return piped if (!piped) return value return piped + "\n" + value @@ -218,18 +226,28 @@ export const TuiThreadCommand = cmd({ } const cwd = Filesystem.resolve(process.cwd()) + let stopped = false const worker = new Worker(file, { env: Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ), }) const client = Rpc.client(worker) + // A worker that fails to load, or dies, posts nothing back. Without these the calls + // waiting on it stay pending and the UI shows an empty screen with no error. + worker.addEventListener("error", (event) => { + const detail = event instanceof ErrorEvent && event.message ? event.message : "worker failed to start" + client.fail(`Redcode server thread failed: ${detail}`) + }) + worker.addEventListener("close", () => { + if (stopped) return + client.fail("Redcode server thread exited unexpectedly") + }) const reload = () => { client.call("reload", undefined).catch(() => {}) } process.on("SIGUSR2", reload) - let stopped = false const stop = async () => { if (stopped) return stopped = true diff --git a/packages/redcode/src/lsp/client.ts b/packages/redcode/src/lsp/client.ts index ae5214a22121..20b90f11ff44 100644 --- a/packages/redcode/src/lsp/client.ts +++ b/packages/redcode/src/lsp/client.ts @@ -3,6 +3,7 @@ import { pathToFileURL, fileURLToPath } from "url" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" import { Process } from "@/util/process" +import { Flag } from "@reddb-io/redcode-core/flag/flag" import { LANGUAGE_EXTENSIONS } from "./language" import { Effect, Schema } from "effect" import type * as LSPServer from "./server" @@ -275,7 +276,40 @@ export async function create(input: { }) } + // Every file the agent reads or edits used to stay resident here in full, in every client + // that matches it — and a .ts file matches typescript, eslint, oxlint and biome at once. + // Over a long session that is unbounded. Documents past the cap are closed on the server + // and dropped here; the next touch reopens them, which is the same didOpen a first touch + // would have sent. + const OPEN_FILE_LIMIT = (() => { + const configured = Number(Flag.REDCODE_LSP_OPEN_FILE_LIMIT) + return Number.isInteger(configured) && configured > 0 ? configured : 200 + })() const files: Record = {} + const opened: string[] = [] + + const forget = (filePath: string) => { + delete files[filePath] + pushDiagnostics.delete(filePath) + pullDiagnostics.delete(filePath) + published.delete(filePath) + } + + const touched = async (filePath: string) => { + const at = opened.indexOf(filePath) + if (at >= 0) opened.splice(at, 1) + opened.push(filePath) + while (opened.length > OPEN_FILE_LIMIT) { + const evict = opened.shift() + if (evict === undefined || evict === filePath) continue + forget(evict) + await connection + .sendNotification("textDocument/didClose", { + textDocument: { uri: pathToFileURL(evict).href }, + }) + .catch(() => {}) + } + } // --- Diagnostic helpers --- @@ -589,6 +623,7 @@ export async function create(input: { const next = document.version + 1 files[request.path] = { version: next, text } + await touched(request.path) await connection.sendNotification("textDocument/didChange", { textDocument: { uri: pathToFileURL(request.path).href, @@ -630,6 +665,7 @@ export async function create(input: { }, }) files[request.path] = { version: 0, text } + await touched(request.path) return 0 }, }, diff --git a/packages/redcode/src/lsp/diagnostic.ts b/packages/redcode/src/lsp/diagnostic.ts index 4bc085e788e4..07087ed11dbf 100644 --- a/packages/redcode/src/lsp/diagnostic.ts +++ b/packages/redcode/src/lsp/diagnostic.ts @@ -17,6 +17,19 @@ export function pretty(diagnostic: LSPClient.Diagnostic) { return `${severity} [${line}:${col}] ${diagnostic.message}` } +// LSP.diagnostics() answers with every file every client has ever opened, which on a large +// workspace is tens of megabytes. Tool metadata is cloned, written to the durable store and +// pushed to every client on each edit, and every reader of it looks up one file — so only the +// files the tool touched belong in there. +export function pick(diagnostics: Record, files: string[]) { + const result: Record = {} + for (const file of files) { + const hit = diagnostics[file] + if (hit) result[file] = hit + } + return result +} + export function report(file: string, issues: LSPClient.Diagnostic[]) { const errors = issues.filter((item) => item.severity === 1) if (errors.length === 0) return "" diff --git a/packages/redcode/src/lsp/lsp.ts b/packages/redcode/src/lsp/lsp.ts index 03d6ebc82288..85bac503d5b6 100644 --- a/packages/redcode/src/lsp/lsp.ts +++ b/packages/redcode/src/lsp/lsp.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@reddb-io/redcode-core/effect/layer-node" +import { Flag } from "@reddb-io/redcode-core/flag/flag" import { FSUtil } from "@reddb-io/redcode-core/fs-util" import { EventV2Bridge } from "@/event-v2-bridge" import * as LSPClient from "./client" @@ -114,8 +115,50 @@ const filterExperimentalServers = (servers: Record, flag type LocInput = { file: string; line: number; character: number } +function clientKey(client: { serverID: string; root: string }) { + return `${client.serverID}\0${client.root}` +} + +/** + * Shut down the least recently used servers until the population is back under the cap. + * The one just spawned is never the victim, and a client already gone from the list (its + * process exited) is simply skipped. + */ +function evict(s: State, keep: string) { + const limit = maxClients() + while (s.clients.length > limit) { + let victim: LSPClient.Info | undefined + let oldest = Number.POSITIVE_INFINITY + for (const client of s.clients) { + const key = clientKey(client) + if (key === keep) continue + const at = s.used.get(key) ?? 0 + if (at < oldest) { + oldest = at + victim = client + } + } + if (!victim) return + const index = s.clients.indexOf(victim) + if (index === -1) return + s.clients.splice(index, 1) + s.used.delete(clientKey(victim)) + void victim.shutdown().catch(() => undefined) + } +} + +// Roots are resolved per file, and eslint, oxlint and biome all treat a package-local config +// as a root — so a monorepo can spawn one language server per package, each of them hundreds +// of megabytes, with nothing to stop it. Past this many, the least recently used one is shut +// down. It respawns on demand, which is the same spawn its first use would have paid for. +function maxClients() { + const configured = Number(Flag.REDCODE_LSP_MAX_CLIENTS) + return Number.isInteger(configured) && configured > 0 ? configured : 8 +} + interface State { clients: LSPClient.Info[] + used: Map servers: Record broken: Map unavailable: Set @@ -197,6 +240,7 @@ const layer = Layer.effect( const s: State = { clients: [], + used: new Map(), servers, broken: new Map(), unavailable: new Set(), @@ -270,6 +314,8 @@ const layer = Layer.effect( } s.clients.push(client) + s.used.set(key, Date.now()) + evict(s, key) void handle.process.exited.then((code) => { if (s.disposing) return const index = s.clients.indexOf(client) @@ -294,6 +340,7 @@ const layer = Layer.effect( const match = s.clients.find((x) => x.root === root && x.serverID === server.id) if (match) { + s.used.set(key, Date.now()) result.push(match) continue } diff --git a/packages/redcode/src/project/project.ts b/packages/redcode/src/project/project.ts index 628581f75376..9bc6bf548e47 100644 --- a/packages/redcode/src/project/project.ts +++ b/packages/redcode/src/project/project.ts @@ -114,6 +114,9 @@ const layer = Layer.effect( const flags = yield* RuntimeFlags.Service const { db } = yield* Database.Service + // Generous: a cold git on a large repository over a Windows mount is slow but finite. + const GIT_DEADLINE = "30 seconds" + const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { const handle = yield* spawner.spawn( @@ -127,6 +130,11 @@ const layer = Layer.effect( return { code, text, stderr } satisfies GitResult }, Effect.scoped, + // This runs during instance boot, before the first frame. A git that stalls — a + // credential or askpass helper waiting on a GUI, a repository on a stalled mount — + // would otherwise hold the whole startup with nothing on screen. The catch below + // already treats a failed git as "not a repository"; a hung one means the same. + Effect.timeout(GIT_DEADLINE), Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)), ) diff --git a/packages/redcode/src/session/summary.ts b/packages/redcode/src/session/summary.ts index c8fbad5c5979..bf44c5724d38 100644 --- a/packages/redcode/src/session/summary.ts +++ b/packages/redcode/src/session/summary.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@reddb-io/redcode-core/effect/layer-node" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context, Schema, Semaphore } from "effect" import { SessionV1 } from "@reddb-io/redcode-core/v1/session" import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" @@ -63,6 +63,20 @@ function unquoteGitPath(input: string) { return Buffer.from(bytes).toString() } +// One summarize at a time per session. It is forked at every step-finish, and each run +// hydrates the whole session and diffs the whole turn, so overlapping runs used to hold +// several full copies of the session at once. Coalescing keeps the same end state — the +// run that follows the last request still sees the final messages — with one copy live. +const locks = new Map() + +function lock(sessionID: string) { + const hit = locks.get(sessionID) + if (hit) return hit + const next = Semaphore.makeUnsafe(1) + locks.set(sessionID, next) + return next +} + export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect @@ -99,7 +113,7 @@ const layer = Layer.effect( return [] }) - const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { + const summarizeOne = Effect.fn("SessionSummary.summarizeOne")(function* (input: { sessionID: SessionID messageID: MessageID }) { @@ -126,6 +140,27 @@ const layer = Layer.effect( yield* sessions.updateMessage(target.info) }) + // Requests that arrive while a run is in flight collapse into a single follow-up run + // rather than queueing one full-session pass each. + const pending = new Set() + const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { + sessionID: SessionID + messageID: MessageID + }) { + const key = `${input.sessionID}:${input.messageID}` + if (pending.has(key)) return + pending.add(key) + yield* lock(input.sessionID) + .withPermits(1)( + Effect.suspend(() => { + pending.delete(key) + return summarizeOne(input) + }), + ) + // Also on interrupt or failure: a key left behind would drop every later request. + .pipe(Effect.ensuring(Effect.sync(() => pending.delete(key)))) + }) + const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { if (!input.messageID) return [] const message = (yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find( diff --git a/packages/redcode/src/snapshot/index.ts b/packages/redcode/src/snapshot/index.ts index f4d3efdcb3f3..f520b561d8f7 100644 --- a/packages/redcode/src/snapshot/index.ts +++ b/packages/redcode/src/snapshot/index.ts @@ -22,6 +22,7 @@ export type FileDiff = typeof FileDiff.Type const prune = "7.days" const limit = 2 * 1024 * 1024 +const PATCH_CONTEXT_LINES = 3 const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] const cfg = ["-c", "core.autocrlf=false", ...core] const quote = [...cfg, "-c", "core.quotepath=false"] @@ -733,8 +734,17 @@ const layer: Layer.Layer - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) + formatPatch( + structuredPatch(file, file, before, after, "", "", { + context: before.length + after.length > limit ? PATCH_CONTEXT_LINES : Number.MAX_SAFE_INTEGER, + }), + ) for (let i = 0; i < rows.length; i += step) { const run = rows.slice(i, i + step) diff --git a/packages/redcode/src/tool/apply_patch.ts b/packages/redcode/src/tool/apply_patch.ts index 38e0934104fc..61f0967bf8a1 100644 --- a/packages/redcode/src/tool/apply_patch.ts +++ b/packages/redcode/src/tool/apply_patch.ts @@ -297,7 +297,12 @@ export const ApplyPatchTool = Tool.define( metadata: { diff: totalDiff, files, - diagnostics, + diagnostics: LSP.Diagnostic.pick( + diagnostics, + fileChanges + .filter((change) => change.type !== "delete") + .map((change) => FSUtil.normalizePath(change.movePath ?? change.filePath)), + ), }, output, } diff --git a/packages/redcode/src/tool/edit.ts b/packages/redcode/src/tool/edit.ts index fe3614123596..9d79a1106b15 100644 --- a/packages/redcode/src/tool/edit.ts +++ b/packages/redcode/src/tool/edit.ts @@ -202,7 +202,7 @@ export const EditTool = Tool.define( return { metadata: { - diagnostics, + diagnostics: LSP.Diagnostic.pick(diagnostics, [normalizedFilePath]), diff, filediff, }, diff --git a/packages/redcode/src/tool/write.ts b/packages/redcode/src/tool/write.ts index b730a84031b5..80466ab77e89 100644 --- a/packages/redcode/src/tool/write.ts +++ b/packages/redcode/src/tool/write.ts @@ -92,7 +92,7 @@ export const WriteTool = Tool.define( return { title: path.relative(instance.worktree, filepath), metadata: { - diagnostics, + diagnostics: LSP.Diagnostic.pick(diagnostics, [normalizedFilepath]), filepath, exists: exists, }, diff --git a/packages/redcode/src/util/rpc.ts b/packages/redcode/src/util/rpc.ts index 02586ebcfc60..b2b5d78f15cb 100644 --- a/packages/redcode/src/util/rpc.ts +++ b/packages/redcode/src/util/rpc.ts @@ -2,12 +2,31 @@ type Definition = { [method: string]: (input: any) => any } +export class RpcError extends Error { + constructor(message: string) { + super(message) + this.name = "RpcError" + } +} + +function describe(error: unknown) { + if (error instanceof Error) return error.stack ?? `${error.name}: ${error.message}` + return String(error) +} + export function listen(rpc: Definition) { onmessage = async (evt) => { const parsed = JSON.parse(evt.data) - if (parsed.type === "rpc.request") { - const result = await rpc[parsed.method](parsed.input) + if (parsed.type !== "rpc.request") return + try { + const handler = rpc[parsed.method] + if (typeof handler !== "function") throw new Error(`Unknown RPC method: ${parsed.method}`) + const result = await handler(parsed.input) postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) + } catch (error) { + // Without this the caller's promise is never settled and the UI freezes with no + // message — a thrown handler has to reach the caller as a failure, not as silence. + postMessage(JSON.stringify({ type: "rpc.error", error: describe(error), id: parsed.id })) } } } @@ -20,16 +39,24 @@ export function client(target: { postMessage: (data: string) => void | null onmessage: ((this: Worker, ev: MessageEvent) => any) | null }) { - const pending = new Map void>() + const pending = new Map void; reject: (error: Error) => void }>() const listeners = new Map void>>() let id = 0 + let failure: Error | undefined target.onmessage = async (evt) => { const parsed = JSON.parse(evt.data) if (parsed.type === "rpc.result") { - const resolve = pending.get(parsed.id) - if (resolve) { - resolve(parsed.result) + const entry = pending.get(parsed.id) + if (entry) { pending.delete(parsed.id) + entry.resolve(parsed.result) + } + } + if (parsed.type === "rpc.error") { + const entry = pending.get(parsed.id) + if (entry) { + pending.delete(parsed.id) + entry.reject(new RpcError(parsed.error)) } } if (parsed.type === "rpc.event") { @@ -43,12 +70,23 @@ export function client(target: { } return { call(method: Method, input: Parameters[0]): Promise> { + if (failure) return Promise.reject(failure) const requestId = id++ - return new Promise((resolve) => { - pending.set(requestId, resolve) + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) }) }, + /** + * The other end is gone. Everything waiting on it has to fail now: a worker that dies + * or fails to load posts nothing, and a pending call would otherwise wait forever. + */ + fail(reason: string) { + failure = new RpcError(reason) + const waiting = [...pending.values()] + pending.clear() + for (const entry of waiting) entry.reject(failure) + }, on(event: string, handler: (data: Data) => void) { let handlers = listeners.get(event) if (!handlers) { diff --git a/packages/redcode/test/fixture/lsp/fake-lsp-server.js b/packages/redcode/test/fixture/lsp/fake-lsp-server.js index 1f6733ed84ef..3f791b76e524 100644 --- a/packages/redcode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/redcode/test/fixture/lsp/fake-lsp-server.js @@ -3,6 +3,7 @@ let nextId = 1 let readBuffer = Buffer.alloc(0) let lastChange = null +const closed = [] let initializeParams = null let diagnosticRequestCount = 0 let registeredCapability = false @@ -143,6 +144,11 @@ function handle(raw) { return } + if (data.method === "textDocument/didClose") { + closed.push(data.params?.textDocument?.uri) + return + } + if (data.method === "textDocument/didChange") { lastChange = data.params maybeRegister("didChange") @@ -206,6 +212,11 @@ function handle(raw) { return } + if (data.method === "test/get-closed") { + sendResponse(data.id, closed) + return + } + if (data.method === "test/get-diagnostic-request-count") { sendResponse(data.id, diagnosticRequestCount) return diff --git a/packages/redcode/test/lsp/client.test.ts b/packages/redcode/test/lsp/client.test.ts index ba4a37256b58..b0366f8ef667 100644 --- a/packages/redcode/test/lsp/client.test.ts +++ b/packages/redcode/test/lsp/client.test.ts @@ -5,6 +5,7 @@ import { tmpdir, withTestInstance } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" import { spawn } from "@/lsp/launch" +import { Flag } from "@reddb-io/redcode-core/flag/flag" function spawnFakeServer(env?: NodeJS.ProcessEnv) { const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") @@ -207,6 +208,47 @@ describe("LSPClient interop", () => { }) }) + test("closes the least recently touched documents once the open cap is reached", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const files: string[] = [] + for (let i = 0; i < 5; i++) { + const file = path.join(tmp.path, `f${i}.ts`) + await Bun.write(file, `const x${i} = ${i}\n`) + files.push(file) + } + + const previous = process.env["REDCODE_LSP_OPEN_FILE_LIMIT"] + process.env["REDCODE_LSP_OPEN_FILE_LIMIT"] = "3" + Flag.REDCODE_LSP_OPEN_FILE_LIMIT = "3" + try { + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + instance: ctx, + }) + + for (const file of files) await client.notify.open({ path: file }) + + const closed = await client.connection.sendRequest("test/get-closed", {}) + // The two oldest are closed on the server; the cap keeps the rest resident. + expect(closed).toEqual([pathToFileURL(files[0]).href, pathToFileURL(files[1]).href]) + + await client.shutdown() + }, + }) + } finally { + if (previous === undefined) delete process.env["REDCODE_LSP_OPEN_FILE_LIMIT"] + else process.env["REDCODE_LSP_OPEN_FILE_LIMIT"] = previous + Flag.REDCODE_LSP_OPEN_FILE_LIMIT = previous + } + }) + test("document mode falls back to push diagnostics", async () => { const handle = spawnFakeServer() as any await using tmp = await tmpdir() diff --git a/packages/redcode/test/lsp/diagnostic-pick.test.ts b/packages/redcode/test/lsp/diagnostic-pick.test.ts new file mode 100644 index 000000000000..96b1fe5b1a69 --- /dev/null +++ b/packages/redcode/test/lsp/diagnostic-pick.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { LSP } from "@/lsp/lsp" +import type * as LSPClient from "@/lsp/client" + +const issue = (message: string): LSPClient.Diagnostic => + ({ + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + severity: 1, + message, + }) as LSPClient.Diagnostic + +describe("Diagnostic.pick", () => { + test("keeps only the files asked for", () => { + const all = { + "/repo/a.ts": [issue("a")], + "/repo/b.ts": [issue("b")], + "/repo/c.ts": [issue("c")], + } + expect(LSP.Diagnostic.pick(all, ["/repo/a.ts", "/repo/c.ts"])).toEqual({ + "/repo/a.ts": [issue("a")], + "/repo/c.ts": [issue("c")], + }) + }) + + test("skips files with no diagnostics rather than emitting empty entries", () => { + const all = { "/repo/a.ts": [issue("a")] } + expect(LSP.Diagnostic.pick(all, ["/repo/missing.ts"])).toEqual({}) + }) + + test("does not carry the whole workspace along", () => { + const all: Record = {} + for (let i = 0; i < 5000; i++) all[`/repo/file${i}.ts`] = [issue(`issue ${i}`)] + const picked = LSP.Diagnostic.pick(all, ["/repo/file42.ts"]) + expect(Object.keys(picked)).toEqual(["/repo/file42.ts"]) + }) +}) diff --git a/packages/redcode/test/lsp/index.test.ts b/packages/redcode/test/lsp/index.test.ts index b5998742c3d4..85d2f0e00aa1 100644 --- a/packages/redcode/test/lsp/index.test.ts +++ b/packages/redcode/test/lsp/index.test.ts @@ -11,6 +11,7 @@ import { CrossSpawnSpawner } from "@reddb-io/redcode-core/cross-spawn-spawner" import { TestInstance, withTestInstance } from "../fixture/fixture" import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { spawn } from "@/lsp/launch" +import { Flag } from "@reddb-io/redcode-core/flag/flag" import fs from "fs/promises" const lspLayer = (flags: Parameters[0] = {}) => @@ -310,6 +311,40 @@ describe("lsp.spawn", () => { }, ) + it.instance( + "keeps the number of language servers under the cap", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory + const previous = process.env["REDCODE_LSP_MAX_CLIENTS"] + process.env["REDCODE_LSP_MAX_CLIENTS"] = "2" + Flag.REDCODE_LSP_MAX_CLIENTS = "2" + try { + const file = path.join(dir, "sample.repro") + yield* Effect.promise(() => Bun.write(file, "sample\n")) + // Three servers claim this extension, so three clients want to exist at once. + yield* lsp.touchFile(file) + const connected = (yield* lsp.status()).filter((item) => item.status === "connected") + expect(connected.length).toBe(2) + } finally { + if (previous === undefined) delete process.env["REDCODE_LSP_MAX_CLIENTS"] + else process.env["REDCODE_LSP_MAX_CLIENTS"] = previous + Flag.REDCODE_LSP_MAX_CLIENTS = previous + } + }), + ), + { + config: { + lsp: { + fakeA: { command: [process.execPath, fakeServerPath], extensions: [".repro"] }, + fakeB: { command: [process.execPath, fakeServerPath], extensions: [".repro"] }, + fakeC: { command: [process.execPath, fakeServerPath], extensions: [".repro"] }, + }, + }, + }, + ) + it.instance( "would spawn builtin LSP for files inside instance when config object is provided", () => diff --git a/packages/redcode/test/session/summary-coalesce.test.ts b/packages/redcode/test/session/summary-coalesce.test.ts new file mode 100644 index 000000000000..dca9ca43ce20 --- /dev/null +++ b/packages/redcode/test/session/summary-coalesce.test.ts @@ -0,0 +1,119 @@ +import { expect } from "bun:test" +import { LayerNode } from "@reddb-io/redcode-core/effect/layer-node" +import { SessionV1 } from "@reddb-io/redcode-core/v1/session" +import { SessionProjector } from "@reddb-io/redcode-core/session/projector" +import { Deferred, Effect, Exit, Layer, Ref } from "effect" +import { Session as SessionNs } from "@/session/session" +import { MessageV2 } from "../../src/session/message-v2" +import { Snapshot } from "@/snapshot" +import { SessionSummary } from "@/session/summary" +import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@reddb-io/redcode-core/provider" +import { ModelV2 } from "@reddb-io/redcode-core/model" + +// summarize() is forked at every step-finish. Each run hydrates the whole session and +// diffs the whole turn, so overlapping runs used to hold several full copies at once. +// A counting stub for the one expensive call lets the test observe the coalescing. +const calls = Ref.makeUnsafe(0) +const gate = Deferred.makeUnsafe() + +const snapshotStub = Layer.succeed( + Snapshot.Service, + Snapshot.Service.of({ + init: () => Effect.void, + cleanup: () => Effect.void, + track: () => Effect.succeed(undefined), + patch: () => Effect.succeed({ hash: "", files: [] } as never), + restore: () => Effect.void, + revert: () => Effect.void, + diff: () => Effect.succeed(""), + diffFull: () => + Ref.update(calls, (n) => n + 1).pipe( + // Hold the first run open so the later requests pile up behind it. + Effect.andThen(Deferred.await(gate)), + Effect.as([]), + ), + }), +) + +const it = testEffect( + Layer.provideMerge( + LayerNode.compile(LayerNode.group([SessionSummary.node, SessionNs.node, MessageV2.node, SessionProjector.node]), [ + [Snapshot.node, snapshotStub], + ]), + Layer.empty, + ), +) + +const turn = Effect.fn("Test.turn")(function* (sessionID: SessionID) { + const session = yield* SessionNs.Service + const userID = MessageID.ascending() + yield* session.updateMessage({ + id: userID, + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "test", modelID: "test" }, + tools: {}, + mode: "", + } as unknown as SessionV1.Info) + const assistantID = MessageID.ascending() + yield* session.updateMessage({ + id: assistantID, + sessionID, + role: "assistant", + time: { created: Date.now() }, + parentID: userID, + modelID: ModelV2.ID.make("test"), + providerID: ProviderV2.ID.make("test"), + mode: "", + agent: "default", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as unknown as SessionV1.Info) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: assistantID, + type: "step-start", + snapshot: "aaaa", + } as never) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: assistantID, + type: "step-finish", + snapshot: "bbbb", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never) + return userID +}) + +it.instance("summarize collapses concurrent requests instead of running one full pass each", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const summary = yield* SessionSummary.Service + const created = yield* session.create({}) + const messageID = yield* turn(created.id) + + // Five requests land while the first run is still held open by the gate. + yield* Effect.exit( + Effect.forEach([1, 2, 3, 4, 5], () => summary.summarize({ sessionID: created.id, messageID }), { + concurrency: "unbounded", + }).pipe(Effect.timeout("500 millis")), + ) + // Without coalescing each request would hydrate the session and diff the turn on its own. + expect(yield* Ref.get(calls)).toBe(1) + + yield* Deferred.done(gate, Exit.void) + // The work itself still happens once the way is clear. + yield* summary.summarize({ sessionID: created.id, messageID }) + expect(yield* Ref.get(calls)).toBe(2) + yield* session.remove(created.id).pipe(Effect.ignore) + }), +) diff --git a/packages/redcode/test/snapshot/snapshot.test.ts b/packages/redcode/test/snapshot/snapshot.test.ts index fa4a0a6e78b1..fcea1136cbeb 100644 --- a/packages/redcode/test/snapshot/snapshot.test.ts +++ b/packages/redcode/test/snapshot/snapshot.test.ts @@ -883,6 +883,35 @@ it.instance( { git: true }, ) +it.instance( + "diffFull keeps a huge file's patch proportional to the change", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + // Over the 2MB guard, so the patch must not carry a second copy of the file. + // Committed first: track() keeps large *untracked* files out of the snapshot entirely, + // so only a tracked file reaches the patch path this guards. + const lines = Array.from({ length: 60_000 }, (_, i) => `line ${i} of a tracked source file`) + yield* write(`${tmp.path}/huge.txt`, lines.join("\n") + "\n") + yield* exec(tmp.path, ["git", "add", "huge.txt"]) + yield* exec(tmp.path, ["git", "commit", "-m", "add huge"]) + const before = yield* snapshot.track() + expect(before).toBeTruthy() + lines[10] = "line 10 changed" + yield* write(`${tmp.path}/huge.txt`, lines.join("\n") + "\n") + const after = yield* snapshot.track() + expect(after).toBeTruthy() + const diffs = yield* snapshot.diffFull(before!, after!) + const huge = diffs.find((d) => d.file === "huge.txt")! + const patch = huge.patch ?? "" + expect(patch).toContain("+line 10 changed") + expect(huge.additions).toBe(1) + expect(huge.deletions).toBe(1) + expect(patch.length).toBeLessThan(4096) + }), + { git: true }, +) + it.instance( "diffFull with new file additions", withTrackedSnapshot(({ tmp, snapshot, before }) => diff --git a/packages/redcode/test/util/rpc.test.ts b/packages/redcode/test/util/rpc.test.ts new file mode 100644 index 000000000000..4f0e2feee00b --- /dev/null +++ b/packages/redcode/test/util/rpc.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import { Rpc } from "@/util/rpc" + +// A stand-in for the worker port: whatever the client posts is handed to the worker side, +// and whatever the worker posts comes back through the client's onmessage. +function pair(handlers: Record any>) { + let toClient: ((event: { data: string }) => void) | undefined + const globals = globalThis as unknown as { + onmessage?: (event: { data: string }) => void + postMessage?: (data: string) => void + } + const previous = { onmessage: globals.onmessage, postMessage: globals.postMessage } + globals.postMessage = (data: string) => toClient?.({ data }) + Rpc.listen(handlers) + const workerReceive = globals.onmessage! + + const client = Rpc.client({ + postMessage: (data: string) => void workerReceive({ data }), + set onmessage(fn: any) { + toClient = fn + }, + get onmessage() { + return toClient as any + }, + } as any) + + return { + client, + restore() { + globals.onmessage = previous.onmessage + globals.postMessage = previous.postMessage + }, + } +} + +describe("Rpc", () => { + test("returns a handler's result", async () => { + const { client, restore } = pair({ echo: (input: string) => `${input}!` }) + expect(await client.call("echo", "hi")).toBe("hi!") + restore() + }) + + test("a throwing handler rejects the caller instead of leaving it pending", async () => { + const { client, restore } = pair({ + boom: () => { + throw new Error("handler exploded") + }, + }) + await expect(client.call("boom", undefined)).rejects.toThrow("handler exploded") + restore() + }) + + test("an unknown method rejects rather than hanging", async () => { + const { client, restore } = pair({}) + await expect(client.call("nope", undefined)).rejects.toThrow("Unknown RPC method: nope") + restore() + }) + + test("fail() settles everything already waiting", async () => { + const { client, restore } = pair({ stuck: () => new Promise(() => {}) }) + const first = client.call("stuck", undefined) + const second = client.call("stuck", undefined) + client.fail("server thread exited unexpectedly") + await expect(first).rejects.toThrow("server thread exited unexpectedly") + await expect(second).rejects.toThrow("server thread exited unexpectedly") + // And calls made after the failure do not start waiting either. + await expect(client.call("stuck", undefined)).rejects.toThrow("server thread exited unexpectedly") + restore() + }) +}) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 61ed0fb4fff8..29c3d5f4eae6 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -32,7 +32,7 @@ import { promptOffsetWidth } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "../../prompt/history" import { computePromptTraits } from "../../prompt/traits" -import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part" +import { expandPastedTextPlaceholders, expandTrackedPastedText, promptMessageText } from "../../prompt/part" import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" @@ -959,7 +959,7 @@ export function Prompt(props: PromptProps) { if (props.disabled) return false if (workspace.creating() || move.creating()) return false if (auto()?.visible) return false - if (!store.prompt.input) return false + if (!promptMessageText(store.prompt.input)) return false const agent = local.agent.current() if (!agent) return false const trimmed = store.prompt.input.trim() @@ -1025,14 +1025,16 @@ export function Prompt(props: PromptProps) { sessionID = res.data.id } - const inputText = expandTrackedPastedText( - store.prompt.input, - input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { - const partIndex = store.extmarkToPartIndex.get(extmark.id) - const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex] - if (part?.type !== "text") return [] - return [{ start: extmark.start, end: extmark.end, text: part.text }] - }), + const inputText = promptMessageText( + expandTrackedPastedText( + store.prompt.input, + input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex] + if (part?.type !== "text") return [] + return [{ start: extmark.start, end: extmark.end, text: part.text }] + }), + ), ) // Filter out text parts (pasted content) since they're now expanded inline diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index d6180df3e050..3effb33f427b 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -77,8 +77,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: sdk.directory ?? process.cwd(), }) + // Only sessions a consumer actually asked for are mirrored here. Every session that + // produces an event used to be kept in full, forever — subagent sessions included — + // which is a second permanent copy of the whole conversation that nothing reads until + // someone calls message.refresh() for that session. + const tracked = new Set() + const message = { update(sessionID: string, fn: (messages: SessionMessage[]) => void) { + if (!tracked.has(sessionID)) return setStore( "session", "message", @@ -427,9 +434,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.message[sessionID] }, async refresh(sessionID: string) { + // Asking for a session's messages is what starts mirroring its events. + tracked.add(sessionID) const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true }) setStore("session", "message", sessionID, result.data.data) }, + forget(sessionID: string) { + tracked.delete(sessionID) + setStore("session", "message", produce((draft) => void delete draft[sessionID])) + }, }, permission: { list(sessionID: string) { diff --git a/packages/tui/src/prompt/part.ts b/packages/tui/src/prompt/part.ts index 0027b9aaed77..4860e3b9a010 100644 --- a/packages/tui/src/prompt/part.ts +++ b/packages/tui/src/prompt/part.ts @@ -21,6 +21,16 @@ function isPastedTextPart(part: unknown): part is { type: "text"; text: string; return Boolean(text && typeof text === "object" && "value" in text && typeof text.value === "string") } +/** + * What is actually sent for a typed message. The editor keeps whatever whitespace the person + * left behind — a trailing newline from shift+enter, indentation from an abandoned edit — and + * none of it is part of what they said. Whitespace alone is not a message: the guard before + * sending and the text being sent both go through here so they cannot disagree. + */ +export function promptMessageText(text: string) { + return text.trim() +} + export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) { return ranges .slice() diff --git a/packages/tui/test/prompt/part.test.ts b/packages/tui/test/prompt/part.test.ts index 2d5605ac0c49..7fbb492937cf 100644 --- a/packages/tui/test/prompt/part.test.ts +++ b/packages/tui/test/prompt/part.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { expandTrackedPastedText, stripPromptPartIDs } from "../../src/prompt/part" +import { expandTrackedPastedText, promptMessageText, stripPromptPartIDs } from "../../src/prompt/part" describe("prompt part", () => { test("strips persisted IDs from reused parts", () => { @@ -51,3 +51,19 @@ describe("prompt part", () => { ).toBe(`keep ${marker} then alpha\nbeta\ngamma tail`) }) }) + +describe("promptMessageText", () => { + test("drops the whitespace the editor left around the message", () => { + expect(promptMessageText(" hello \n\n")).toBe("hello") + expect(promptMessageText("first line\nsecond line\n")).toBe("first line\nsecond line") + }) + + test("keeps whitespace inside the message", () => { + expect(promptMessageText(" fix this:\n\n indented code\n")).toBe("fix this:\n\n indented code") + }) + + test("reports whitespace-only input as nothing to send", () => { + expect(promptMessageText(" \n\t ")).toBe("") + expect(promptMessageText("")).toBe("") + }) +})