@nxisai/agent-harness 0.7.0 → 0.9.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.
Files changed (62) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +17 -5
  3. package/dist/a2ui/cards.d.ts +3 -0
  4. package/dist/a2ui/cards.d.ts.map +1 -1
  5. package/dist/a2ui/cards.js +11 -2
  6. package/dist/a2ui/cards.js.map +1 -1
  7. package/dist/harness/cta.d.ts +27 -0
  8. package/dist/harness/cta.d.ts.map +1 -0
  9. package/dist/harness/cta.js +43 -0
  10. package/dist/harness/cta.js.map +1 -0
  11. package/dist/harness/harness.d.ts +33 -0
  12. package/dist/harness/harness.d.ts.map +1 -1
  13. package/dist/harness/harness.js +84 -2
  14. package/dist/harness/harness.js.map +1 -1
  15. package/dist/harness/recommend.d.ts +46 -0
  16. package/dist/harness/recommend.d.ts.map +1 -0
  17. package/dist/harness/recommend.js +144 -0
  18. package/dist/harness/recommend.js.map +1 -0
  19. package/dist/harness/records.d.ts +70 -0
  20. package/dist/harness/records.d.ts.map +1 -0
  21. package/dist/harness/records.js +209 -0
  22. package/dist/harness/records.js.map +1 -0
  23. package/dist/harness/renderer.d.ts +11 -1
  24. package/dist/harness/renderer.d.ts.map +1 -1
  25. package/dist/harness/router/coordinator.d.ts +6 -0
  26. package/dist/harness/router/coordinator.d.ts.map +1 -1
  27. package/dist/harness/router/coordinator.js +14 -0
  28. package/dist/harness/router/coordinator.js.map +1 -1
  29. package/dist/harness/tools/action-tools.d.ts +25 -0
  30. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  31. package/dist/harness/tools/action-tools.js +39 -0
  32. package/dist/harness/tools/action-tools.js.map +1 -1
  33. package/dist/harness/tools/inbound.d.ts +88 -0
  34. package/dist/harness/tools/inbound.d.ts.map +1 -0
  35. package/dist/harness/tools/inbound.js +156 -0
  36. package/dist/harness/tools/inbound.js.map +1 -0
  37. package/dist/harness/tools/site-pages.js +1 -1
  38. package/dist/index.d.ts +7 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +5 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  43. package/dist/ui/agent-fullscreen.js +154 -76
  44. package/dist/ui/agent-fullscreen.js.map +1 -1
  45. package/dist/ui/result-pick.d.ts +52 -0
  46. package/dist/ui/result-pick.d.ts.map +1 -0
  47. package/dist/ui/result-pick.js +196 -0
  48. package/dist/ui/result-pick.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/a2ui/cards.ts +14 -2
  51. package/src/harness/cta.ts +54 -0
  52. package/src/harness/harness.ts +93 -2
  53. package/src/harness/recommend.ts +194 -0
  54. package/src/harness/records.ts +237 -0
  55. package/src/harness/renderer.ts +12 -1
  56. package/src/harness/router/coordinator.ts +14 -0
  57. package/src/harness/tools/action-tools.ts +41 -0
  58. package/src/harness/tools/inbound.ts +189 -0
  59. package/src/harness/tools/site-pages.ts +1 -1
  60. package/src/index.ts +16 -0
  61. package/src/ui/agent-fullscreen.ts +162 -66
  62. package/src/ui/result-pick.ts +233 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Arriving from somewhere else to use one of this page's forms.
3
+ *
4
+ * The NxisAI app in Claude or ChatGPT cannot fill a form that runs in the
5
+ * browser, so for such an action it sends the person to the page instead:
6
+ * `?nxis_handoff=<the action's @id>`, and optionally `&nxis_intent=<what they
7
+ * want>`. On arrival the agent opens on that form.
8
+ *
9
+ * In the query, so the fragment, which some sites route by, is left alone. A
10
+ * page's structured data is found by its registered address, so the site tag
11
+ * leaves these two parameters out of the address it asks for.
12
+ *
13
+ * A link can be written by anyone, so it only ever chooses which of the page's
14
+ * own forms to start. The words that start it are the agent's own, built from
15
+ * the form; the intent is left in the composer for the person to send, change
16
+ * or delete, never sent on their behalf. Neither parameter carries anything
17
+ * personal, and both are taken out of the address bar once read.
18
+ *
19
+ * @module
20
+ */
21
+ import { awaitPageTool, withoutSiteSuffix } from './action-tools.js';
22
+ import { waitForModelContext } from './source.js';
23
+ import type { ModelContext, ModelContextToolInfo } from '../../types.js';
24
+
25
+ /** The action to open on: its `@id`. */
26
+ export const HANDOFF_PARAM = 'nxis_handoff';
27
+ /** What the person wants, in a few words. Optional. */
28
+ export const INTENT_PARAM = 'nxis_intent';
29
+
30
+ /** Longer than any `@id` the crawler writes; a longer value is not one. */
31
+ const MAX_ID = 512;
32
+ /** A few words: enough for "a quote for a panel upgrade", not a message. */
33
+ export const MAX_INTENT = 120;
34
+
35
+ /** How long to wait for the registry, and then for the form's own tool. */
36
+ const WAIT_MS = 8000;
37
+
38
+ /** A handoff read from the address. */
39
+ export interface InboundHandoff {
40
+ /** The `@id` of the action whose form to open. */
41
+ actionId: string;
42
+ /** What the person wants, cleaned up; absent when none was given. */
43
+ intent?: string;
44
+ }
45
+
46
+ /**
47
+ * The handoff in an address, if there is one.
48
+ *
49
+ * @param href - The page's address.
50
+ * @returns The handoff, or null when there is none or it is malformed.
51
+ */
52
+ export function readInboundHandoff(href: string): InboundHandoff | null {
53
+ let url: URL;
54
+ try {
55
+ url = new URL(href);
56
+ } catch {
57
+ return null;
58
+ }
59
+ const actionId = (url.searchParams.get(HANDOFF_PARAM) ?? '').trim();
60
+ if (!actionId || actionId.length > MAX_ID || /\s/.test(actionId)) return null;
61
+ const intent = cleanIntent(url.searchParams.get(INTENT_PARAM) ?? '');
62
+ return intent ? { actionId, intent } : { actionId };
63
+ }
64
+
65
+ /** One line of plain text, at most {@link MAX_INTENT} characters. */
66
+ export function cleanIntent(raw: string): string {
67
+ // Control characters, newlines included, become spaces: a draft is one line.
68
+ const flat = raw.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
69
+ return flat.length > MAX_INTENT ? flat.slice(0, MAX_INTENT - 1).trimEnd() + '…' : flat;
70
+ }
71
+
72
+ /**
73
+ * The address without the handoff parameters, everything else kept.
74
+ *
75
+ * A reload, a bookmark or a copied link would otherwise open the form again.
76
+ */
77
+ export function withoutHandoff(href: string): string {
78
+ const url = new URL(href);
79
+ if (!url.search) return url.toString();
80
+ // Pair by pair on the raw text: re-serializing would re-encode the other
81
+ // parameters, and the address would no longer be the one the page had.
82
+ const kept = url.search
83
+ .slice(1)
84
+ .split('&')
85
+ .filter((pair) => ![HANDOFF_PARAM, INTENT_PARAM].includes(decodeName(pair.split('=')[0])));
86
+ url.search = kept.length ? `?${kept.join('&')}` : '';
87
+ return url.toString();
88
+ }
89
+
90
+ /** A query parameter's name, decoded; as written when it can't be. */
91
+ function decodeName(raw: string): string {
92
+ try {
93
+ return decodeURIComponent(raw.replace(/\+/g, ' '));
94
+ } catch {
95
+ return raw;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The address that hands someone to an action's page.
101
+ *
102
+ * @param pageUrl - The page the action's form is on.
103
+ * @param actionId - The action's `@id`.
104
+ * @param intent - What they want, in a few words. Nothing personal.
105
+ */
106
+ export function handoffUrl(pageUrl: string, actionId: string, intent?: string): string {
107
+ const url = new URL(pageUrl);
108
+ // Appended to the raw query, so the page's own parameters keep their encoding.
109
+ const added = new URLSearchParams({ [HANDOFF_PARAM]: actionId });
110
+ const said = intent ? cleanIntent(intent) : '';
111
+ if (said) added.set(INTENT_PARAM, said);
112
+ url.search = url.search ? `${url.search}&${added}` : `?${added}`;
113
+ return url.toString();
114
+ }
115
+
116
+ /**
117
+ * Read the handoff from this page's address and take it out.
118
+ *
119
+ * @param opts - The address and the history to rewrite; the browser's by default.
120
+ */
121
+ export function takeInboundHandoff(
122
+ opts: { href?: string; replace?: (href: string) => void } = {},
123
+ ): InboundHandoff | null {
124
+ const href = opts.href ?? (typeof location !== 'undefined' ? location.href : '');
125
+ const handoff = href ? readInboundHandoff(href) : null;
126
+ if (!handoff) return null;
127
+ const replace =
128
+ opts.replace ??
129
+ ((next: string) => {
130
+ try {
131
+ history.replaceState(history.state, '', next);
132
+ } catch {
133
+ // A sandboxed frame can refuse; the handoff is still taken once, below.
134
+ }
135
+ });
136
+ replace(withoutHandoff(href));
137
+ return handoff;
138
+ }
139
+
140
+ /** What the agent says to start a form: "Start the quote form". */
141
+ export function startMessage(tool: ModelContextToolInfo): string {
142
+ return startFormMessage(withoutSiteSuffix(tool.name).replace(/[_-]+/g, ' ').trim().toLowerCase());
143
+ }
144
+
145
+ /**
146
+ * What the agent says to start a site form for one business, in the words a
147
+ * result card's button uses: "Get a quote from McManus Electric".
148
+ */
149
+ export function startFormMessage(noun: string, choice?: string): string {
150
+ const named = noun || 'form';
151
+ const form = /\bform\b/.test(named) ? named : `${named} form`;
152
+ if (!choice) return `Start the ${form}`;
153
+ return /quote/.test(named) ? `Get a quote from ${choice}` : `Start the ${form} for ${choice}`;
154
+ }
155
+
156
+ /** Where the form a handoff names stands, once the page has loaded. */
157
+ export type ArrivalOutcome =
158
+ /** WebMCP never appeared, so the page's forms cannot be reached. */
159
+ | { status: 'no-registry' }
160
+ /** The page has no form for that action. */
161
+ | { status: 'no-tool' }
162
+ /** The form is here. */
163
+ | { status: 'ready'; tool: ModelContextToolInfo };
164
+
165
+ /** Overrides, so this can be exercised without a page. */
166
+ export interface ArrivalOptions {
167
+ /** How long to wait for the registry, and then for the tool. */
168
+ timeoutMs?: number;
169
+ /** Wait for a WebMCP registry. */
170
+ registry?(timeoutMs: number): Promise<ModelContext | null>;
171
+ /** Wait for the page's own tool for this action. */
172
+ pageTool?(mc: ModelContext, ref: { id: string; name: string }, timeoutMs: number): Promise<ModelContextToolInfo | null>;
173
+ }
174
+
175
+ /**
176
+ * Find the form a handoff names, waiting for the page to register it.
177
+ *
178
+ * Runs while the page is still loading, as the cross-page resume does: the
179
+ * registry and the form's tool usually appear a moment after the agent.
180
+ */
181
+ export async function findHandoffForm(handoff: InboundHandoff, opts: ArrivalOptions = {}): Promise<ArrivalOutcome> {
182
+ const timeoutMs = opts.timeoutMs ?? WAIT_MS;
183
+ const mc = await (opts.registry ?? ((ms: number) => waitForModelContext(ms)))(timeoutMs);
184
+ if (!mc) return { status: 'no-registry' };
185
+ // By id only: a name match could be a different form on this page.
186
+ const find = opts.pageTool ?? ((c, ref, ms) => awaitPageTool(c, ref, ms));
187
+ const tool = await find(mc, { id: handoff.actionId, name: '' }, timeoutMs);
188
+ return tool ? { status: 'ready', tool } : { status: 'no-tool' };
189
+ }
@@ -194,7 +194,7 @@ export function pageForEntity(entity: Obj, pages: SitePage[], origin: string): S
194
194
  /**
195
195
  * The page an id formed from it names, or null.
196
196
  *
197
- * nxis-sdn-api forms a page entity's id as the page URL plus a fragment, so the page is the id with
197
+ * The NxisAI index forms a page entity's id as the page URL plus a fragment, so the page is the id with
198
198
  * the fragment removed.
199
199
  */
200
200
  function pageOfId(id: string | undefined, origin: string): string | null {
package/src/index.ts CHANGED
@@ -50,6 +50,9 @@ export type { Slot, Resolution, ResolveSources } from './harness/slots.js';
50
50
 
51
51
  export { buildPlan } from './harness/plan.js';
52
52
 
53
+ export { actionCta } from './harness/cta.js';
54
+ export type { CardAction } from './harness/cta.js';
55
+
53
56
  export { AgentCoordinator } from './harness/router/coordinator.js';
54
57
  export type { CoordinatorOptions, RoutedTurn, RouteSource } from './harness/router/coordinator.js';
55
58
  export { matchRoute } from './harness/router/rules.js';
@@ -60,6 +63,7 @@ export type {
60
63
  HarnessRenderer,
61
64
  AskRequest,
62
65
  ConfirmRequest,
66
+ ResultsExtra,
63
67
  StreamingBubble,
64
68
  } from './harness/renderer.js';
65
69
 
@@ -133,6 +137,16 @@ export {
133
137
  export type { AgentStep, AgentStepId, AgentStepStatus } from './harness/steps.js';
134
138
  export { resumePendingAction } from './harness/tools/resume.js';
135
139
  export type { ResumeOutcome, ResumeOptions } from './harness/tools/resume.js';
140
+ // Arriving from the NxisAI app to use one of this page's forms.
141
+ export {
142
+ HANDOFF_PARAM,
143
+ INTENT_PARAM,
144
+ readInboundHandoff,
145
+ takeInboundHandoff,
146
+ handoffUrl,
147
+ findHandoffForm,
148
+ } from './harness/tools/inbound.js';
149
+ export type { InboundHandoff, ArrivalOutcome, ArrivalOptions } from './harness/tools/inbound.js';
136
150
  export {
137
151
  loadSitePages,
138
152
  parsePage,
@@ -172,6 +186,8 @@ export {
172
186
  } from './harness/tools/pending-action.js';
173
187
  export type { PendingAction, PendingVisit } from './harness/tools/pending-action.js';
174
188
  export { findEmbeddedAction, loadSiteActions, parseAction, type EmbeddedActionQuery } from './harness/tools/site-actions.js';
189
+ export { extractRecords, openStatus, recordFacts, factLines, type OpenStatus, type RecordFacts } from './harness/records.js';
190
+ export { recommend, grounded, RECOMMEND_TOOL, type ResultPick, type RecommendRequest } from './harness/recommend.js';
175
191
  export type { ConnectResult, ConnectOptions, SiteActionsOptions } from './harness/tools/source.js';
176
192
  export type { BridgeOptions } from './harness/tools/bridge.js';
177
193
 
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { mountSurface, type MountedSurface } from '../a2ui/host.js';
24
24
  import { emitCardsSurface, type ResultCardData } from '../a2ui/cards.js';
25
+ import { actionCta, type CardAction } from '../harness/cta.js';
25
26
  import { emitToolForm } from '../a2ui/form.js';
26
27
  import { A2UI_THEME_CSS } from '../a2ui/theme.js';
27
28
  // Compiled from agent.tw.css at build time (scripts/css-to-ts.mjs). It is a
@@ -43,8 +44,10 @@ import { connectTools } from '../harness/tools/source.js';
43
44
  import {
44
45
  chooserOf,
45
46
  formOptions,
47
+ formsFor,
46
48
  handoffFor,
47
49
  isSiteTool,
50
+ siteFormFor,
48
51
  withoutSiteSuffix,
49
52
  HANDOFF_RESULT,
50
53
  } from '../harness/tools/action-tools.js';
@@ -59,7 +62,11 @@ import {
59
62
  type ActionItem,
60
63
  type ActionStep,
61
64
  } from './action-card.js';
65
+ import { resultsAnswer, RESULTS_ANSWER_CSS, type AnswerCard } from './result-pick.js';
66
+ import { extractRecords, openStatus, recordFacts } from '../harness/records.js';
67
+ import type { ResultPick } from '../harness/recommend.js';
62
68
  import { resumePendingAction } from '../harness/tools/resume.js';
69
+ import { findHandoffForm, startFormMessage, startMessage, takeInboundHandoff } from '../harness/tools/inbound.js';
63
70
  import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
64
71
  import { pageForEntity, type SitePage } from '../harness/tools/site-pages.js';
65
72
  import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
@@ -329,6 +336,16 @@ interface ResultCard {
329
336
  id?: string;
330
337
  /** The page on this site the record came from, when it can be told. */
331
338
  page?: { url: string; label: string };
339
+ /** The rating and how many reviews it rests on. */
340
+ stars?: number;
341
+ reviews?: number;
342
+ /** Open or closed when the search ran: "Open now · 24/7", "Opens 7am". */
343
+ status?: { open: boolean; label: string };
344
+ phone?: string;
345
+ /** What the record states, short, for comparing it. */
346
+ facts?: string[];
347
+ /** The action the record itself offers (its potentialAction), as a button. */
348
+ action?: CardAction;
332
349
  }
333
350
 
334
351
  type ThreadItem =
@@ -337,7 +354,7 @@ type ThreadItem =
337
354
  | { kind: 'trace'; name: string; args: Record<string, unknown>; text: string; isError: boolean; count?: number }
338
355
  | { kind: 'route'; agent: string; via: string }
339
356
  | { kind: 'detail'; title: string; note: string; rows: DetailRow[] }
340
- | { kind: 'results'; title: string; note: string; cards: ResultCard[] }
357
+ | { kind: 'results'; title: string; note: string; cards: ResultCard[]; pick?: ResultPick }
341
358
  | { kind: 'metrics'; rounds: MetricsRound[] }
342
359
  | { kind: 'form' }
343
360
  /** A turn the person called off. Kept so the thread explains its own gap. */
@@ -670,6 +687,8 @@ class NxisAgentFullscreen extends HTMLElement {
670
687
  // belongs to is the one open.
671
688
  void this._resumePendingAction();
672
689
  this._resumePendingVisit();
690
+ // Sent here from the NxisAI app to use one of this page's forms.
691
+ void this._arriveFromHandoff();
673
692
 
674
693
  this._els.launcher.addEventListener('click', () => this._open());
675
694
  this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
@@ -1577,7 +1596,14 @@ class NxisAgentFullscreen extends HTMLElement {
1577
1596
  }
1578
1597
 
1579
1598
  // ── Turn loop ──────────────────────────────────────────────────────────────
1580
- private async _onSend(): Promise<void> {
1599
+ /**
1600
+ * Send what is in the composer.
1601
+ *
1602
+ * @param call - A tool call already decided, run without asking the model
1603
+ * (a handoff names its form). Falls back to an ordinary turn when no agent
1604
+ * here holds the tool.
1605
+ */
1606
+ private async _onSend(call?: { name: string; args: Record<string, unknown> }): Promise<void> {
1581
1607
  const goal = this._els.input.value.trim();
1582
1608
  if (!goal || this._turnActive) return;
1583
1609
  this._els.input.value = '';
@@ -1614,7 +1640,9 @@ class NxisAgentFullscreen extends HTMLElement {
1614
1640
  await this._connectTools();
1615
1641
  this._syncProfileContext();
1616
1642
  // One agent, or several with a router in front: the signatures match.
1617
- await (this._coordinator ?? this._harness).turn(goal, conv!.messages);
1643
+ const engine = this._coordinator ?? this._harness;
1644
+ const begun = call ? await engine.begin(goal, call.name, call.args, conv!.messages) : null;
1645
+ if (!begun || begun.decision === 'unavailable') await engine.turn(goal, conv!.messages);
1618
1646
  // A remote endpoint reports which model answered; the name beside the
1619
1647
  // input follows it rather than keeping whatever was configured.
1620
1648
  this._paintProvenance();
@@ -1646,9 +1674,9 @@ class NxisAgentFullscreen extends HTMLElement {
1646
1674
  ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
1647
1675
  confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
1648
1676
  left: (tool, destination) => this._cardLeft(tool, destination),
1649
- results: (tool, res) => {
1677
+ results: (tool, res, extra) => {
1650
1678
  const conv = this._active();
1651
- const item = this._buildResultsItem(tool, res);
1679
+ const item = this._buildResultsItem(tool, res, extra?.pick);
1652
1680
  // A read that answers in words — "you are on the home page", "already open" — has no records
1653
1681
  // to draw, and an empty result card reading "No results found." contradicts the answer it
1654
1682
  // just gave. Say what it said.
@@ -2582,61 +2610,9 @@ class NxisAgentFullscreen extends HTMLElement {
2582
2610
  * return the first that yields records. Shapes covered: query → results[],
2583
2611
  * list_types → types[], get → result{}, or a bare entity/array. */
2584
2612
  private _extractItems(res: ModelContextToolResponse | string | null): Record<string, unknown>[] {
2585
- const sources: unknown[] = [];
2586
- // Collect every candidate payload, recursively unwrapping tool-response
2587
- // envelopes. `res` can be an object, a JSON string, or (with a WebMCP browser
2588
- // extension in the loop) the whole `{content, structuredContent}` envelope
2589
- // serialized to a string, possibly more than once. Diving into both
2590
- // `structuredContent` and each `content[].text` covers all of them.
2591
- const visit = (raw: unknown, depth = 0): void => {
2592
- if (raw == null || depth > 5) return;
2593
- const val: unknown = raw;
2594
- if (typeof val === 'string') {
2595
- const t = val.trim();
2596
- if (!t) return;
2597
- let parsed: unknown;
2598
- try {
2599
- parsed = JSON.parse(t);
2600
- } catch {
2601
- return; // plain (non-JSON) text — nothing to extract
2602
- }
2603
- // Parsed value may itself be an object/array OR another JSON string
2604
- // (double-encoded); recurse either way.
2605
- return visit(parsed, depth + 1);
2606
- }
2607
- if (!val || typeof val !== 'object') return;
2608
- sources.push(val);
2609
- const v = val as Record<string, unknown>;
2610
- if (v.structuredContent != null) visit(v.structuredContent, depth + 1);
2611
- if (Array.isArray(v.content)) {
2612
- for (const c of v.content) {
2613
- if (c && typeof c === 'object') visit((c as Record<string, unknown>).text, depth + 1);
2614
- }
2615
- }
2616
- };
2617
- visit(res);
2618
-
2619
- for (const data of sources) {
2620
- const items = this._itemsFrom(data);
2621
- if (items.length) return items;
2622
- }
2623
- console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
2624
- return [];
2625
- }
2626
-
2627
- /** Find the record array within one parsed payload. */
2628
- private _itemsFrom(data: unknown): Record<string, unknown>[] {
2629
- const objs = (arr: unknown[]) =>
2630
- arr.filter((x) => x && typeof x === 'object') as Record<string, unknown>[];
2631
- if (Array.isArray(data)) return objs(data);
2632
- if (!data || typeof data !== 'object') return [];
2633
- const d = data as Record<string, unknown>;
2634
- for (const key of ['results', 'types', 'items', 'entities', 'data']) {
2635
- if (Array.isArray(d[key])) return objs(d[key] as unknown[]);
2636
- }
2637
- if (d.result && typeof d.result === 'object') return [d.result as Record<string, unknown>];
2638
- if (d['@id'] != null || d['@type'] != null || d.name != null) return [d];
2639
- return [];
2613
+ const items = extractRecords(res);
2614
+ if (!items.length) console.warn('[nxis-mcp] no items extracted from tool result', { res });
2615
+ return items;
2640
2616
  }
2641
2617
 
2642
2618
  /** Build a display card from a single record (a JSON-LD entity or a type row). */
@@ -2704,19 +2680,88 @@ class NxisAgentFullscreen extends HTMLElement {
2704
2680
  const id = entity['@id'] != null ? str(entity['@id']) : undefined;
2705
2681
  const found = pageForEntity(entity, this._sitePages, location.origin);
2706
2682
  const page = found ? { url: found.url, label: found.name } : undefined;
2707
- return { title, type, rating, emergency, sub, rows, id, ...(page ? { page } : {}) };
2683
+
2684
+ // What the answer view compares by, beyond the rating and hours its heading already shows.
2685
+ const facts = recordFacts(entity);
2686
+ const status = openStatus(entity) ?? undefined;
2687
+ const lines = [
2688
+ facts.services.length ? facts.services.slice(0, 3).map((x) => x.name).join(', ') : facts.knows.slice(0, 3).join(', '),
2689
+ facts.since ? `In business since ${facts.since}` : '',
2690
+ facts.reviews[0] ? `\u201c${facts.reviews[0].text}\u201d${facts.reviews[0].author ? ` \u2014 ${facts.reviews[0].author}` : ''}` : '',
2691
+ ].filter((l): l is string => !!l);
2692
+ const action = actionCta(entity, title);
2693
+ return {
2694
+ title,
2695
+ type,
2696
+ rating,
2697
+ emergency,
2698
+ sub,
2699
+ rows,
2700
+ id,
2701
+ ...(page ? { page } : {}),
2702
+ ...(facts.rating !== undefined ? { stars: facts.rating } : {}),
2703
+ ...(facts.reviewCount !== undefined ? { reviews: facts.reviewCount } : {}),
2704
+ ...(status ? { status: { open: status.open, label: status.label } } : {}),
2705
+ ...(facts.phone ? { phone: facts.phone } : {}),
2706
+ ...(action ? { action } : {}),
2707
+ facts: lines,
2708
+ };
2708
2709
  }
2709
2710
 
2710
2711
  private _buildResultsItem(
2711
2712
  tool: ModelContextToolInfo,
2712
2713
  res: ModelContextToolResponse,
2714
+ pick?: ResultPick,
2713
2715
  ): Extract<ThreadItem, { kind: 'results' }> {
2714
2716
  const items = this._extractItems(res);
2715
2717
  const cards = items.slice(0, 12).map((e) => this._entityCard(e));
2716
2718
  const note = !cards.length
2717
2719
  ? 'No results found.'
2718
2720
  : `${cards.length} result${cards.length === 1 ? '' : 's'}`;
2719
- return { kind: 'results', title: humanize(tool.name), note, cards };
2721
+ return { kind: 'results', title: humanize(tool.name), note, cards, ...(pick && pick.index < cards.length ? { pick } : {}) };
2722
+ }
2723
+
2724
+ /**
2725
+ * Results as an answer: the recommended pick with its reasons and the others
2726
+ * to compare, or a short list that opens per result. Records of things
2727
+ * only: a listing of types or counts keeps the plain cards.
2728
+ */
2729
+ private _answerEl(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement | null {
2730
+ // Records of things: a pick, or at least one result with a rating, hours, a phone or a page.
2731
+ // A sparse record beside them (an index entry with only a name) still shows, by name.
2732
+ const things = item.cards.filter((c) => c.stars !== undefined || c.status || c.phone || c.page);
2733
+ if (!item.pick && !things.length) return null;
2734
+ const cards: AnswerCard[] = item.cards.map((c) => {
2735
+ // The record's own action first: the same map and prompt as the Typed Retrieval skill and the NxisAI
2736
+ // app. A record that states none falls back to a form found on this page for it.
2737
+ const form = c.action ? undefined : formsFor(c.title)[0];
2738
+ const quote = form && /quote/.test(form.noun);
2739
+ return {
2740
+ title: c.title,
2741
+ stars: c.stars,
2742
+ reviews: c.reviews,
2743
+ status: c.status,
2744
+ phone: c.phone,
2745
+ page: c.page,
2746
+ facts: c.facts ?? [],
2747
+ ...(c.action
2748
+ ? { form: { label: c.action.label, message: c.action.message } }
2749
+ : form
2750
+ ? {
2751
+ form: quote
2752
+ ? { label: 'Get a quote', message: `Get a quote from ${c.title}` }
2753
+ : { label: `Use their ${form.noun}`, message: `Start the ${form.noun} for ${c.title}` },
2754
+ }
2755
+ : {}),
2756
+ };
2757
+ });
2758
+ return resultsAnswer(cards, item.pick, {
2759
+ ask: (text) => {
2760
+ if (this._turnActive) return;
2761
+ this._els.input.value = text;
2762
+ void this._onSend();
2763
+ },
2764
+ });
2720
2765
  }
2721
2766
 
2722
2767
  /** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
@@ -2739,6 +2784,7 @@ class NxisAgentFullscreen extends HTMLElement {
2739
2784
  ...(c.emergency ? [{ label: '24/7' }] : []),
2740
2785
  ],
2741
2786
  rows: c.rows.map((r) => ({ label: r.label, value: r.value })),
2787
+ actions: c.action ? [c.action] : [],
2742
2788
  }));
2743
2789
  }
2744
2790
 
@@ -2752,7 +2798,15 @@ class NxisAgentFullscreen extends HTMLElement {
2752
2798
  }
2753
2799
  try {
2754
2800
  const messages = emitCardsSurface(item.note, this._toA2uiCards(item.cards));
2755
- const mounted = mountSurface(wrap, messages);
2801
+ const mounted = mountSurface(wrap, messages, (event: any) => {
2802
+ const name = event?.event?.name ?? event?.name ?? '';
2803
+ const ctx = (event?.event?.context ?? event?.context ?? {}) as Record<string, unknown>;
2804
+ // A card's action button: send its message as the visitor, as the inline card does.
2805
+ if (name === 'nxis.ask' && typeof ctx.message === 'string' && !this._turnActive) {
2806
+ this._els.input.value = ctx.message;
2807
+ void this._onSend();
2808
+ }
2809
+ });
2756
2810
  if (!mounted) throw new Error('no surface produced');
2757
2811
  this._surfaces.push(mounted);
2758
2812
  } catch (err) {
@@ -2777,9 +2831,12 @@ class NxisAgentFullscreen extends HTMLElement {
2777
2831
  * surface; a2ui="a2ui-results" opts this element back into it for comparison.
2778
2832
  */
2779
2833
  private _resultsEl(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
2780
- return this.getAttribute('a2ui') === 'a2ui-results'
2781
- ? this._resultsElA2ui(item)
2782
- : this._resultsElLegacy(item);
2834
+ if (this.getAttribute('a2ui') === 'a2ui-results') return this._resultsElA2ui(item);
2835
+ if (!this._classicPrompts && item.cards.length) {
2836
+ const answer = this._answerEl(item);
2837
+ if (answer) return answer;
2838
+ }
2839
+ return this._resultsElLegacy(item);
2783
2840
  }
2784
2841
 
2785
2842
  private _resultsElLegacy(item: Extract<ThreadItem, { kind: 'results' }>): HTMLElement {
@@ -3477,6 +3534,42 @@ class NxisAgentFullscreen extends HTMLElement {
3477
3534
  if (this.mode !== 'fullscreen') this._open();
3478
3535
  }
3479
3536
 
3537
+ /**
3538
+ * Open on the form a link from the NxisAI app names.
3539
+ *
3540
+ * In a new conversation: the person arrives from another app, and nothing
3541
+ * on this site led here. The agent starts the form in its own words, so the
3542
+ * link only chooses which form; what the person wanted waits in the
3543
+ * composer for them to send, change or delete.
3544
+ */
3545
+ private async _arriveFromHandoff(): Promise<void> {
3546
+ const handoff = takeInboundHandoff();
3547
+ if (!handoff) return;
3548
+ this._newConversation();
3549
+ this._open();
3550
+ this._setStatus('Opening the form…');
3551
+ // The site's own tools first: they reach a form from any page, from the
3552
+ // site's records, so they work where the page registers nothing itself.
3553
+ await this._connectTools();
3554
+ const site = siteFormFor(handoff.actionId);
3555
+ const start = site
3556
+ ? startFormMessage(site.noun, site.choice)
3557
+ : await findHandoffForm(handoff).then((o) => (o.status === 'ready' ? startMessage(o.tool) : null));
3558
+ this._setStatus('');
3559
+ if (!start) {
3560
+ this._appendMsg('assistant', "I couldn't find that form on this page. Tell me what you need and I'll help from here.");
3561
+ } else if (!this._turnActive) {
3562
+ this._els.input.value = start;
3563
+ // The form is known, so it opens without the model choosing it: left to
3564
+ // choose, a model sometimes asks for the details in prose instead.
3565
+ await this._onSend(site ? { name: site.tool, args: site.args } : undefined);
3566
+ }
3567
+ if (handoff.intent && !this._els.input.value) {
3568
+ this._els.input.value = handoff.intent;
3569
+ this._autosize();
3570
+ }
3571
+ }
3572
+
3480
3573
  private async _resumePendingAction(): Promise<void> {
3481
3574
  const outcome = await resumePendingAction({
3482
3575
  onStart: (pending) => {
@@ -4099,6 +4192,9 @@ class NxisAgentFullscreen extends HTMLElement {
4099
4192
  /* The action card: forms and handoffs as one message. */
4100
4193
  ${ACTION_CARD_CSS}
4101
4194
 
4195
+ /* Search results as an answer: a pick and why, or a short list. */
4196
+ ${RESULTS_ANSWER_CSS}
4197
+
4102
4198
  /* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
4103
4199
  .launcher {
4104
4200
  position: fixed; right: 20px; bottom: 20px; z-index: 9998;