All files / main classifier.ts

79.06% Statements 68/86
83.63% Branches 46/55
100% Functions 4/4
79.06% Lines 68/86

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 1211x             1x       1x 1x       1x 1x   1x 37x 1x 1x 1x 1x 1x 1x 1x   37x                   37x                   37x 29x 29x 29x 29x 29x 29x 14x 14x 14x 29x 29x 29x 29x 29x 29x 29x 29x   7x 7x 7x         7x 7x 7x 7x 7x 7x 7x 7x                       1x 32x 32x   27x 27x   36x 36x 36x 36x 36x 36x 19x 19x 3x 3x 1x 1x 5x 5x     36x 36x 36x  
import type { AgentEvent, Classification, WorkAnimation } from "../shared/types";
import type { ZoneId } from "../shared/zones";
import { logger } from "./logger";
 
// Remember which (tool, zone, animation) combinations we have already logged
// this process lifetime so classifier DEBUG output stays sparse. Instrumentation,
// not business data, so in-memory is fine.
const seenClassifications = new Set<string>();
 
// Matches common test-runner invocations. Word boundary `\b` is intentional so
// substrings inside larger commands still register (e.g. `pnpm run -- pnpm test`).
const TEST_RE =
  /\b(pnpm test|npm test|yarn test|vitest|jest|pytest|rspec|ruby -Itest|go test|cargo test)\b/;
 
// Matches git subcommands we treat as "nether" activity plus the GitHub CLI when
// invoked at the start of a command (`gh ...`). Keep in sync with spec Section 5.
const GIT_RE =
  /\bgit (commit|push|pull|checkout|branch|merge|rebase|fetch|log|diff|status|reset|revert|tag)\b|^gh\s/;
 
export function classify(event: AgentEvent): Classification {
  if (event.type === "session-end" || event.type === "subagent-end") {
    return {
      zone: "tavern",
      animation: "work-tavern",
      tooltip: "Idle",
      timelineText: event.type === "session-end" ? "Session ended" : "Subagent finished"
    };
  }
 
  if (event.type === "user-message") {
    const excerpt = (event.messageExcerpt ?? "").trim();
    return {
      zone: "tavern",
      animation: "idle",
      tooltip: excerpt ? `User: ${excerpt}` : "User",
      timelineText: `user: ${excerpt}`
    };
  }
 
  if (event.type === "assistant-message") {
    const excerpt = (event.messageExcerpt ?? "").trim();
    return {
      zone: "tavern",
      animation: "idle",
      tooltip: excerpt || "Thinking",
      timelineText: `assistant: ${excerpt}`
    };
  }
 
  if (event.type === "pre-tool-use") {
    const toolName = (event.toolName ?? "tool").trim() || "tool";
    const args = event.toolArgsSummary ?? "";
    const zone = toolToZone(toolName, args);
    const animation = zoneToAnimation(zone);
    const key = `${toolName}:${zone}:${animation}`;
    if (!seenClassifications.has(key)) {
      seenClassifications.add(key);
      logger.debug("classifier new classification path", { toolName, zone, animation });
    }
    const tooltip = `${toolName} ${args}`.trim() || toolName;
    return {
      zone,
      animation,
      tooltip,
      timelineText: `${toolName}(${args})`
    };
  }
 
  if (event.type === "post-tool-use") {
    const zone = toolToZone(event.toolName ?? "", "");
    const rawSummary = (event.resultSummary ?? "").trim();
    // `resultSummary` can arrive as "" (empty string, not undefined) when the
    // tool produced no textual output, or as pure punctuation like "->" or "..."
    // from terse shell output. Either case would otherwise clobber the bubble
    // with unreadable junk, so we fall back to "Done" for display.
    const tooltip = isTrivialSummary(rawSummary) ? "Done" : rawSummary;
    return {
      zone,
      animation: zoneToAnimation(zone),
      tooltip,
      timelineText: `-> ${event.resultSummary ?? ""}`
    };
  }
 
  return { zone: "tavern", animation: "idle", tooltip: "", timelineText: "" };
}
 
/**
 * True if `s` is empty (after trimming) or contains only punctuation that
 * would render as a meaningless speech bubble - arrows like "->", "<-", ellipses
 * like "...", dashes, etc. We use this both in the classifier (to replace such
 * content with a human-readable fallback) and in the session store (to avoid
 * overwriting the previous readable bubble with junk).
 */
export function isTrivialSummary(s: string): boolean {
  const trimmed = s.trim();
  if (trimmed.length === 0) return true;
  // Only characters from the "arrow / punctuation" set - nothing alphanumeric.
  return /^[-<>._\s]+$/.test(trimmed);
}
 
function toolToZone(tool: string, args: string): ZoneId {
  if (tool === "Read") return "library";
  if (tool === "Write" || tool === "Edit" || tool === "NotebookEdit") return "office";
  if (tool === "Grep" || tool === "Glob") return "mine";
  if (tool === "Task" || tool === "Agent") return "spawner";
  if (tool === "WebFetch" || tool === "WebSearch") return "signpost";
  if (tool.startsWith("mcp__")) return "signpost";
  if (tool === "Bash") {
    if (TEST_RE.test(args)) return "farm";
    if (GIT_RE.test(args)) return "nether";
    return "forest";
  }
  return "tavern";
}
 
// Template-literal return type keeps this in sync with `ZoneId` without a cast.
function zoneToAnimation(zone: ZoneId): WorkAnimation {
  return `work-${zone}`;
}