@moxxy/plugin-channel-web 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/channel.d.ts +152 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +595 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +171 -0
- package/dist/index.js.map +1 -0
- package/dist/projector.d.ts +17 -0
- package/dist/projector.d.ts.map +1 -0
- package/dist/projector.js +96 -0
- package/dist/projector.js.map +1 -0
- package/dist/protocol.d.ts +145 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +33 -0
- package/dist/protocol.js.map +1 -0
- package/dist/public/app.js +54 -0
- package/dist/public/index.html +178 -0
- package/dist/tunnel-settings.d.ts +17 -0
- package/dist/tunnel-settings.d.ts.map +1 -0
- package/dist/tunnel-settings.js +42 -0
- package/dist/tunnel-settings.js.map +1 -0
- package/package.json +71 -0
- package/src/channel.test.ts +514 -0
- package/src/channel.ts +669 -0
- package/src/discovery.test.ts +40 -0
- package/src/frontend/chat.test.ts +47 -0
- package/src/frontend/chat.tsx +138 -0
- package/src/frontend/index.html +178 -0
- package/src/frontend/main.tsx +87 -0
- package/src/frontend/render-diff.test.ts +86 -0
- package/src/frontend/render-diff.tsx +66 -0
- package/src/frontend/render.test.ts +166 -0
- package/src/frontend/render.tsx +274 -0
- package/src/frontend/socket.ts +212 -0
- package/src/frontend/url-safety.test.ts +0 -0
- package/src/frontend/url-safety.ts +33 -0
- package/src/frontend/view-store.test.ts +104 -0
- package/src/frontend/view-store.ts +120 -0
- package/src/index.ts +212 -0
- package/src/projector.test.ts +172 -0
- package/src/projector.ts +95 -0
- package/src/protocol.test.ts +59 -0
- package/src/protocol.ts +105 -0
- package/src/tunnel-settings.test.ts +64 -0
- package/src/tunnel-settings.ts +57 -0
- package/src/tunnel-tools.test.ts +120 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ViewDoc } from '@moxxy/sdk';
|
|
3
|
+
import { applyView, canGoBack, currentEntry, goBack, initialNav, navigateTo } from './view-store.js';
|
|
4
|
+
|
|
5
|
+
const doc = (tag = 'view'): ViewDoc => ({ root: { kind: 'element', tag, props: {}, children: [] } });
|
|
6
|
+
const frame = (viewId: string, name?: string): { viewId: string; name?: string; doc: ViewDoc } => ({
|
|
7
|
+
viewId,
|
|
8
|
+
...(name ? { name } : {}),
|
|
9
|
+
doc: doc(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('view-store', () => {
|
|
13
|
+
it('caches a view and makes it current', () => {
|
|
14
|
+
const s = applyView(initialNav, frame('v1', 'search'));
|
|
15
|
+
expect(currentEntry(s)?.key).toBe('search');
|
|
16
|
+
expect(currentEntry(s)?.viewId).toBe('v1');
|
|
17
|
+
expect(canGoBack(s)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('pushes distinct screens onto history; Back pops them', () => {
|
|
21
|
+
let s = applyView(initialNav, frame('v1', 'search'));
|
|
22
|
+
s = applyView(s, frame('v2', 'results'));
|
|
23
|
+
expect(currentEntry(s)?.key).toBe('results');
|
|
24
|
+
expect(canGoBack(s)).toBe(true);
|
|
25
|
+
s = goBack(s);
|
|
26
|
+
expect(currentEntry(s)?.key).toBe('search');
|
|
27
|
+
expect(canGoBack(s)).toBe(false);
|
|
28
|
+
// goBack at the root is a no-op.
|
|
29
|
+
expect(goBack(s)).toBe(s);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('re-rendering the current screen updates in place without growing history', () => {
|
|
33
|
+
let s = applyView(initialNav, frame('v1', 'results'));
|
|
34
|
+
const depth = s.history.length;
|
|
35
|
+
s = applyView(s, frame('v2', 'results')); // same name → update in place
|
|
36
|
+
expect(s.history.length).toBe(depth);
|
|
37
|
+
expect(currentEntry(s)?.viewId).toBe('v2'); // latest doc/viewId wins
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keys by viewId when a view has no name', () => {
|
|
41
|
+
let s = applyView(initialNav, frame('v1'));
|
|
42
|
+
s = applyView(s, frame('v2'));
|
|
43
|
+
expect(s.history).toEqual(['v1', 'v2']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('navigateTo a cached view switches client-side; uncached returns null', () => {
|
|
47
|
+
let s = applyView(initialNav, frame('v1', 'search'));
|
|
48
|
+
s = applyView(s, frame('v2', 'results'));
|
|
49
|
+
const back = navigateTo(s, 'search');
|
|
50
|
+
expect(back).not.toBeNull();
|
|
51
|
+
expect(currentEntry(back!)?.key).toBe('search');
|
|
52
|
+
expect(navigateTo(s, 'detail')).toBeNull(); // not cached → caller asks the agent
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('navigateTo the current screen is a no-op (no duplicate history entry)', () => {
|
|
56
|
+
const s = applyView(initialNav, frame('v1', 'search'));
|
|
57
|
+
expect(navigateTo(s, 'search')).toBe(s);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('bounds the cache: an unbounded stream of unnamed views does not grow forever', () => {
|
|
61
|
+
// Worst case: a long session presents 500 distinct UNNAMED views (each a new
|
|
62
|
+
// viewId, never coalesced). The browser must not leak one ViewDoc per render.
|
|
63
|
+
let s = initialNav;
|
|
64
|
+
for (let i = 0; i < 500; i++) s = applyView(s, frame(`v${i}`));
|
|
65
|
+
expect(Object.keys(s.cache).length).toBeLessThanOrEqual(64);
|
|
66
|
+
expect(s.order.length).toBeLessThanOrEqual(64);
|
|
67
|
+
// The current (newest) view is always still present and navigable.
|
|
68
|
+
expect(currentEntry(s)?.viewId).toBe('v499');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('bounds history under pressure yet every Back target stays in cache (no dangling)', () => {
|
|
72
|
+
// Flood with unnamed views (each pushes a fresh history frame). History is
|
|
73
|
+
// FIFO-trimmed, but the INVARIANT must hold: every key still on the history
|
|
74
|
+
// stack has a live cache entry, so Back can never dereference a missing doc.
|
|
75
|
+
let s = initialNav;
|
|
76
|
+
for (let i = 0; i < 500; i++) s = applyView(s, frame(`x${i}`));
|
|
77
|
+
expect(s.history.length).toBeLessThanOrEqual(50);
|
|
78
|
+
let back = s;
|
|
79
|
+
while (canGoBack(back)) {
|
|
80
|
+
back = goBack(back);
|
|
81
|
+
expect(currentEntry(back)).not.toBeNull(); // never a dangling history key
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('preserves recent Back targets — a screen visited just before the flood is still reachable', () => {
|
|
86
|
+
// 'recent' is pushed, then a modest run of unnamed views within the history
|
|
87
|
+
// cap: it must remain on the stack AND in cache (recency is what's retained).
|
|
88
|
+
let s = applyView(initialNav, frame('r', 'recent'));
|
|
89
|
+
for (let i = 0; i < 10; i++) s = applyView(s, frame(`y${i}`));
|
|
90
|
+
expect(s.cache['recent']).toBeDefined();
|
|
91
|
+
let back = s;
|
|
92
|
+
while (canGoBack(back)) back = goBack(back);
|
|
93
|
+
expect(currentEntry(back)?.key).toBe('recent');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('keeps the cache map and order array consistent after eviction', () => {
|
|
97
|
+
let s = initialNav;
|
|
98
|
+
for (let i = 0; i < 200; i++) s = applyView(s, frame(`v${i}`));
|
|
99
|
+
// order and cache must be the SAME key set (no dangling refs either way).
|
|
100
|
+
expect(new Set(s.order)).toEqual(new Set(Object.keys(s.cache)));
|
|
101
|
+
// and every history key must be cached (Back integrity).
|
|
102
|
+
for (const key of s.history) expect(s.cache[key]).toBeDefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ViewDoc } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure navigation state for the multi-view app — a view cache keyed by logical
|
|
5
|
+
* name (falling back to viewId) plus a history stack. Kept DOM-free and pure so
|
|
6
|
+
* it is unit-testable; the React hook in socket.ts is a thin wrapper.
|
|
7
|
+
*
|
|
8
|
+
* Model: each `view` frame caches its doc and pushes onto history (unless it
|
|
9
|
+
* re-renders the screen already on top — then it updates in place). `navigateTo`
|
|
10
|
+
* only succeeds client-side when the target is cached; otherwise the caller asks
|
|
11
|
+
* the agent to build it (a `navigate:<name>` turn). `goBack` pops the stack.
|
|
12
|
+
*/
|
|
13
|
+
export interface ViewEntry {
|
|
14
|
+
readonly key: string;
|
|
15
|
+
readonly viewId: string;
|
|
16
|
+
readonly doc: ViewDoc;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NavState {
|
|
20
|
+
readonly cache: Readonly<Record<string, ViewEntry>>;
|
|
21
|
+
readonly history: ReadonlyArray<string>;
|
|
22
|
+
/** Cache insertion order (oldest first), used to LRU-evict so the map stays bounded. */
|
|
23
|
+
readonly order: ReadonlyArray<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const initialNav: NavState = { cache: {}, history: [], order: [] };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cap on the navigable history depth. Each UNNAMED view (keyed by its
|
|
30
|
+
* ever-incrementing viewId, so never coalesced) pushes a fresh history frame,
|
|
31
|
+
* which in turn PINS its cache entry — so without a history bound a long session
|
|
32
|
+
* presenting many unnamed views would grow `history` AND `cache` (one full
|
|
33
|
+
* ViewDoc per render) without limit. We trim the OLDEST history frames first
|
|
34
|
+
* (a user can't realistically Back through hundreds of screens), then evict any
|
|
35
|
+
* now-unreferenced cache entries. The current view and recent Back targets are
|
|
36
|
+
* always retained.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_HISTORY = 50;
|
|
39
|
+
/**
|
|
40
|
+
* Cap on distinct cached view docs. Bounds the cache independently of history so
|
|
41
|
+
* that even if history stays short, an LRU of off-stack screens can't grow
|
|
42
|
+
* forever. Never evicts a view still referenced by the (already-bounded) stack.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_CACHED_VIEWS = 64;
|
|
45
|
+
|
|
46
|
+
export interface ViewFrameLike {
|
|
47
|
+
readonly viewId: string;
|
|
48
|
+
readonly name?: string;
|
|
49
|
+
readonly doc: ViewDoc;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Cache a freshly-arrived view and make it current. */
|
|
53
|
+
export function applyView(state: NavState, frame: ViewFrameLike): NavState {
|
|
54
|
+
const key = frame.name ?? frame.viewId;
|
|
55
|
+
const cache: Record<string, ViewEntry> = { ...state.cache, [key]: { key, viewId: frame.viewId, doc: frame.doc } };
|
|
56
|
+
const top = state.history[state.history.length - 1];
|
|
57
|
+
const history = top === key ? state.history.slice() : [...state.history, key];
|
|
58
|
+
// Refresh recency: move (or add) key to the tail of the insertion order.
|
|
59
|
+
const order = [...state.order.filter((k) => k !== key), key];
|
|
60
|
+
return prune({ cache, history, order });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Keep both `history` and `cache` bounded while preserving the invariant that
|
|
65
|
+
* `cache` keys === `order` keys, and every `history` key is in `cache` (so Back
|
|
66
|
+
* never dangles). First trim the oldest history frames past {@link MAX_HISTORY}
|
|
67
|
+
* (FIFO — drop the deepest Back targets); then LRU-evict cache entries past
|
|
68
|
+
* {@link MAX_CACHED_VIEWS}, oldest first, but NEVER one still on the (now
|
|
69
|
+
* bounded) history stack.
|
|
70
|
+
*/
|
|
71
|
+
function prune(state: NavState): NavState {
|
|
72
|
+
let history = state.history;
|
|
73
|
+
if (history.length > MAX_HISTORY) {
|
|
74
|
+
history = history.slice(history.length - MAX_HISTORY);
|
|
75
|
+
}
|
|
76
|
+
const live = new Set(history);
|
|
77
|
+
const cache: Record<string, ViewEntry> = { ...state.cache };
|
|
78
|
+
const order = [...state.order];
|
|
79
|
+
// Walk oldest→newest, dropping evictable (non-history) keys until within cap.
|
|
80
|
+
for (let i = 0; i < order.length && order.length > MAX_CACHED_VIEWS; ) {
|
|
81
|
+
const key = order[i]!;
|
|
82
|
+
if (live.has(key)) {
|
|
83
|
+
i += 1; // pinned by history — skip, keep it
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
order.splice(i, 1);
|
|
87
|
+
delete cache[key];
|
|
88
|
+
}
|
|
89
|
+
return { cache, history, order };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Client-side navigation to a cached view. Returns the new state, or null if the
|
|
94
|
+
* target isn't cached (caller should request it from the agent instead).
|
|
95
|
+
*/
|
|
96
|
+
export function navigateTo(state: NavState, name: string): NavState | null {
|
|
97
|
+
if (!state.cache[name]) return null;
|
|
98
|
+
const top = state.history[state.history.length - 1];
|
|
99
|
+
if (top === name) return state;
|
|
100
|
+
// Cap history depth so repeated back-and-forth navigation can't grow it
|
|
101
|
+
// without bound (drop the oldest Back targets first). `name` is already
|
|
102
|
+
// cached, so trimming history never strands the entry referenced here.
|
|
103
|
+
const pushed = [...state.history, name];
|
|
104
|
+
const history = pushed.length > MAX_HISTORY ? pushed.slice(pushed.length - MAX_HISTORY) : pushed;
|
|
105
|
+
return { ...state, history };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function goBack(state: NavState): NavState {
|
|
109
|
+
if (state.history.length <= 1) return state;
|
|
110
|
+
return { ...state, history: state.history.slice(0, -1) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function currentEntry(state: NavState): ViewEntry | null {
|
|
114
|
+
const key = state.history[state.history.length - 1];
|
|
115
|
+
return key ? state.cache[key] ?? null : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function canGoBack(state: NavState): boolean {
|
|
119
|
+
return state.history.length > 1;
|
|
120
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { defineChannel, definePlugin, defineTool, z, type Plugin, type TunnelProviderDef } from '@moxxy/sdk';
|
|
2
|
+
import { proxyTunnel } from '@moxxy/plugin-tunnel-proxy';
|
|
3
|
+
import { WebChannel, type WebSurfaceControls } from './channel.js';
|
|
4
|
+
import { normalizeTunnelName, readTunnelSetting, writeTunnelSetting } from './tunnel-settings.js';
|
|
5
|
+
|
|
6
|
+
export { WebChannel, type WebChannelOptions, type WebStartOpts, type WebSurfaceControls } from './channel.js';
|
|
7
|
+
export { EventProjector } from './projector.js';
|
|
8
|
+
export { readTunnelSetting, writeTunnelSetting, normalizeTunnelName } from './tunnel-settings.js';
|
|
9
|
+
export { actionPrompt, type ServerFrame, type ClientFrame } from './protocol.js';
|
|
10
|
+
|
|
11
|
+
/** Live access to the session's tunnel registry (injected by the CLI builder). */
|
|
12
|
+
export interface TunnelControls {
|
|
13
|
+
list(): string[];
|
|
14
|
+
active(): string | null;
|
|
15
|
+
setActive(name: string): void;
|
|
16
|
+
isAvailable(name: string): Promise<boolean>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BuildWebChannelOptions {
|
|
20
|
+
readonly getTunnel?: () => TunnelProviderDef | null;
|
|
21
|
+
readonly publishSurface?: (surface: { url: string; nextViewId: () => string } | null) => void;
|
|
22
|
+
readonly publishControls?: (controls: WebSurfaceControls | null) => void;
|
|
23
|
+
/** Read the live surface controls (for live tunnel switching from the tool). */
|
|
24
|
+
readonly getControls?: () => WebSurfaceControls | null;
|
|
25
|
+
/** Live tunnel registry access for the web_set_tunnel / web_tunnel_status tools. */
|
|
26
|
+
readonly tunnels?: TunnelControls;
|
|
27
|
+
/** Static default tunnel (from config.channels.web.tunnel) when no persisted setting. */
|
|
28
|
+
readonly defaultTunnel?: string;
|
|
29
|
+
/** Override the persisted settings file path (tests). Defaults to ~/.moxxy/web.json. */
|
|
30
|
+
readonly settingsFile?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the web channel plugin. The CLI passes closures over the concrete
|
|
35
|
+
* Session (mirroring the Telegram plugin's vault injection). Contributes:
|
|
36
|
+
* - the `web` channel (surface + action round-trip),
|
|
37
|
+
* - the self-hosted `proxy` tunnel provider (registered; `localhost` is the
|
|
38
|
+
* core-seeded default),
|
|
39
|
+
* - `web_set_tunnel` / `web_tunnel_status` tools so the user/agent can switch
|
|
40
|
+
* the tunnel at runtime — persisted to ~/.moxxy/web.json,
|
|
41
|
+
* - an onInit hook that applies the persisted (or configured) tunnel on boot.
|
|
42
|
+
*/
|
|
43
|
+
export function buildWebChannelPlugin(opts: BuildWebChannelOptions = {}): Plugin {
|
|
44
|
+
const def = defineChannel({
|
|
45
|
+
name: 'web',
|
|
46
|
+
description:
|
|
47
|
+
'Browser surface that renders agent-authored view-spec UIs over a WebSocket and routes form/button actions back into the session. Exposes itself via the self-hosted proxy relay so users on other channels can open it.',
|
|
48
|
+
create: (deps) => {
|
|
49
|
+
const o = deps.options ?? {};
|
|
50
|
+
return new WebChannel({
|
|
51
|
+
...(typeof o.port === 'number' ? { port: o.port } : {}),
|
|
52
|
+
...(typeof o.host === 'string' ? { host: o.host } : {}),
|
|
53
|
+
...(typeof o.authToken === 'string'
|
|
54
|
+
? { authToken: o.authToken }
|
|
55
|
+
: process.env.MOXXY_WEB_TOKEN
|
|
56
|
+
? { authToken: process.env.MOXXY_WEB_TOKEN }
|
|
57
|
+
: {}),
|
|
58
|
+
...(Array.isArray(o.allowedTools) ? { allowedTools: o.allowedTools as string[] } : {}),
|
|
59
|
+
...(opts.getTunnel ? { getTunnel: opts.getTunnel } : {}),
|
|
60
|
+
...(opts.publishSurface ? { publishSurface: opts.publishSurface } : {}),
|
|
61
|
+
...(opts.publishControls ? { publishControls: opts.publishControls } : {}),
|
|
62
|
+
logger: deps.logger as never,
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const tunnels = opts.tunnels;
|
|
68
|
+
const tools = tunnels
|
|
69
|
+
? [
|
|
70
|
+
defineTool({
|
|
71
|
+
name: 'web_set_tunnel',
|
|
72
|
+
description:
|
|
73
|
+
'Choose how the web app surface is exposed: "proxy" (public URL via the self-hosted relay, for remote channels like Telegram) or "none"/"localhost" (loopback only). Persisted to ~/.moxxy/web.json and applied immediately if a surface is live. Use when the user asks to change or disable the tunnel.',
|
|
74
|
+
inputSchema: z.object({ provider: z.string().min(1) }),
|
|
75
|
+
permission: { action: 'prompt' },
|
|
76
|
+
// Persists the choice, then (re)establishes the tunnel through the
|
|
77
|
+
// live surface controls — the relay host is config-dependent, so
|
|
78
|
+
// the net surface is genuinely dynamic.
|
|
79
|
+
isolation: {
|
|
80
|
+
capabilities: {
|
|
81
|
+
fs: {
|
|
82
|
+
read: [opts.settingsFile ?? '~/.moxxy/web.json'],
|
|
83
|
+
write: [opts.settingsFile ?? '~/.moxxy/web.json'],
|
|
84
|
+
},
|
|
85
|
+
net: { mode: 'any' },
|
|
86
|
+
timeMs: 60_000,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
handler: async ({ provider }) => {
|
|
90
|
+
const name = normalizeTunnelName(provider);
|
|
91
|
+
if (!tunnels.list().includes(name)) {
|
|
92
|
+
return { ok: false, error: `unknown tunnel "${provider}"`, available: ['none', ...tunnels.list()] };
|
|
93
|
+
}
|
|
94
|
+
const available = await tunnels.isAvailable(name);
|
|
95
|
+
if (!available) {
|
|
96
|
+
return { ok: false, error: `${name} is not available` };
|
|
97
|
+
}
|
|
98
|
+
tunnels.setActive(name);
|
|
99
|
+
await writeTunnelSetting(name, opts.settingsFile);
|
|
100
|
+
const url = (await opts.getControls?.()?.retunnel()) ?? null;
|
|
101
|
+
return { ok: true, active: name, ...(url ? { url } : { note: 'applies when the web surface (re)starts' }) };
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
defineTool({
|
|
105
|
+
name: 'web_tunnel_status',
|
|
106
|
+
description: 'Report the active web tunnel provider and the available options.',
|
|
107
|
+
inputSchema: z.object({}),
|
|
108
|
+
// Pure registry view — no fs / net / subprocess.
|
|
109
|
+
isolation: { capabilities: { net: { mode: 'none' }, timeMs: 10_000 } },
|
|
110
|
+
handler: () => ({ active: tunnels.active(), available: ['none', ...tunnels.list()] }),
|
|
111
|
+
}),
|
|
112
|
+
]
|
|
113
|
+
: [];
|
|
114
|
+
|
|
115
|
+
return definePlugin({
|
|
116
|
+
name: '@moxxy/plugin-channel-web',
|
|
117
|
+
version: '0.0.0',
|
|
118
|
+
channels: [def],
|
|
119
|
+
tunnelProviders: [proxyTunnel],
|
|
120
|
+
tools,
|
|
121
|
+
hooks: tunnels
|
|
122
|
+
? {
|
|
123
|
+
onInit: () => {
|
|
124
|
+
// Apply the persisted choice (or the configured default) on boot.
|
|
125
|
+
const chosen = readTunnelSetting(opts.settingsFile) ?? (opts.defaultTunnel ? normalizeTunnelName(opts.defaultTunnel) : undefined);
|
|
126
|
+
if (chosen && tunnels.list().includes(chosen)) {
|
|
127
|
+
try {
|
|
128
|
+
tunnels.setActive(chosen);
|
|
129
|
+
} catch {
|
|
130
|
+
/* keep the seeded default */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
: {},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface TunnelRegistryLike {
|
|
140
|
+
getActive(): TunnelProviderDef | null;
|
|
141
|
+
list(): ReadonlyArray<TunnelProviderDef>;
|
|
142
|
+
setActive(name: string): void;
|
|
143
|
+
}
|
|
144
|
+
interface SurfaceRef {
|
|
145
|
+
current: { url: string; nextViewId: () => string } | null;
|
|
146
|
+
}
|
|
147
|
+
interface ControlsRef {
|
|
148
|
+
current: WebSurfaceControls | null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Discovery-loadable default export. Resolves the tunnel registry
|
|
153
|
+
* (`'tunnelProviders'`), the shared web-surface ref (`'viewSurface'`, read by the
|
|
154
|
+
* view plugin) + web-controls ref (`'webControls'`) and the configured default
|
|
155
|
+
* tunnel (`'webDefaultTunnel'`) from the inter-plugin service registry in
|
|
156
|
+
* `onInit`, instead of the host `{ getTunnel, publishSurface, … }` closure. A
|
|
157
|
+
* lazy `tunnels` object keeps the `web_set_tunnel`/`web_tunnel_status` tools +
|
|
158
|
+
* the boot tunnel-apply hook present (built before onInit), deferring every
|
|
159
|
+
* registry call to the resolved instance.
|
|
160
|
+
*/
|
|
161
|
+
export const webChannelPlugin: Plugin = (() => {
|
|
162
|
+
let tp: TunnelRegistryLike | null = null;
|
|
163
|
+
let surfaceRef: SurfaceRef | null = null;
|
|
164
|
+
let controlsRef: ControlsRef | null = null;
|
|
165
|
+
let defaultTunnel: string | undefined;
|
|
166
|
+
|
|
167
|
+
const tunnels: TunnelControls = {
|
|
168
|
+
list: () => tp?.list().map((p) => p.name) ?? [],
|
|
169
|
+
active: () => tp?.getActive()?.name ?? null,
|
|
170
|
+
setActive: (n) => {
|
|
171
|
+
tp?.setActive(n);
|
|
172
|
+
},
|
|
173
|
+
isAvailable: async (n) => {
|
|
174
|
+
const p = tp?.list().find((x) => x.name === n);
|
|
175
|
+
return p?.isAvailable ? p.isAvailable() : true;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const opts: BuildWebChannelOptions = {
|
|
179
|
+
getTunnel: () => tp?.getActive() ?? null,
|
|
180
|
+
publishSurface: (s) => {
|
|
181
|
+
if (surfaceRef) surfaceRef.current = s;
|
|
182
|
+
},
|
|
183
|
+
publishControls: (c) => {
|
|
184
|
+
if (controlsRef) controlsRef.current = c;
|
|
185
|
+
},
|
|
186
|
+
getControls: () => controlsRef?.current ?? null,
|
|
187
|
+
tunnels,
|
|
188
|
+
get defaultTunnel(): string | undefined {
|
|
189
|
+
return defaultTunnel;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const plugin = buildWebChannelPlugin(opts);
|
|
194
|
+
const innerOnInit = plugin.hooks?.onInit;
|
|
195
|
+
|
|
196
|
+
return definePlugin({
|
|
197
|
+
...plugin,
|
|
198
|
+
hooks: {
|
|
199
|
+
...plugin.hooks,
|
|
200
|
+
onInit: (ctx) => {
|
|
201
|
+
tp = ctx.services.get<TunnelRegistryLike>('tunnelProviders') ?? null;
|
|
202
|
+
surfaceRef = ctx.services.get<SurfaceRef>('viewSurface') ?? null;
|
|
203
|
+
controlsRef = ctx.services.get<ControlsRef>('webControls') ?? null;
|
|
204
|
+
defaultTunnel = ctx.services.get<string>('webDefaultTunnel') ?? undefined;
|
|
205
|
+
// Now the refs are resolved → apply the persisted/default tunnel on boot.
|
|
206
|
+
return innerOnInit?.(ctx);
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
})();
|
|
211
|
+
|
|
212
|
+
export default webChannelPlugin;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { MoxxyEvent } from '@moxxy/sdk';
|
|
3
|
+
import { EventProjector } from './projector.js';
|
|
4
|
+
|
|
5
|
+
// Minimal event factory — only the fields the projector reads.
|
|
6
|
+
function ev(partial: Record<string, unknown>): MoxxyEvent {
|
|
7
|
+
return { turnId: 't1', sessionId: 's1', source: 'system', id: 'e', seq: 0, ts: 0, ...partial } as unknown as MoxxyEvent;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const sampleDoc = { root: { kind: 'element', tag: 'view', props: {}, children: [] } };
|
|
11
|
+
|
|
12
|
+
describe('EventProjector', () => {
|
|
13
|
+
it('emits a view frame from a present_view tool_result', () => {
|
|
14
|
+
const p = new EventProjector();
|
|
15
|
+
expect(p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: { fallbackText: 'fb' } }))).toEqual([]);
|
|
16
|
+
const frames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }));
|
|
17
|
+
expect(frames).toHaveLength(1);
|
|
18
|
+
const f = frames[0]!;
|
|
19
|
+
expect(f.kind).toBe('view');
|
|
20
|
+
if (f.kind !== 'view') return;
|
|
21
|
+
expect(f.doc).toEqual(sampleDoc);
|
|
22
|
+
expect(f.replaces).toBeNull();
|
|
23
|
+
expect(f.fallbackText).toBe('fb');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('sets `replaces` to the prior view id on the second view', () => {
|
|
27
|
+
const p = new EventProjector();
|
|
28
|
+
p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
|
|
29
|
+
const first = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }))[0]!;
|
|
30
|
+
p.project(ev({ type: 'tool_call_requested', callId: 'c2', name: 'present_view', input: {} }));
|
|
31
|
+
const second = p.project(ev({ type: 'tool_result', callId: 'c2', ok: true, output: { ast: sampleDoc } }))[0]!;
|
|
32
|
+
if (first.kind !== 'view' || second.kind !== 'view') throw new Error('expected views');
|
|
33
|
+
expect(second.replaces).toBe(first.viewId);
|
|
34
|
+
expect(second.viewId).not.toBe(first.viewId);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('carries the view name from the AST root onto the frame', () => {
|
|
38
|
+
const p = new EventProjector();
|
|
39
|
+
p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
|
|
40
|
+
const namedDoc = { root: { kind: 'element', tag: 'view', props: { name: 'search' }, children: [] } };
|
|
41
|
+
const f = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: namedDoc } }))[0]!;
|
|
42
|
+
expect(f.kind === 'view' && f.name).toBe('search');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('omits name when the view has none', () => {
|
|
46
|
+
const p = new EventProjector();
|
|
47
|
+
p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
|
|
48
|
+
const f = p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } }))[0]!;
|
|
49
|
+
expect(f.kind === 'view' && f.name).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('ignores tool_result for non-present_view calls', () => {
|
|
53
|
+
const p = new EventProjector();
|
|
54
|
+
expect(p.project(ev({ type: 'tool_result', callId: 'x', ok: true, output: { ast: sampleDoc } }))).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('emits a file-diff frame from a Write/Edit tool_result carrying a display', () => {
|
|
58
|
+
const p = new EventProjector();
|
|
59
|
+
const display = {
|
|
60
|
+
kind: 'file-diff',
|
|
61
|
+
path: 'src/foo.ts',
|
|
62
|
+
mode: 'update',
|
|
63
|
+
added: 2,
|
|
64
|
+
removed: 1,
|
|
65
|
+
hunks: [
|
|
66
|
+
{
|
|
67
|
+
oldStart: 1,
|
|
68
|
+
oldLines: 2,
|
|
69
|
+
newStart: 1,
|
|
70
|
+
newLines: 3,
|
|
71
|
+
lines: [
|
|
72
|
+
{ kind: 'context', text: 'a', oldNo: 1, newNo: 1 },
|
|
73
|
+
{ kind: 'del', text: 'b', oldNo: 2 },
|
|
74
|
+
{ kind: 'add', text: 'b2', newNo: 2 },
|
|
75
|
+
{ kind: 'add', text: 'c', newNo: 3 },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
const frames = p.project(ev({ type: 'tool_result', callId: 'w1', name: 'Write', ok: true, output: { forModel: 'Updated', display } }));
|
|
81
|
+
expect(frames).toEqual([{ kind: 'file-diff', turnId: 't1', display }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('does NOT emit a file-diff frame for a failed write result', () => {
|
|
85
|
+
const p = new EventProjector();
|
|
86
|
+
const display = { kind: 'file-diff', path: 'x', mode: 'create', added: 1, removed: 0, hunks: [] };
|
|
87
|
+
expect(p.project(ev({ type: 'tool_result', callId: 'w2', ok: false, output: { display } }))).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('hides synthesized [ui-action] prompts but shows real ones', () => {
|
|
91
|
+
const p = new EventProjector();
|
|
92
|
+
expect(p.project(ev({ type: 'user_prompt', text: '[ui-action] ...' }))).toEqual([]);
|
|
93
|
+
const frames = p.project(ev({ type: 'user_prompt', text: 'find flights' }));
|
|
94
|
+
expect(frames[0]).toMatchObject({ kind: 'message', role: 'user', text: 'find flights' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('emits assistant message + done status, and tool status for other tools', () => {
|
|
98
|
+
const p = new EventProjector();
|
|
99
|
+
expect(p.project(ev({ type: 'tool_call_requested', callId: 'c', name: 'web_fetch', input: {} }))[0]).toMatchObject({
|
|
100
|
+
kind: 'status',
|
|
101
|
+
phase: 'tool',
|
|
102
|
+
});
|
|
103
|
+
const frames = p.project(ev({ type: 'assistant_message', content: 'done!', stopReason: 'end_turn' }));
|
|
104
|
+
expect(frames.find((f) => f.kind === 'message')).toMatchObject({ role: 'assistant', text: 'done!' });
|
|
105
|
+
expect(frames.find((f) => f.kind === 'status')).toMatchObject({ phase: 'done' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does NOT emit a done status when the assistant stops to use a tool', () => {
|
|
109
|
+
const p = new EventProjector();
|
|
110
|
+
const frames = p.project(ev({ type: 'assistant_message', content: 'thinking', stopReason: 'tool_use' }));
|
|
111
|
+
expect(frames.some((f) => f.kind === 'status' && f.phase === 'done')).toBe(false);
|
|
112
|
+
expect(frames.some((f) => f.kind === 'message')).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('skips empty assistant messages but still ends the turn', () => {
|
|
116
|
+
const p = new EventProjector();
|
|
117
|
+
const frames = p.project(ev({ type: 'assistant_message', content: ' ', stopReason: 'end_turn' }));
|
|
118
|
+
expect(frames.some((f) => f.kind === 'message')).toBe(false);
|
|
119
|
+
expect(frames.some((f) => f.kind === 'status' && f.phase === 'done')).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('emits an error status for a failed present_view tool_result', () => {
|
|
123
|
+
const p = new EventProjector();
|
|
124
|
+
p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
|
|
125
|
+
const frames = p.project(ev({ type: 'tool_result', callId: 'c1', ok: false, error: { message: 'bad', kind: 'threw' } }));
|
|
126
|
+
expect(frames).toEqual([{ kind: 'status', turnId: 't1', phase: 'error', text: 'view failed to render' }]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('emits nothing for a present_view result missing an ast', () => {
|
|
130
|
+
const p = new EventProjector();
|
|
131
|
+
p.project(ev({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} }));
|
|
132
|
+
expect(p.project(ev({ type: 'tool_result', callId: 'c1', ok: true, output: {} }))).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('maps error events to an error status', () => {
|
|
136
|
+
const p = new EventProjector();
|
|
137
|
+
expect(p.project(ev({ type: 'error', message: 'boom', kind: 'fatal' }))).toEqual([
|
|
138
|
+
{ kind: 'status', turnId: 't1', phase: 'error', text: 'boom' },
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('handles two present_view calls in one turn, each replacing the last', () => {
|
|
143
|
+
const p = new EventProjector();
|
|
144
|
+
p.project(ev({ type: 'tool_call_requested', callId: 'a', name: 'present_view', input: {} }));
|
|
145
|
+
const first = p.project(ev({ type: 'tool_result', callId: 'a', ok: true, output: { ast: sampleDoc } }))[0]!;
|
|
146
|
+
p.project(ev({ type: 'tool_call_requested', callId: 'b', name: 'present_view', input: {} }));
|
|
147
|
+
const second = p.project(ev({ type: 'tool_result', callId: 'b', ok: true, output: { ast: sampleDoc } }))[0]!;
|
|
148
|
+
if (first.kind !== 'view' || second.kind !== 'view') throw new Error('views');
|
|
149
|
+
expect(second.replaces).toBe(first.viewId);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('ignores event types it does not render', () => {
|
|
153
|
+
const p = new EventProjector();
|
|
154
|
+
expect(p.project(ev({ type: 'tool_call_approved', callId: 'c' }))).toEqual([]);
|
|
155
|
+
expect(p.project(ev({ type: 'provider_request' }))).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('stays correct after a long run of aborted present_view calls (pending map bounded)', () => {
|
|
159
|
+
// Each present_view tool_call without a matching tool_result simulates an
|
|
160
|
+
// aborted/errored turn — its pending entry would otherwise leak forever.
|
|
161
|
+
// After many such aborts the projector must remain functional and still
|
|
162
|
+
// render a fresh, fully-resolved view.
|
|
163
|
+
const p = new EventProjector();
|
|
164
|
+
for (let i = 0; i < 500; i++) {
|
|
165
|
+
expect(p.project(ev({ type: 'tool_call_requested', callId: `abort${i}`, name: 'present_view', input: {} }))).toEqual([]);
|
|
166
|
+
}
|
|
167
|
+
p.project(ev({ type: 'tool_call_requested', callId: 'live', name: 'present_view', input: {} }));
|
|
168
|
+
const frames = p.project(ev({ type: 'tool_result', callId: 'live', ok: true, output: { ast: sampleDoc } }));
|
|
169
|
+
expect(frames).toHaveLength(1);
|
|
170
|
+
expect(frames[0]!.kind).toBe('view');
|
|
171
|
+
});
|
|
172
|
+
});
|