Files
AgentHub/client/src/components/Sagi/__tests__/intents.test.ts
T
nntrivi2001 0cb5e41faf feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window
- Fix: an errored session alerted on every update, and Clear alerts only
  dropped it in memory, so its next update (or a reload) brought the alert
  straight back. An error now alerts on the transition into error only;
  cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay
  quiet until they recover and fail anew.
- Quips, greetings, notifications and Ask answers move to i18n (EN/VI);
  Ask understands Vietnamese keywords and names the sessions it counts.
- The pet goes by the worn pet's name; click it in the panel to rename
  (agenthub.pet.names, synced to the account).
- Bubbles name the session they are about, with an Open button (never a
  resume); a burst of the same event coalesces into one count.
- Moments: a new session waves, a finish hops, a subagent peeks, a wait taps,
  an error flinches — on Sagi, Codex sprites and still images. Idle fidgets,
  a once-per-session greeting, a "Needs you" list, an amber waiting dot.
- Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so
  it shows while the panel or chat is open.
- Panel drops Run Claude / Activity / Sessions / Errored.
- Ask hands off to a chat window (PetChat) on the default AI tool
  (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an
  off-screen attached terminal; no new server route.

Verified on HEAD + this diff in a clean worktree: tsc -b, the full client
suite (1334/1335; the one failure, ConversationView.follow, is flaky under
load and passes 3/3 alone) and server me-prefs.test.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:29 +07:00

102 lines
4.0 KiB
TypeScript

/**
* @file intents.test.ts
* @description Unit tests for Sagi's intent matcher — natural-language queries mapped to dashboard navigation and status intents.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { matchIntent } from "../intents";
import type { SagiStatus } from "../brain";
const status = (over: Partial<SagiStatus> = {}): SagiStatus => ({
liveCount: 0,
waitingCount: 0,
errorCount: 0,
connected: true,
...over,
});
describe("matchIntent", () => {
it("reports live sessions", () => {
const r = matchIntent("what's running?", status({ liveCount: 3 }));
expect(r).toEqual({ kind: "answer", text: expect.stringContaining("3 sessions live") });
});
it("singularizes correctly", () => {
const r = matchIntent("anything active", status({ liveCount: 1 }));
expect(r.kind).toBe("answer");
if (r.kind === "answer") expect(r.text).toContain("1 session live");
});
it("says all quiet when nothing live", () => {
const r = matchIntent("what is running", status());
if (r.kind === "answer") expect(r.text).toContain("nothing's running");
});
it("reports errors and prioritizes error intent over live", () => {
const r = matchIntent("any failed runs?", status({ liveCount: 2, errorCount: 1 }));
if (r.kind === "answer") expect(r.text).toContain("1 session errored");
});
it("clean when no errors", () => {
const r = matchIntent("are there errors", status({ liveCount: 2 }));
if (r.kind === "answer") expect(r.text).toContain("all clean");
});
it("gives a combined status summary", () => {
const r = matchIntent(
"status",
status({ liveCount: 2, waitingCount: 1, errorCount: 1, connected: true })
);
if (r.kind === "answer") expect(r.text).toBe("2 live · 1 waiting · 1 errored · connected.");
});
it("reports sessions waiting on the user", () => {
const r = matchIntent("anything waiting on me?", status({ liveCount: 2, waitingCount: 1 }));
if (r.kind === "answer") expect(r.text).toContain("1 session waiting on you");
});
it("reflects offline in summary", () => {
const r = matchIntent("overview", status({ connected: false }));
if (r.kind === "answer") expect(r.text).toContain("offline");
});
it("explains itself on help", () => {
const r = matchIntent("help", status());
if (r.kind === "answer") expect(r.text.toLowerCase()).toContain("watch your sessions");
});
it("empty query nudges the user", () => {
const r = matchIntent(" ", status());
expect(r.kind).toBe("answer");
});
it("hands unknown questions to Claude, preserving original casing", () => {
const r = matchIntent("Refactor my auth module", status());
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
});
it("understands Vietnamese keywords", () => {
const r = matchIntent("có lỗi nào không?", status({ errorCount: 2 }));
if (r.kind === "answer") expect(r.text).toContain("2 sessions errored");
expect(matchIntent("đang chạy gì vậy", status({ liveCount: 1 })).kind).toBe("answer");
expect(matchIntent("tình hình sao rồi", status()).kind).toBe("answer");
});
it("names the sessions it reports when it knows them", () => {
const r = matchIntent("any errors?", status({ errorCount: 1 }), {
attention: [{ id: "a", kind: "error", name: "Fix login", cwd: null, lane_id: null }],
});
if (r.kind === "answer") expect(r.text).toContain("errored: Fix login");
});
it("introduces itself by the pet's name and reacts to a pat", () => {
const who = matchIntent("who are you", status(), { name: "Mochi" });
expect(who).toMatchObject({ kind: "answer", moment: "wave" });
if (who.kind === "answer") expect(who.text).toContain("Mochi");
expect(matchIntent("pat", status())).toMatchObject({ kind: "answer", moment: "jump" });
// "pat" inside another word is not a pat.
expect(matchIntent("update the path", status()).kind).toBe("handoff");
});
});