Files
Empryo/tests/hearth-claim.test.ts
PouiiroandSoulForge 3fd96f7315 feat(hearth): remote control surface layer with TUI + daemon hosts
Hearth exposes a running forge session to external surfaces (Telegram,
Discord, iMessage, fakechat) through one bridge seam. Either the TUI or
a standalone daemon can own surfaces; ownership handoff is transparent
via an advisory file lock.

Surfaces
- Telegram adapter with long-polling, inline-keyboard approvals,
  callback-query round-tripping, photo + image-document attachments
  (data-URL → ImageAttachment), 4096-char code-block-aware paginator.
- Discord, iMessage, fakechat adapters scaffolded behind BaseSurface.

Bridge core (src/hearth/bridge.ts)
- HearthBridgeImpl: tab registry, chat↔tab bindings, outbound fan-out
  keyed by per-chat view overlay (activeTabByChat), remote-callback
  resolution, persisted to ~/.soulforge/hearth-bridge.json.
- TabHandle.submit carries images; BridgeInbound.images forwards them.
- switchActiveTab updates only the view selector — binding.tabId is
  the immutable home tab so /tab N never orphans a stream.
- TuiActions interface with 25+ provider hooks covering the full
  command surface; providers wire into React state in components/App.tsx.
- BridgeBinding.notifyMode (on|off|errors) for granular outbound filter.

Hosts
- TuiHost: same-process SurfaceHost + outbound sender, waits for daemon
  release-ack before opening its own long-poll (closes dual-poller 409).
- Daemon: socket-IPC service, ChatWorkspace per chat, bridge-notify op
  for lock handoff, debounced reconcile loop with single-transition mutex.
- SurfaceHost: registry + router, reload diff.

Commands (30+)
- Tabs: /tabs, /tab <n> [text], /new, /close, /branch (stub).
- Chat: /model, /mode (read+write), /clear, /stop, /say, /queue.
- State: /status, /cost, /diff, /files, /cwd, /find.
- History: /sessions, /resume (stub), /checkpoint (stub), /undo (stub).
- Agents/MCP: /agents (stub), /cancel (stub), /mcp list, /mcp toggle (stub).
- Notify: /notify on|off|errors, /mute, /unmute.
- Pairing: /pair [CODE], /unpair, /help.
- Rich grouped /help on both TUI host and daemon paths.

CLI
- `soulforge hearth start|stop|status|doctor|pair|unpair|config` via
  src/boot.tsx dispatch.
- `soulforge remote` re-exports the permission CLI (approve-cli).
- `soulforge-remote` bin for external pairing (manifest change lands in
  the following proxy commit along with the undici bump).

UI
- HearthSettings component (daemon tab, surface detail, pairings, logs).
- StatusDashboard shows hearth summary (surfaces/chats/approvals/stats).
- /hearth command registered; openHearthSettings wired into
  CommandContext. hearthSettings added to UIStore modal set.
- ChatMessage.origin ('local'|'telegram'|'discord'|'imessage'|'fakechat')
  surfaced as a badge in MessageList's user-message header.

Streaming (src/hooks/useChat.ts)
- BridgeStreamEmitter coalesces text deltas; ReasoningStreamEmitter runs
  independently. Tool-call / tool-result / warning / error / turn-done
  events emit to the bridge during every relevant stream-part handler.
- Per-turn token accumulator (turnTokensRef) reports real numbers to
  the bridge's turn-done.
- askRemote races the TUI prompt against the remote callback; whichever
  resolves first wins.

Headless seam
- HeadlessChatOptions gained readPrompt / signal / callbacks / onEvent /
  tabId / tabLabel so runChat is parameterizable from the daemon.

Tooling
- getEditedFilesForTab exported from edit-stack so /diff can enumerate
  a tab's edit history without touching the tool runtime.

Routing invariants
- Outbound: fan-out uses activeTabByChat overlay → falls back to
  binding.tabId. Events from a tab the chat isn't viewing are dropped.
- Inbound: active tab resolves via the same overlay so a message sent
  after /tab N lands in the viewed tab, not the home tab.
- activeTabByChat preserved across unregisterTab (HMR, session restore).
- Persisted so /tab N survives TUI restart.

Tests (tests/hearth-*.test.ts)
- bridge.test.ts: bindings, fan-out, persistence.
- streaming.test.ts: coalescing, view-switch routing, paginator, notify
  modes, image forwarding.
- surface-host.test.ts: registry, hot-swap router, lifecycle.
- claim.test.ts: bindClaimedSessions across multi-session restore.
- html.test.ts: redact + telegram-format escaping.
- tui-actions.test.ts: provider defaults + partial merge.
- tui-host.test.ts: singleton, isActive, issuePairingCode format.

Full suite green: typecheck, lint, test (2827 total).

Co-Authored-By: SoulForge <[email protected]>
2026-04-24 20:18:07 +02:00

104 lines
3.1 KiB
TypeScript

/**
* Tests for auto-claim + bindClaimedSessions. Exercises only the in-memory
* bridge-binding effect — socket round-trip is covered by integration.
*/
import { beforeEach, describe, expect, test } from "bun:test";
import { bindClaimedSessions, type ClaimedSession } from "../src/hearth/claim.js";
import { hearthBridge } from "../src/hearth/bridge.js";
import type { SurfaceId } from "../src/hearth/types.js";
beforeEach(() => {
hearthBridge._disablePersistForTests();
hearthBridge._resetForTests();
});
function fakeSession(overrides?: Partial<ClaimedSession>): ClaimedSession {
return {
surfaceId: "telegram:11111" as SurfaceId,
externalId: "222",
sessionId: "sess-1",
meta: {
id: "sess-1",
title: "hearth test",
cwd: "/tmp/proj",
startedAt: Date.now(),
updatedAt: Date.now(),
activeTabId: "tab-a",
forgeMode: "default",
tabs: [
{
id: "tab-a",
label: "TAB-1",
activeModel: "",
sessionId: "sess-1",
planMode: false,
planRequest: null,
coAuthorCommits: false,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
messageRange: { startLine: 0, endLine: 0 },
forgeMode: "default",
},
],
},
tabMessages: new Map(),
...overrides,
};
}
describe("bindClaimedSessions", () => {
test("binds each claimed session to its active tab by id", () => {
const s = fakeSession();
bindClaimedSessions([s]);
const b = hearthBridge.getBinding(s.surfaceId, s.externalId);
expect(b).not.toBeNull();
expect(b?.tabId).toBe("tab-a");
expect(b?.tabLabel).toBe("TAB-1");
});
test("falls back to first tab when activeTabId is unknown", () => {
const s = fakeSession();
s.meta.activeTabId = "does-not-exist";
bindClaimedSessions([s]);
const b = hearthBridge.getBinding(s.surfaceId, s.externalId);
expect(b?.tabId).toBe("tab-a");
});
test("skips sessions with no tabs", () => {
const s = fakeSession();
s.meta.tabs = [];
bindClaimedSessions([s]);
expect(hearthBridge.getBinding(s.surfaceId, s.externalId)).toBeNull();
});
test("binds multiple independent sessions", () => {
const s1 = fakeSession();
const s2 = fakeSession({
surfaceId: "telegram:33333" as SurfaceId,
externalId: "444",
sessionId: "sess-2",
meta: {
...fakeSession().meta,
id: "sess-2",
activeTabId: "tab-b",
tabs: [
{
id: "tab-b",
label: "TAB-2",
activeModel: "",
sessionId: "sess-2",
planMode: false,
planRequest: null,
coAuthorCommits: false,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
messageRange: { startLine: 0, endLine: 0 },
forgeMode: "default",
},
],
},
});
bindClaimedSessions([s1, s2]);
expect(hearthBridge.getBinding(s1.surfaceId, s1.externalId)?.tabId).toBe("tab-a");
expect(hearthBridge.getBinding(s2.surfaceId, s2.externalId)?.tabId).toBe("tab-b");
});
});