@moxxy/plugin-view 0.26.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/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +173 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/src/discovery.test.ts +29 -0
- package/src/index.test.ts +229 -0
- package/src/index.ts +237 -0
- package/src/integration.test.ts +73 -0
- package/src/present-view.test.ts +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moxxy (moxxy.ai)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type LifecycleHooks, type Plugin, type ViewDoc, type ViewRendererDef } from '@moxxy/sdk';
|
|
2
|
+
/**
|
|
3
|
+
* A live view surface the tool can query for the public URL + the next view id
|
|
4
|
+
* (supplied by the web channel once it co-attaches). Optional: when absent,
|
|
5
|
+
* `present_view` still parses + validates, it just returns no URL.
|
|
6
|
+
*/
|
|
7
|
+
export interface ViewSurface {
|
|
8
|
+
/** The currently reachable URL of the surface, or null if not yet up. */
|
|
9
|
+
readonly url: string | null;
|
|
10
|
+
/** Allocate the next view id for the turn that is presenting. */
|
|
11
|
+
nextViewId(): string;
|
|
12
|
+
}
|
|
13
|
+
export interface BuildViewPluginOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Returns the session's active view renderer. A closure (not the renderer
|
|
16
|
+
* itself) so `setActive`/plugin replacement is honored at call time —
|
|
17
|
+
* ToolContext exposes no session handle, so this is how the tool reaches it.
|
|
18
|
+
*/
|
|
19
|
+
getRenderer: () => ViewRendererDef | null;
|
|
20
|
+
/** Returns the live web surface, if one is co-attached. */
|
|
21
|
+
getSurface?: () => ViewSurface | null;
|
|
22
|
+
}
|
|
23
|
+
export declare function buildViewPlugin(opts: BuildViewPluginOptions, hooks?: LifecycleHooks): Plugin;
|
|
24
|
+
/**
|
|
25
|
+
* Discovery-loadable default export. Resolves the active view-renderer registry
|
|
26
|
+
* (`'viewRenderers'`) and the shared web-surface ref (`'viewSurface'`, published
|
|
27
|
+
* by the host + written by the web channel) from the inter-plugin service
|
|
28
|
+
* registry in `onInit`, so `present_view` reaches them without a host-injected
|
|
29
|
+
* `{ getRenderer, getSurface }` closure. Both reads are lazy (at tool-call time),
|
|
30
|
+
* and degrade to "no renderer"/"no surface" if the host hasn't published them.
|
|
31
|
+
*/
|
|
32
|
+
export declare const viewPlugin: Plugin;
|
|
33
|
+
export interface PresentViewResult {
|
|
34
|
+
readonly ok: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Whether a live web surface is ATTACHED (false = parsed only, no surface).
|
|
37
|
+
* NOT a delivery confirmation: the view is handed to the surface
|
|
38
|
+
* asynchronously when the channel's projector observes this tool_result, so
|
|
39
|
+
* `rendered: true` only means a surface exists to deliver to — a browser may
|
|
40
|
+
* not yet be connected to it. Do not treat it as "the user definitely sees
|
|
41
|
+
* the screen".
|
|
42
|
+
*/
|
|
43
|
+
readonly rendered: boolean;
|
|
44
|
+
readonly url?: string;
|
|
45
|
+
readonly viewId?: string;
|
|
46
|
+
readonly nodeCount?: number;
|
|
47
|
+
/** Validated AST — the web surface reads this from the tool_result. */
|
|
48
|
+
readonly ast?: ViewDoc;
|
|
49
|
+
readonly errors?: ReadonlyArray<{
|
|
50
|
+
message: string;
|
|
51
|
+
line?: number;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
export default viewPlugin;
|
|
55
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,cAAc,EACnB,KAAK,MAAM,EACX,KAAK,OAAO,EAGZ,KAAK,eAAe,EACrB,MAAM,YAAY,CAAC;AAiCpB;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,iEAAiE;IACjE,UAAU,IAAI,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,eAAe,GAAG,IAAI,CAAC;IAC1C,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,WAAW,GAAG,IAAI,CAAC;CACvC;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,sBAAsB,EAAE,KAAK,CAAC,EAAE,cAAc,GAAG,MAAM,CAkH5F;AAUD;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU,EAAE,MAgBrB,CAAC;AAEL,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;;;OAOG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrE;AAGD,eAAe,UAAU,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { defineTool, definePlugin, z, } from '@moxxy/sdk';
|
|
2
|
+
/**
|
|
3
|
+
* Hard ceiling on the validated AST size. The zod `spec` cap (20k chars) only
|
|
4
|
+
* bounds the *source* — rich components (e.g. `results`) expand at parse time
|
|
5
|
+
* into many primitives, and a swapped-in custom renderer is not bound by core's
|
|
6
|
+
* parse-depth cap at all. The full AST is JSON-serialized into the tool_result,
|
|
7
|
+
* round-trips back into model context, and is streamed verbatim to every
|
|
8
|
+
* web-surface client, so it must be bounded independently of the source length.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_VIEW_NODES = 2_000;
|
|
11
|
+
/**
|
|
12
|
+
* Iterative (stack-safe) node count. Unlike the recursive `countNodes` in the
|
|
13
|
+
* SDK, this cannot overflow the call stack on a pathologically deep AST that a
|
|
14
|
+
* custom renderer might return (core's default parser caps depth, but a
|
|
15
|
+
* swapped-in renderer need not). Short-circuits once `limit` is exceeded so a
|
|
16
|
+
* huge tree is rejected without walking all of it.
|
|
17
|
+
*/
|
|
18
|
+
function countNodesBounded(root, limit) {
|
|
19
|
+
const stack = [root];
|
|
20
|
+
let count = 0;
|
|
21
|
+
while (stack.length > 0) {
|
|
22
|
+
const node = stack.pop();
|
|
23
|
+
count++;
|
|
24
|
+
if (count > limit)
|
|
25
|
+
return count;
|
|
26
|
+
if (node.kind === 'element') {
|
|
27
|
+
for (const child of node.children)
|
|
28
|
+
stack.push(child);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return count;
|
|
32
|
+
}
|
|
33
|
+
export function buildViewPlugin(opts, hooks) {
|
|
34
|
+
const presentView = defineTool({
|
|
35
|
+
name: 'present_view',
|
|
36
|
+
description: 'Render a screen of a generative UI (a "generative / dynamic / agentic UI") from a view-spec ' +
|
|
37
|
+
'(JSX-like, allow-listed tags). Read the `generative-ui` skill first. Use this only when the ' +
|
|
38
|
+
'user explicitly asks for a generative / dynamic / agentic UI (or interactive UI) for X — NOT ' +
|
|
39
|
+
"for ordinary searches or questions, which stay plain text. Give each screen a <view name>; " +
|
|
40
|
+
'call once per screen. The view appears on the user’s web surface; share the returned `url` ' +
|
|
41
|
+
'with them. When the user interacts (submits a form / clicks a button) it arrives as a normal ' +
|
|
42
|
+
'follow-up user message containing a `[ui-action]` block — read it and respond, usually by ' +
|
|
43
|
+
'calling present_view again under the same `name` to update the screen in place.',
|
|
44
|
+
inputSchema: z.object({
|
|
45
|
+
spec: z
|
|
46
|
+
.string()
|
|
47
|
+
.min(1)
|
|
48
|
+
.max(20_000)
|
|
49
|
+
.describe('The view-spec source, a single <view>…</view> root with allow-listed tags.'),
|
|
50
|
+
fallbackText: z
|
|
51
|
+
.string()
|
|
52
|
+
.max(2_000)
|
|
53
|
+
.optional()
|
|
54
|
+
.describe('Plain-text summary shown on channels that cannot render views.'),
|
|
55
|
+
}),
|
|
56
|
+
handler: (input, ctx) => {
|
|
57
|
+
// Short-circuit a cancelled turn before the heaviest work (parse + count
|
|
58
|
+
// + AST serialization on a near-20k spec). ctx is optional only so unit
|
|
59
|
+
// tests can call the handler bare; the registry always supplies it.
|
|
60
|
+
if (ctx?.signal?.aborted) {
|
|
61
|
+
return { ok: false, rendered: false, errors: [{ message: 'aborted' }] };
|
|
62
|
+
}
|
|
63
|
+
const renderer = opts.getRenderer();
|
|
64
|
+
if (!renderer) {
|
|
65
|
+
return { ok: false, rendered: false, errors: [{ message: 'no active view renderer' }] };
|
|
66
|
+
}
|
|
67
|
+
// `renderer.parse` is a caller-supplied closure: core's default renderer
|
|
68
|
+
// never throws (it wraps everything, even a RangeError, into a structured
|
|
69
|
+
// result), but a swapped-in custom renderer carries no such guarantee. A
|
|
70
|
+
// throw here would surface as a generic turn-level error instead of a
|
|
71
|
+
// structured tool result, so guard it like every other external closure.
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
result = renderer.parse(input.spec);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
const message = e instanceof Error && e.message ? e.message : 'view renderer failed';
|
|
78
|
+
return { ok: false, rendered: false, errors: [{ message }] };
|
|
79
|
+
}
|
|
80
|
+
if (!result.ok) {
|
|
81
|
+
// `errors` may be malformed (custom renderer); degrade to a single
|
|
82
|
+
// generic error rather than throwing on a non-iterable `.map`.
|
|
83
|
+
const errs = Array.isArray(result.errors)
|
|
84
|
+
? result.errors.map((e) => ({ message: e?.message ?? 'parse error', line: e?.line }))
|
|
85
|
+
: [{ message: 'view renderer reported a parse failure' }];
|
|
86
|
+
return { ok: false, rendered: false, errors: errs };
|
|
87
|
+
}
|
|
88
|
+
// A custom renderer can return `ok:true` with a missing/null doc or root;
|
|
89
|
+
// reject with an honest message instead of letting the count below crash
|
|
90
|
+
// and report "too deeply nested".
|
|
91
|
+
const root = result.doc?.root;
|
|
92
|
+
if (root == null || (root.kind !== 'element' && root.kind !== 'text')) {
|
|
93
|
+
return { ok: false, rendered: false, errors: [{ message: 'view renderer returned a malformed AST' }] };
|
|
94
|
+
}
|
|
95
|
+
// Bound the AST independently of the source length. countNodesBounded is
|
|
96
|
+
// iterative so a deep tree from a custom renderer cannot overflow the
|
|
97
|
+
// stack; we still wrap defensively so the handler can never throw.
|
|
98
|
+
let nodeCount;
|
|
99
|
+
try {
|
|
100
|
+
nodeCount = countNodesBounded(root, MAX_VIEW_NODES);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { ok: false, rendered: false, errors: [{ message: 'view too deeply nested' }] };
|
|
104
|
+
}
|
|
105
|
+
if (nodeCount > MAX_VIEW_NODES) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
rendered: false,
|
|
109
|
+
errors: [{ message: `view too large (${nodeCount}+ nodes; max ${MAX_VIEW_NODES})` }],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// The surface closures are caller-supplied and read mutable shared state
|
|
113
|
+
// (web channel minter); never let a fault there turn a successful parse
|
|
114
|
+
// into a turn-level throw — degrade to the documented parsed-only result.
|
|
115
|
+
let surface = null;
|
|
116
|
+
let viewId;
|
|
117
|
+
try {
|
|
118
|
+
surface = opts.getSurface?.() ?? null;
|
|
119
|
+
viewId = surface?.nextViewId();
|
|
120
|
+
// An empty-string id is a minter fault, not "no surface": treat the
|
|
121
|
+
// surface as absent rather than claiming rendered:true with no id.
|
|
122
|
+
if (surface != null && (viewId == null || viewId === '')) {
|
|
123
|
+
surface = null;
|
|
124
|
+
viewId = undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
surface = null;
|
|
129
|
+
viewId = undefined;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
rendered: surface != null,
|
|
134
|
+
...(surface?.url ? { url: surface.url } : {}),
|
|
135
|
+
...(viewId != null ? { viewId } : {}),
|
|
136
|
+
nodeCount,
|
|
137
|
+
ast: result.doc,
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
// No hardcoded `version`: it only ever drifts from package.json. The real
|
|
142
|
+
// package version is captured from the resolved plugin manifest at load time.
|
|
143
|
+
return definePlugin({
|
|
144
|
+
name: '@moxxy/plugin-view',
|
|
145
|
+
tools: [presentView],
|
|
146
|
+
...(hooks ? { hooks } : {}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Discovery-loadable default export. Resolves the active view-renderer registry
|
|
151
|
+
* (`'viewRenderers'`) and the shared web-surface ref (`'viewSurface'`, published
|
|
152
|
+
* by the host + written by the web channel) from the inter-plugin service
|
|
153
|
+
* registry in `onInit`, so `present_view` reaches them without a host-injected
|
|
154
|
+
* `{ getRenderer, getSurface }` closure. Both reads are lazy (at tool-call time),
|
|
155
|
+
* and degrade to "no renderer"/"no surface" if the host hasn't published them.
|
|
156
|
+
*/
|
|
157
|
+
export const viewPlugin = (() => {
|
|
158
|
+
let renderers = null;
|
|
159
|
+
let surface = null;
|
|
160
|
+
const hooks = {
|
|
161
|
+
onInit: (ctx) => {
|
|
162
|
+
renderers = ctx.services.get('viewRenderers') ?? null;
|
|
163
|
+
surface = ctx.services.get('viewSurface') ?? null;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
return buildViewPlugin({
|
|
167
|
+
getRenderer: () => renderers?.getActive() ?? null,
|
|
168
|
+
getSurface: () => surface?.current ?? null,
|
|
169
|
+
}, hooks);
|
|
170
|
+
})();
|
|
171
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
172
|
+
export default viewPlugin;
|
|
173
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-view",
|
|
3
|
+
"version": "0.26.0",
|
|
4
|
+
"description": "Agent-authored UIs for moxxy. A present_view tool parses the agent's JSX-like view-spec (via the active view renderer) into a validated AST that channels render as rich interactive UI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"agent",
|
|
8
|
+
"tools",
|
|
9
|
+
"ui",
|
|
10
|
+
"present-view"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://moxxy.ai",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/moxxy-ai/moxxy/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/moxxy-ai/moxxy.git",
|
|
19
|
+
"directory": "packages/plugin-view"
|
|
20
|
+
},
|
|
21
|
+
"author": "Michal Makowski <michal.makowski97@gmail.com>",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"moxxy": {
|
|
40
|
+
"plugin": {
|
|
41
|
+
"entry": "./dist/index.js",
|
|
42
|
+
"kind": "tools"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"zod": "^3.24.0",
|
|
47
|
+
"@moxxy/sdk": "0.26.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.10.0",
|
|
51
|
+
"typescript": "^5.7.3",
|
|
52
|
+
"vitest": "^2.1.8",
|
|
53
|
+
"@moxxy/vitest-preset": "0.0.0",
|
|
54
|
+
"@moxxy/core": "0.26.0",
|
|
55
|
+
"@moxxy/tsconfig": "0.0.0"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsc -p tsconfig.json",
|
|
59
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
60
|
+
"test": "vitest run",
|
|
61
|
+
"clean": "rm -rf dist .turbo"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { ServiceRegistry } from '@moxxy/sdk';
|
|
3
|
+
import { viewPlugin } from './index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The discovery-loadable default export resolves the active view-renderer
|
|
7
|
+
* registry + the shared web-surface ref from the service registry in onInit,
|
|
8
|
+
* instead of the host `{ getRenderer, getSurface }` closure.
|
|
9
|
+
*/
|
|
10
|
+
describe('viewPlugin (discovery-loadable)', () => {
|
|
11
|
+
it('exposes present_view + an onInit hook', () => {
|
|
12
|
+
expect(viewPlugin.tools?.map((t) => t.name)).toEqual(['present_view']);
|
|
13
|
+
expect(typeof viewPlugin.hooks?.onInit).toBe('function');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('onInit resolves viewRenderers + viewSurface from the registry', () => {
|
|
17
|
+
const get = vi.fn((name: string) =>
|
|
18
|
+
name === 'viewRenderers'
|
|
19
|
+
? { getActive: () => null }
|
|
20
|
+
: name === 'viewSurface'
|
|
21
|
+
? { current: null }
|
|
22
|
+
: undefined,
|
|
23
|
+
);
|
|
24
|
+
const services = { get, register: () => {}, require: () => undefined, has: () => true } as unknown as ServiceRegistry;
|
|
25
|
+
viewPlugin.hooks!.onInit!({ services } as never);
|
|
26
|
+
expect(get).toHaveBeenCalledWith('viewRenderers');
|
|
27
|
+
expect(get).toHaveBeenCalledWith('viewSurface');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ToolContext, ToolDef, ViewNode, ViewParseResult, ViewRendererDef } from '@moxxy/sdk';
|
|
3
|
+
import { defaultViewRenderer } from '@moxxy/core';
|
|
4
|
+
import { buildViewPlugin, type PresentViewResult } from './index.js';
|
|
5
|
+
|
|
6
|
+
function toolOf(plugin: ReturnType<typeof buildViewPlugin>): ToolDef {
|
|
7
|
+
const tool = plugin.tools?.find((t) => t.name === 'present_view');
|
|
8
|
+
if (!tool) throw new Error('present_view tool not found');
|
|
9
|
+
return tool;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ctx = {} as ToolContext;
|
|
13
|
+
|
|
14
|
+
/** A renderer whose parse returns the supplied doc unconditionally — lets us
|
|
15
|
+
* feed the handler ASTs that core's depth/node caps would normally reject. */
|
|
16
|
+
function fixedRenderer(root: ViewNode): ViewRendererDef {
|
|
17
|
+
return {
|
|
18
|
+
name: 'fixed',
|
|
19
|
+
allowList: [],
|
|
20
|
+
parse: (): ViewParseResult => ({ ok: true, doc: { root } }),
|
|
21
|
+
validate: () => [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A linearly-nested `stack` chain `depth` levels deep. */
|
|
26
|
+
function deepChain(depth: number): ViewNode {
|
|
27
|
+
let node: ViewNode = { kind: 'text', value: 'leaf' };
|
|
28
|
+
for (let i = 0; i < depth; i++) {
|
|
29
|
+
node = { kind: 'element', tag: 'stack', props: {}, children: [node] };
|
|
30
|
+
}
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A wide `stack` with `n` direct text children. */
|
|
35
|
+
function wideStack(n: number): ViewNode {
|
|
36
|
+
const children: ViewNode[] = [];
|
|
37
|
+
for (let i = 0; i < n; i++) children.push({ kind: 'text', value: 'x' });
|
|
38
|
+
return { kind: 'element', tag: 'stack', props: {}, children };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('present_view tool', () => {
|
|
42
|
+
const tool = toolOf(buildViewPlugin({ getRenderer: () => defaultViewRenderer }));
|
|
43
|
+
const call = (spec: string) => tool.handler({ spec }, ctx) as PresentViewResult;
|
|
44
|
+
|
|
45
|
+
it('returns the validated AST for a good spec', () => {
|
|
46
|
+
const r = call(`<view title="x"><text>hi</text></view>`);
|
|
47
|
+
expect(r.ok).toBe(true);
|
|
48
|
+
expect(r.ast?.title).toBe('x');
|
|
49
|
+
expect(r.nodeCount).toBeGreaterThan(0);
|
|
50
|
+
// No surface attached → parsed only.
|
|
51
|
+
expect(r.rendered).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns errors for a bad spec without throwing', () => {
|
|
55
|
+
const r = call(`<view><script>x</script></view>`);
|
|
56
|
+
expect(r.ok).toBe(false);
|
|
57
|
+
expect(r.errors?.[0]?.message).toMatch(/unknown tag/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('surfaces the url + viewId when a surface is attached', () => {
|
|
61
|
+
const t = toolOf(
|
|
62
|
+
buildViewPlugin({
|
|
63
|
+
getRenderer: () => defaultViewRenderer,
|
|
64
|
+
getSurface: () => ({ url: 'https://x.trycloudflare.com/?t=abc', nextViewId: () => 'v_1' }),
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
const r = t.handler({ spec: `<view><text>hi</text></view>` }, ctx) as PresentViewResult;
|
|
68
|
+
expect(r.ok).toBe(true);
|
|
69
|
+
expect(r.url).toContain('trycloudflare');
|
|
70
|
+
expect(r.viewId).toBe('v_1');
|
|
71
|
+
expect(r.rendered).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('reports no active renderer gracefully', () => {
|
|
75
|
+
const t = toolOf(buildViewPlugin({ getRenderer: () => null }));
|
|
76
|
+
const r = t.handler({ spec: `<view><text>hi</text></view>` }, ctx) as PresentViewResult;
|
|
77
|
+
expect(r.ok).toBe(false);
|
|
78
|
+
expect(r.errors?.[0]?.message).toMatch(/no active view renderer/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects an over-large AST (node-count ceiling) without serializing it', () => {
|
|
82
|
+
// A custom renderer can emit an AST far past what the 20k-char source cap
|
|
83
|
+
// implies; the handler must reject on node count, not return the AST.
|
|
84
|
+
const t = toolOf(buildViewPlugin({ getRenderer: () => fixedRenderer(wideStack(5_000)) }));
|
|
85
|
+
const r = t.handler({ spec: '<view/>' }, ctx) as PresentViewResult;
|
|
86
|
+
expect(r.ok).toBe(false);
|
|
87
|
+
expect(r.rendered).toBe(false);
|
|
88
|
+
expect(r.ast).toBeUndefined();
|
|
89
|
+
expect(r.errors?.[0]?.message).toMatch(/view too large/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does not throw (no stack overflow) on a pathologically deep AST', () => {
|
|
93
|
+
// Deeper than any recursion limit; iterative counting + node cap must turn
|
|
94
|
+
// this into a structured error, never a RangeError out of the handler.
|
|
95
|
+
const t = toolOf(buildViewPlugin({ getRenderer: () => fixedRenderer(deepChain(200_000)) }));
|
|
96
|
+
let r: PresentViewResult;
|
|
97
|
+
expect(() => {
|
|
98
|
+
r = t.handler({ spec: '<view/>' }, ctx) as PresentViewResult;
|
|
99
|
+
}).not.toThrow();
|
|
100
|
+
expect(r!.ok).toBe(false);
|
|
101
|
+
expect(r!.ast).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('accepts an AST exactly at the ceiling', () => {
|
|
105
|
+
// 1 (stack) + 1999 text children = 2000 nodes — must pass.
|
|
106
|
+
const t = toolOf(buildViewPlugin({ getRenderer: () => fixedRenderer(wideStack(1_999)) }));
|
|
107
|
+
const r = t.handler({ spec: '<view/>' }, ctx) as PresentViewResult;
|
|
108
|
+
expect(r.ok).toBe(true);
|
|
109
|
+
expect(r.nodeCount).toBe(2_000);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('degrades to rendered:false when getSurface throws', () => {
|
|
113
|
+
const t = toolOf(
|
|
114
|
+
buildViewPlugin({
|
|
115
|
+
getRenderer: () => defaultViewRenderer,
|
|
116
|
+
getSurface: () => {
|
|
117
|
+
throw new Error('surface mid-teardown');
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
const r = t.handler({ spec: '<view><text>hi</text></view>' }, ctx) as PresentViewResult;
|
|
122
|
+
expect(r.ok).toBe(true);
|
|
123
|
+
expect(r.rendered).toBe(false);
|
|
124
|
+
expect(r.viewId).toBeUndefined();
|
|
125
|
+
expect(r.url).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('degrades to rendered:false when nextViewId throws', () => {
|
|
129
|
+
const t = toolOf(
|
|
130
|
+
buildViewPlugin({
|
|
131
|
+
getRenderer: () => defaultViewRenderer,
|
|
132
|
+
getSurface: () => ({
|
|
133
|
+
url: 'https://x.trycloudflare.com/?t=k',
|
|
134
|
+
nextViewId: () => {
|
|
135
|
+
throw new Error('minter corrupted');
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
const r = t.handler({ spec: '<view><text>hi</text></view>' }, ctx) as PresentViewResult;
|
|
141
|
+
expect(r.ok).toBe(true);
|
|
142
|
+
expect(r.rendered).toBe(false);
|
|
143
|
+
expect(r.viewId).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('treats an empty-string viewId as a surface fault (rendered:false, no id)', () => {
|
|
147
|
+
const t = toolOf(
|
|
148
|
+
buildViewPlugin({
|
|
149
|
+
getRenderer: () => defaultViewRenderer,
|
|
150
|
+
getSurface: () => ({ url: 'https://x.trycloudflare.com/?t=k', nextViewId: () => '' }),
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
const r = t.handler({ spec: '<view><text>hi</text></view>' }, ctx) as PresentViewResult;
|
|
154
|
+
expect(r.ok).toBe(true);
|
|
155
|
+
expect(r.rendered).toBe(false);
|
|
156
|
+
expect(r.viewId).toBeUndefined();
|
|
157
|
+
expect(r.url).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('degrades to a structured error when a custom renderer.parse throws', () => {
|
|
161
|
+
// The default renderer never throws, but a swapped-in custom renderer is
|
|
162
|
+
// not bound by that guarantee — a throw must become a tool error, not a
|
|
163
|
+
// turn-level exception out of the handler.
|
|
164
|
+
const throwing: ViewRendererDef = {
|
|
165
|
+
name: 'throwing',
|
|
166
|
+
allowList: [],
|
|
167
|
+
parse: () => {
|
|
168
|
+
throw new Error('renderer exploded');
|
|
169
|
+
},
|
|
170
|
+
validate: () => [],
|
|
171
|
+
};
|
|
172
|
+
const t = toolOf(buildViewPlugin({ getRenderer: () => throwing }));
|
|
173
|
+
let r: PresentViewResult;
|
|
174
|
+
expect(() => {
|
|
175
|
+
r = t.handler({ spec: '<view/>' }, ctx) as PresentViewResult;
|
|
176
|
+
}).not.toThrow();
|
|
177
|
+
expect(r!.ok).toBe(false);
|
|
178
|
+
expect(r!.rendered).toBe(false);
|
|
179
|
+
expect(r!.errors?.[0]?.message).toMatch(/renderer exploded/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('rejects a malformed AST (missing root) with an honest message', () => {
|
|
183
|
+
// A custom renderer claims ok:true but returns no usable root; the handler
|
|
184
|
+
// must reject cleanly (and NOT mislabel it "too deeply nested").
|
|
185
|
+
const malformed: ViewRendererDef = {
|
|
186
|
+
name: 'malformed',
|
|
187
|
+
allowList: [],
|
|
188
|
+
parse: () => ({ ok: true, doc: { root: undefined } as unknown as ViewParseResult['doc'] }) as ViewParseResult,
|
|
189
|
+
validate: () => [],
|
|
190
|
+
};
|
|
191
|
+
const t = toolOf(buildViewPlugin({ getRenderer: () => malformed }));
|
|
192
|
+
let r: PresentViewResult;
|
|
193
|
+
expect(() => {
|
|
194
|
+
r = t.handler({ spec: '<view/>' }, ctx) as PresentViewResult;
|
|
195
|
+
}).not.toThrow();
|
|
196
|
+
expect(r!.ok).toBe(false);
|
|
197
|
+
expect(r!.ast).toBeUndefined();
|
|
198
|
+
expect(r!.errors?.[0]?.message).toMatch(/malformed AST/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('degrades when a custom renderer reports a failure with non-iterable errors', () => {
|
|
202
|
+
// ok:false but `errors` is not an array — the handler must not throw on
|
|
203
|
+
// `.map` and must surface a generic failure instead.
|
|
204
|
+
const bad: ViewRendererDef = {
|
|
205
|
+
name: 'bad-errors',
|
|
206
|
+
allowList: [],
|
|
207
|
+
parse: () => ({ ok: false, errors: null as unknown as [] }) as ViewParseResult,
|
|
208
|
+
validate: () => [],
|
|
209
|
+
};
|
|
210
|
+
const t = toolOf(buildViewPlugin({ getRenderer: () => bad }));
|
|
211
|
+
let r: PresentViewResult;
|
|
212
|
+
expect(() => {
|
|
213
|
+
r = t.handler({ spec: '<view/>' }, ctx) as PresentViewResult;
|
|
214
|
+
}).not.toThrow();
|
|
215
|
+
expect(r!.ok).toBe(false);
|
|
216
|
+
expect(r!.errors?.[0]?.message).toMatch(/parse failure/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('short-circuits when the turn is already aborted', () => {
|
|
220
|
+
const t = toolOf(buildViewPlugin({ getRenderer: () => defaultViewRenderer }));
|
|
221
|
+
const ac = new AbortController();
|
|
222
|
+
ac.abort();
|
|
223
|
+
const abortedCtx = { signal: ac.signal } as unknown as ToolContext;
|
|
224
|
+
const r = t.handler({ spec: '<view><text>hi</text></view>' }, abortedCtx) as PresentViewResult;
|
|
225
|
+
expect(r.ok).toBe(false);
|
|
226
|
+
expect(r.rendered).toBe(false);
|
|
227
|
+
expect(r.errors?.[0]?.message).toMatch(/aborted/);
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineTool,
|
|
3
|
+
definePlugin,
|
|
4
|
+
z,
|
|
5
|
+
type LifecycleHooks,
|
|
6
|
+
type Plugin,
|
|
7
|
+
type ViewDoc,
|
|
8
|
+
type ViewNode,
|
|
9
|
+
type ToolContext,
|
|
10
|
+
type ViewRendererDef,
|
|
11
|
+
} from '@moxxy/sdk';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hard ceiling on the validated AST size. The zod `spec` cap (20k chars) only
|
|
15
|
+
* bounds the *source* — rich components (e.g. `results`) expand at parse time
|
|
16
|
+
* into many primitives, and a swapped-in custom renderer is not bound by core's
|
|
17
|
+
* parse-depth cap at all. The full AST is JSON-serialized into the tool_result,
|
|
18
|
+
* round-trips back into model context, and is streamed verbatim to every
|
|
19
|
+
* web-surface client, so it must be bounded independently of the source length.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_VIEW_NODES = 2_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Iterative (stack-safe) node count. Unlike the recursive `countNodes` in the
|
|
25
|
+
* SDK, this cannot overflow the call stack on a pathologically deep AST that a
|
|
26
|
+
* custom renderer might return (core's default parser caps depth, but a
|
|
27
|
+
* swapped-in renderer need not). Short-circuits once `limit` is exceeded so a
|
|
28
|
+
* huge tree is rejected without walking all of it.
|
|
29
|
+
*/
|
|
30
|
+
function countNodesBounded(root: ViewNode, limit: number): number {
|
|
31
|
+
const stack: ViewNode[] = [root];
|
|
32
|
+
let count = 0;
|
|
33
|
+
while (stack.length > 0) {
|
|
34
|
+
const node = stack.pop()!;
|
|
35
|
+
count++;
|
|
36
|
+
if (count > limit) return count;
|
|
37
|
+
if (node.kind === 'element') {
|
|
38
|
+
for (const child of node.children) stack.push(child);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return count;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A live view surface the tool can query for the public URL + the next view id
|
|
46
|
+
* (supplied by the web channel once it co-attaches). Optional: when absent,
|
|
47
|
+
* `present_view` still parses + validates, it just returns no URL.
|
|
48
|
+
*/
|
|
49
|
+
export interface ViewSurface {
|
|
50
|
+
/** The currently reachable URL of the surface, or null if not yet up. */
|
|
51
|
+
readonly url: string | null;
|
|
52
|
+
/** Allocate the next view id for the turn that is presenting. */
|
|
53
|
+
nextViewId(): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BuildViewPluginOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Returns the session's active view renderer. A closure (not the renderer
|
|
59
|
+
* itself) so `setActive`/plugin replacement is honored at call time —
|
|
60
|
+
* ToolContext exposes no session handle, so this is how the tool reaches it.
|
|
61
|
+
*/
|
|
62
|
+
getRenderer: () => ViewRendererDef | null;
|
|
63
|
+
/** Returns the live web surface, if one is co-attached. */
|
|
64
|
+
getSurface?: () => ViewSurface | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildViewPlugin(opts: BuildViewPluginOptions, hooks?: LifecycleHooks): Plugin {
|
|
68
|
+
const presentView = defineTool({
|
|
69
|
+
name: 'present_view',
|
|
70
|
+
description:
|
|
71
|
+
'Render a screen of a generative UI (a "generative / dynamic / agentic UI") from a view-spec ' +
|
|
72
|
+
'(JSX-like, allow-listed tags). Read the `generative-ui` skill first. Use this only when the ' +
|
|
73
|
+
'user explicitly asks for a generative / dynamic / agentic UI (or interactive UI) for X — NOT ' +
|
|
74
|
+
"for ordinary searches or questions, which stay plain text. Give each screen a <view name>; " +
|
|
75
|
+
'call once per screen. The view appears on the user’s web surface; share the returned `url` ' +
|
|
76
|
+
'with them. When the user interacts (submits a form / clicks a button) it arrives as a normal ' +
|
|
77
|
+
'follow-up user message containing a `[ui-action]` block — read it and respond, usually by ' +
|
|
78
|
+
'calling present_view again under the same `name` to update the screen in place.',
|
|
79
|
+
inputSchema: z.object({
|
|
80
|
+
spec: z
|
|
81
|
+
.string()
|
|
82
|
+
.min(1)
|
|
83
|
+
.max(20_000)
|
|
84
|
+
.describe('The view-spec source, a single <view>…</view> root with allow-listed tags.'),
|
|
85
|
+
fallbackText: z
|
|
86
|
+
.string()
|
|
87
|
+
.max(2_000)
|
|
88
|
+
.optional()
|
|
89
|
+
.describe('Plain-text summary shown on channels that cannot render views.'),
|
|
90
|
+
}),
|
|
91
|
+
handler: (input, ctx?: ToolContext): PresentViewResult => {
|
|
92
|
+
// Short-circuit a cancelled turn before the heaviest work (parse + count
|
|
93
|
+
// + AST serialization on a near-20k spec). ctx is optional only so unit
|
|
94
|
+
// tests can call the handler bare; the registry always supplies it.
|
|
95
|
+
if (ctx?.signal?.aborted) {
|
|
96
|
+
return { ok: false, rendered: false, errors: [{ message: 'aborted' }] };
|
|
97
|
+
}
|
|
98
|
+
const renderer = opts.getRenderer();
|
|
99
|
+
if (!renderer) {
|
|
100
|
+
return { ok: false, rendered: false, errors: [{ message: 'no active view renderer' }] };
|
|
101
|
+
}
|
|
102
|
+
// `renderer.parse` is a caller-supplied closure: core's default renderer
|
|
103
|
+
// never throws (it wraps everything, even a RangeError, into a structured
|
|
104
|
+
// result), but a swapped-in custom renderer carries no such guarantee. A
|
|
105
|
+
// throw here would surface as a generic turn-level error instead of a
|
|
106
|
+
// structured tool result, so guard it like every other external closure.
|
|
107
|
+
let result: ReturnType<ViewRendererDef['parse']>;
|
|
108
|
+
try {
|
|
109
|
+
result = renderer.parse(input.spec);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const message = e instanceof Error && e.message ? e.message : 'view renderer failed';
|
|
112
|
+
return { ok: false, rendered: false, errors: [{ message }] };
|
|
113
|
+
}
|
|
114
|
+
if (!result.ok) {
|
|
115
|
+
// `errors` may be malformed (custom renderer); degrade to a single
|
|
116
|
+
// generic error rather than throwing on a non-iterable `.map`.
|
|
117
|
+
const errs = Array.isArray(result.errors)
|
|
118
|
+
? result.errors.map((e) => ({ message: e?.message ?? 'parse error', line: e?.line }))
|
|
119
|
+
: [{ message: 'view renderer reported a parse failure' }];
|
|
120
|
+
return { ok: false, rendered: false, errors: errs };
|
|
121
|
+
}
|
|
122
|
+
// A custom renderer can return `ok:true` with a missing/null doc or root;
|
|
123
|
+
// reject with an honest message instead of letting the count below crash
|
|
124
|
+
// and report "too deeply nested".
|
|
125
|
+
const root = result.doc?.root;
|
|
126
|
+
if (root == null || (root.kind !== 'element' && root.kind !== 'text')) {
|
|
127
|
+
return { ok: false, rendered: false, errors: [{ message: 'view renderer returned a malformed AST' }] };
|
|
128
|
+
}
|
|
129
|
+
// Bound the AST independently of the source length. countNodesBounded is
|
|
130
|
+
// iterative so a deep tree from a custom renderer cannot overflow the
|
|
131
|
+
// stack; we still wrap defensively so the handler can never throw.
|
|
132
|
+
let nodeCount: number;
|
|
133
|
+
try {
|
|
134
|
+
nodeCount = countNodesBounded(root, MAX_VIEW_NODES);
|
|
135
|
+
} catch {
|
|
136
|
+
return { ok: false, rendered: false, errors: [{ message: 'view too deeply nested' }] };
|
|
137
|
+
}
|
|
138
|
+
if (nodeCount > MAX_VIEW_NODES) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
rendered: false,
|
|
142
|
+
errors: [{ message: `view too large (${nodeCount}+ nodes; max ${MAX_VIEW_NODES})` }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// The surface closures are caller-supplied and read mutable shared state
|
|
146
|
+
// (web channel minter); never let a fault there turn a successful parse
|
|
147
|
+
// into a turn-level throw — degrade to the documented parsed-only result.
|
|
148
|
+
let surface: ViewSurface | null = null;
|
|
149
|
+
let viewId: string | undefined;
|
|
150
|
+
try {
|
|
151
|
+
surface = opts.getSurface?.() ?? null;
|
|
152
|
+
viewId = surface?.nextViewId();
|
|
153
|
+
// An empty-string id is a minter fault, not "no surface": treat the
|
|
154
|
+
// surface as absent rather than claiming rendered:true with no id.
|
|
155
|
+
if (surface != null && (viewId == null || viewId === '')) {
|
|
156
|
+
surface = null;
|
|
157
|
+
viewId = undefined;
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
surface = null;
|
|
161
|
+
viewId = undefined;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
rendered: surface != null,
|
|
166
|
+
...(surface?.url ? { url: surface.url } : {}),
|
|
167
|
+
...(viewId != null ? { viewId } : {}),
|
|
168
|
+
nodeCount,
|
|
169
|
+
ast: result.doc,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// No hardcoded `version`: it only ever drifts from package.json. The real
|
|
175
|
+
// package version is captured from the resolved plugin manifest at load time.
|
|
176
|
+
return definePlugin({
|
|
177
|
+
name: '@moxxy/plugin-view',
|
|
178
|
+
tools: [presentView],
|
|
179
|
+
...(hooks ? { hooks } : {}),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Minimal views of the services view consumes — avoids importing @moxxy/core. */
|
|
184
|
+
interface ActiveRendererSource {
|
|
185
|
+
getActive(): ViewRendererDef | null;
|
|
186
|
+
}
|
|
187
|
+
interface ViewSurfaceRef {
|
|
188
|
+
readonly current: ViewSurface | null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Discovery-loadable default export. Resolves the active view-renderer registry
|
|
193
|
+
* (`'viewRenderers'`) and the shared web-surface ref (`'viewSurface'`, published
|
|
194
|
+
* by the host + written by the web channel) from the inter-plugin service
|
|
195
|
+
* registry in `onInit`, so `present_view` reaches them without a host-injected
|
|
196
|
+
* `{ getRenderer, getSurface }` closure. Both reads are lazy (at tool-call time),
|
|
197
|
+
* and degrade to "no renderer"/"no surface" if the host hasn't published them.
|
|
198
|
+
*/
|
|
199
|
+
export const viewPlugin: Plugin = (() => {
|
|
200
|
+
let renderers: ActiveRendererSource | null = null;
|
|
201
|
+
let surface: ViewSurfaceRef | null = null;
|
|
202
|
+
const hooks: LifecycleHooks = {
|
|
203
|
+
onInit: (ctx) => {
|
|
204
|
+
renderers = ctx.services.get<ActiveRendererSource>('viewRenderers') ?? null;
|
|
205
|
+
surface = ctx.services.get<ViewSurfaceRef>('viewSurface') ?? null;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
return buildViewPlugin(
|
|
209
|
+
{
|
|
210
|
+
getRenderer: () => renderers?.getActive() ?? null,
|
|
211
|
+
getSurface: () => surface?.current ?? null,
|
|
212
|
+
},
|
|
213
|
+
hooks,
|
|
214
|
+
);
|
|
215
|
+
})();
|
|
216
|
+
|
|
217
|
+
export interface PresentViewResult {
|
|
218
|
+
readonly ok: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* Whether a live web surface is ATTACHED (false = parsed only, no surface).
|
|
221
|
+
* NOT a delivery confirmation: the view is handed to the surface
|
|
222
|
+
* asynchronously when the channel's projector observes this tool_result, so
|
|
223
|
+
* `rendered: true` only means a surface exists to deliver to — a browser may
|
|
224
|
+
* not yet be connected to it. Do not treat it as "the user definitely sees
|
|
225
|
+
* the screen".
|
|
226
|
+
*/
|
|
227
|
+
readonly rendered: boolean;
|
|
228
|
+
readonly url?: string;
|
|
229
|
+
readonly viewId?: string;
|
|
230
|
+
readonly nodeCount?: number;
|
|
231
|
+
/** Validated AST — the web surface reads this from the tool_result. */
|
|
232
|
+
readonly ast?: ViewDoc;
|
|
233
|
+
readonly errors?: ReadonlyArray<{ message: string; line?: number }>;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
237
|
+
export default viewPlugin;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Session } from '@moxxy/core';
|
|
3
|
+
import { buildViewPlugin, type PresentViewResult } from './index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Integration: a REAL Session (which seeds the default view renderer) with the
|
|
7
|
+
* view plugin registered, exercising present_view through the real tool
|
|
8
|
+
* registry — input is validated by the tool's zod schema, the handler reaches
|
|
9
|
+
* the session's active renderer via the closure, and returns the AST.
|
|
10
|
+
*/
|
|
11
|
+
function sessionWithView(): Session {
|
|
12
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
13
|
+
session.pluginHost.registerStatic(
|
|
14
|
+
buildViewPlugin({ getRenderer: () => session.viewRenderers.getActive() }),
|
|
15
|
+
);
|
|
16
|
+
return session;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('plugin-view integration with a real Session', () => {
|
|
20
|
+
it('registers present_view and the default renderer is active', () => {
|
|
21
|
+
const session = sessionWithView();
|
|
22
|
+
expect(session.tools.has('present_view')).toBe(true);
|
|
23
|
+
expect(session.viewRenderers.getActive()?.name).toBe('moxxy/default');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('executes present_view end-to-end and returns a validated AST', async () => {
|
|
27
|
+
const session = sessionWithView();
|
|
28
|
+
const out = (await session.tools.execute(
|
|
29
|
+
'present_view',
|
|
30
|
+
{ spec: '<view title="Trip"><stack><text>hi</text></stack></view>' },
|
|
31
|
+
new AbortController().signal,
|
|
32
|
+
)) as PresentViewResult;
|
|
33
|
+
expect(out.ok).toBe(true);
|
|
34
|
+
expect(out.ast?.title).toBe('Trip');
|
|
35
|
+
expect(out.ast?.root.kind === 'element' && out.ast.root.tag).toBe('view');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns parse errors (not a throw) for a disallowed tag', async () => {
|
|
39
|
+
const session = sessionWithView();
|
|
40
|
+
const out = (await session.tools.execute(
|
|
41
|
+
'present_view',
|
|
42
|
+
{ spec: '<view><iframe/></view>' },
|
|
43
|
+
new AbortController().signal,
|
|
44
|
+
)) as PresentViewResult;
|
|
45
|
+
expect(out.ok).toBe(false);
|
|
46
|
+
expect(out.errors?.some((e) => /unknown tag <iframe>/.test(e.message))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects schema-invalid input at the registry boundary', async () => {
|
|
50
|
+
const session = sessionWithView();
|
|
51
|
+
await expect(
|
|
52
|
+
session.tools.execute('present_view', { spec: '' }, new AbortController().signal),
|
|
53
|
+
).rejects.toThrow(/Invalid input/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('honours a swapped-in renderer via setActive', async () => {
|
|
57
|
+
const session = sessionWithView();
|
|
58
|
+
session.viewRenderers.replace({
|
|
59
|
+
name: 'stub',
|
|
60
|
+
allowList: [],
|
|
61
|
+
parse: () => ({ ok: true, doc: { root: { kind: 'element', tag: 'view', props: {}, children: [] } } }),
|
|
62
|
+
validate: () => [],
|
|
63
|
+
});
|
|
64
|
+
session.viewRenderers.setActive('stub');
|
|
65
|
+
const out = (await session.tools.execute(
|
|
66
|
+
'present_view',
|
|
67
|
+
{ spec: 'anything the stub ignores' },
|
|
68
|
+
new AbortController().signal,
|
|
69
|
+
)) as PresentViewResult;
|
|
70
|
+
expect(out.ok).toBe(true);
|
|
71
|
+
expect(out.ast?.root.kind === 'element' && out.ast.root.tag).toBe('view');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ToolContext, ToolDef } from '@moxxy/sdk';
|
|
3
|
+
import { defaultViewRenderer } from '@moxxy/core';
|
|
4
|
+
import { buildViewPlugin, type PresentViewResult, type ViewSurface } from './index.js';
|
|
5
|
+
|
|
6
|
+
const ctx = {} as ToolContext;
|
|
7
|
+
function tool(surface?: ViewSurface | null): ToolDef {
|
|
8
|
+
const plugin = buildViewPlugin({
|
|
9
|
+
getRenderer: () => defaultViewRenderer,
|
|
10
|
+
...(surface !== undefined ? { getSurface: () => surface } : {}),
|
|
11
|
+
});
|
|
12
|
+
const t = plugin.tools?.find((x) => x.name === 'present_view');
|
|
13
|
+
if (!t) throw new Error('present_view missing');
|
|
14
|
+
return t;
|
|
15
|
+
}
|
|
16
|
+
const run = (t: ToolDef, spec: string) => t.handler({ spec }, ctx) as PresentViewResult;
|
|
17
|
+
|
|
18
|
+
describe('present_view — input schema', () => {
|
|
19
|
+
const t = tool();
|
|
20
|
+
it('requires a non-empty spec', () => {
|
|
21
|
+
expect(t.inputSchema.safeParse({ spec: '' }).success).toBe(false);
|
|
22
|
+
expect(t.inputSchema.safeParse({ spec: '<view/>' }).success).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it('caps spec length at 20k', () => {
|
|
25
|
+
expect(t.inputSchema.safeParse({ spec: 'x'.repeat(20_001) }).success).toBe(false);
|
|
26
|
+
expect(t.inputSchema.safeParse({ spec: 'x'.repeat(20_000) }).success).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it('accepts an optional fallbackText (capped)', () => {
|
|
29
|
+
expect(t.inputSchema.safeParse({ spec: '<view/>', fallbackText: 'summary' }).success).toBe(true);
|
|
30
|
+
expect(t.inputSchema.safeParse({ spec: '<view/>', fallbackText: 'x'.repeat(2_001) }).success).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('present_view — output', () => {
|
|
35
|
+
it('returns a view-rooted AST and an accurate nodeCount', () => {
|
|
36
|
+
const r = run(tool(), '<view title="t"><stack><text>a</text><text>b</text></stack></view>');
|
|
37
|
+
expect(r.ok).toBe(true);
|
|
38
|
+
expect(r.ast?.root.kind).toBe('element');
|
|
39
|
+
expect(r.ast?.root.kind === 'element' && r.ast.root.tag).toBe('view');
|
|
40
|
+
// view > stack > (text>txt, text>txt) = 1 + 1 + (2 + 2) = 6
|
|
41
|
+
expect(r.nodeCount).toBe(6);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('surfaces parse errors with messages', () => {
|
|
45
|
+
const r = run(tool(), '<view><iframe/></view>');
|
|
46
|
+
expect(r.ok).toBe(false);
|
|
47
|
+
expect(r.rendered).toBe(false);
|
|
48
|
+
expect(r.errors?.some((e) => /unknown tag <iframe>/.test(e.message))).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('reports no active renderer', () => {
|
|
52
|
+
const plugin = buildViewPlugin({ getRenderer: () => null });
|
|
53
|
+
const t = plugin.tools!.find((x) => x.name === 'present_view')!;
|
|
54
|
+
const r = run(t, '<view/>');
|
|
55
|
+
expect(r.ok).toBe(false);
|
|
56
|
+
expect(r.errors?.[0]?.message).toMatch(/no active view renderer/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('without a surface: rendered=false, no url/viewId', () => {
|
|
60
|
+
const r = run(tool(null), '<view/>');
|
|
61
|
+
expect(r.ok).toBe(true);
|
|
62
|
+
expect(r.rendered).toBe(false);
|
|
63
|
+
expect(r.url).toBeUndefined();
|
|
64
|
+
expect(r.viewId).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('with a surface: returns url and a fresh viewId each call', () => {
|
|
68
|
+
let n = 0;
|
|
69
|
+
const surface: ViewSurface = { url: 'https://x.trycloudflare.com/?t=k', nextViewId: () => `v_${++n}` };
|
|
70
|
+
const t = tool(surface);
|
|
71
|
+
const a = run(t, '<view/>');
|
|
72
|
+
const b = run(t, '<view/>');
|
|
73
|
+
expect(a.rendered).toBe(true);
|
|
74
|
+
expect(a.url).toBe('https://x.trycloudflare.com/?t=k');
|
|
75
|
+
expect(a.viewId).toBe('v_1');
|
|
76
|
+
expect(b.viewId).toBe('v_2');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('present_view — plugin shape', () => {
|
|
81
|
+
it('contributes exactly the present_view tool', () => {
|
|
82
|
+
const plugin = buildViewPlugin({ getRenderer: () => defaultViewRenderer });
|
|
83
|
+
expect(plugin.tools?.map((t) => t.name)).toEqual(['present_view']);
|
|
84
|
+
expect(plugin.name).toBe('@moxxy/plugin-view');
|
|
85
|
+
});
|
|
86
|
+
});
|