Files
LLeMbas-CLI/tests/steer.test.ts
T
HomerandClaude Opus 5.5 28ff205ed0 Custom connections are gone: the CLI talks to the LLeMbas server it is logged in to
LLeMbas CLI 2.0 is LLeMbas-server-specific (CLI rule 2). Removed: the
anthropic, gemini, ollama and responses clients, context discovery and
llama-swap unloading (discover.ts), the Connection schema with its dialect,
auth, key_cmd, discover, unload_url and one_model_at_a_time, the model
`fallback` (it only ever moved to another server), effort_map and cache, and
the multi-connection refs. Kept: openai-chat, http, sse, think, effort,
tokens and all of src/lembas.

- connections.yaml holds the login (`type: webui`, as lembas login writes
  it). A custom entry from 1.x is read past with a warning that says how to
  log in, so an old file still starts; with several logins left, the latest
  is used and the others named.
- A model's ref is the id the server serves it under; the older forms
  (`<login>/<id>`, `<login>/<model>`) still resolve and config.yaml is
  rewritten once. Without a login there is no model, and the error (and the
  first-run screen, now the sign-in alone) says `lembas login <address>`.
- A login to another server signs this machine out of the previous one.
- The window is the server's word (no discovery); a capacity group comes
  from the server's `capacity.group` alone; embeddings use the server's
  /v1/embeddings.
- `lembas models` lists the server's models; install.sh's starter files, the
  README and the JSON schemas say the same.

Tests: every fixture is a login now (the fake server lists models, answers
other GETs with 404, and can hand one model to another script, as a server
with several backends); the dialect suites and the cross-connection tests are
gone with their code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:00:05 +00:00

150 lines
6.8 KiB
TypeScript

// A message sent while the agent works: it goes in at the next step boundary — after every tool
// result of the step that was running, before the next request — and if the model had already
// finished, it carries on with it in the same task. Esc gives it back; busy_input: queue holds it
// for the task's end.
import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp } from "../src/app.ts"
import type { AskReply, Event } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import type { Message } from "../src/provider/types.ts"
import { setTrust } from "../src/project/root.ts"
import { mergeUsers, STEER_MARK } from "../src/session/engine.ts"
import { delta, fakeProvider, toolCall, usage, type Fake } from "./fake-provider.ts"
let fake: Fake | undefined
afterEach(() => fake?.stop())
function setup(script: Parameters<typeof fakeProvider>[0], config = "") {
fake = fakeProvider(script)
mkdirSync(paths.config, { recursive: true })
writeFileSync(
join(paths.config, "connections.yaml"),
`connections:\n f:\n type: webui\n url: ${fake.url}\n models:\n f/main: { context: 32768 }\n f/a: {}\n`,
{ mode: 0o600 },
)
writeFileSync(join(paths.config, "config.yaml"), `model: f/main\ntitles: prompt\n${config}`)
const cwd = mkdtempSync(join(tmpdir(), "ph-steer-"))
Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd })
writeFileSync(join(cwd, "a.txt"), "one\n")
setTrust(cwd, "trusted")
const app = createApp({ cwd, mode: "edit", asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
const events: Event[] = []
app.bus.on((e) => events.push(e))
return { app, events }
}
/** Send `text` once the first step's reply has started streaming. */
function sendDuringFirstStep(app: ReturnType<typeof setup>["app"], text: string) {
let sent = false
app.bus.on((e) => {
if (!sent && (e.type === "text" || e.type === "tool_call_delta")) {
sent = true
expect(app.turns.steer(text)).toBe(true)
}
})
}
const lastUser = (msgs: { role: string; content: unknown }[]) => JSON.stringify(msgs.filter((m) => m.role === "user").at(-1)?.content)
test("sent during a tool step: after that step's tool results, before the next request", async () => {
const { app, events } = setup([
{ gapMs: 150, chunks: [delta({ content: "Listing." }), toolCall(0, "c1", "list", "{}"), usage(10, 5)] },
{ chunks: [delta({ content: "Using b.txt then." }, "stop")] },
])
sendDuringFirstStep(app, "actually call it b.txt")
expect(await app.turns.prompt("make a file")).toBe("stop")
const second = fake!.requests[1].messages as { role: string; content: unknown }[]
// …, assistant (the call), tool (its result), user (the message): the pairing is intact.
expect(second.slice(-3).map((m) => m.role)).toEqual(["assistant", "tool", "user"])
expect(lastUser(second)).toContain("actually call it b.txt")
expect(lastUser(second)).toContain(STEER_MARK)
expect(events.filter((e) => e.type === "steered")).toHaveLength(1)
// The waiting list was shown, then emptied when it went in.
const inbox = events.filter((e) => e.type === "inbox").map((e) => (e.type === "inbox" ? e.texts.length : -1))
expect(inbox).toEqual([1, 0])
expect(app.turns.takeInbox()).toEqual([])
})
test("sent while the final answer streams: the model carries on with it in the same task", async () => {
const { app } = setup([
{ gapMs: 150, chunks: [delta({ content: "All " }), delta({ content: "done." }, "stop")] },
{ chunks: [delta({ content: "Added the tests too." }, "stop")] },
])
sendDuringFirstStep(app, "and add tests")
expect(await app.turns.prompt("fix it")).toBe("stop")
expect(fake!.requests).toHaveLength(2)
const second = fake!.requests[1].messages as { role: string; content: unknown }[]
expect(second.slice(-2).map((m) => m.role)).toEqual(["assistant", "user"])
expect(lastUser(second)).toContain("and add tests")
expect(app.engine.messages.at(-1)).toMatchObject({ role: "assistant" })
})
test("stopped before it went in: Esc gives it back, nothing reached the model", async () => {
const { app } = setup([{ gapMs: 300, chunks: [delta({ content: "Working" }), delta({ content: " on it" }), delta({ content: "." }, "stop")] }])
let sent = false
app.bus.on((e) => {
if (!sent && e.type === "text") {
sent = true
app.turns.steer("wait, not that")
setTimeout(() => app.cancel(), 50)
}
})
expect(await app.turns.prompt("do the thing")).toBe("cancelled")
expect(fake!.requests).toHaveLength(1)
expect(app.turns.takeInbox()).toEqual(["wait, not that"])
})
test("busy_input: queue — held for the end of the task, never sent mid-turn", async () => {
const { app } = setup(
[
{ gapMs: 150, chunks: [delta({ content: "Listing." }), toolCall(0, "c1", "list", "{}")] },
{ chunks: [delta({ content: "Done." }, "stop")] },
],
"busy_input: queue\n",
)
sendDuringFirstStep(app, "next: the docs")
expect(await app.turns.prompt("first: the code")).toBe("stop")
expect(fake!.requests).toHaveLength(2)
expect(JSON.stringify(fake!.requests[1].messages)).not.toContain("next: the docs")
expect(app.turns.takeInbox()).toEqual(["next: the docs"])
})
test("no task running: steer says no, the caller sends a prompt instead", () => {
const { app } = setup([])
expect(app.turns.steer("hello")).toBe(false)
})
test("neighbouring user messages go to the model as one", () => {
const m: Message[] = [
{ role: "user", parts: [{ type: "text", text: "a" }] },
{ role: "assistant", parts: [{ type: "text", text: "b" }] },
{ role: "user", parts: [{ type: "text", text: "c" }] },
{ role: "user", parts: [{ type: "text", text: "d" }] },
]
const merged = mergeUsers(m)
expect(merged).toHaveLength(3)
expect(merged[2]).toEqual({ role: "user", parts: [{ type: "text", text: "c" }, { type: "text", text: "d" }] })
// The stored conversation is not touched.
expect(m).toHaveLength(4)
})
test("compacted in a later step: the message is kept word for word, like the prompt", async () => {
// A 2000-token window and a step that reports 1900 used: the next step compacts.
fake = undefined
const { app } = setup([
{ gapMs: 150, chunks: [delta({ content: "Listing." }), toolCall(0, "c1", "list", "{}"), usage(1500, 10)] },
{ chunks: [toolCall(0, "c2", "list", "{}"), usage(1900, 10)] },
{ chunks: [delta({ content: "Summary: listed twice." }, "stop")] },
{ chunks: [delta({ content: "Done." }, "stop")] },
])
app.engine.model.spec.context = 2000
sendDuringFirstStep(app, "KEEP-THIS-INSTRUCTION")
expect(await app.turns.prompt("list things")).toBe("stop")
const last = JSON.stringify(fake!.requests.at(-1).messages)
expect(last).toContain("compacted")
expect(last).toContain("KEEP-THIS-INSTRUCTION")
})