mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
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]>
104 lines
3.1 KiB
TypeScript
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");
|
|
});
|
|
});
|