Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,14 +541,18 @@ share a tool name inside one server.

### Vendored code — `lib/vendor/`, `scripts/vendor-adaptive-cards.mjs`

`lib/vendor/adaptive-cards/{render,mapping,submit}.mjs` are COPIES of
abap2UI5/protocol `renderers/adaptive-cards/`, at the commit
`lib/vendor/adaptive-cards/{render,mapping,submit}.mjs`, `common/{view,request}.mjs`
and `profiles/portable-v1.json` are COPIES of abap2UI5/protocol
`renderers/adaptive-cards/`, `renderers/common/` (the halves the protocol's
renderers share) and the portable profile the view half reads, at the commit
`lib/vendor/adaptive-cards/source.json` records, with the sha256 of each.
Rules: never edit a copy - change it upstream and re-vendor (`node
scripts/vendor-adaptive-cards.mjs /path/to/protocol --ref <commit>`); the only
transformation is that the renderer's imports of the protocol repository's
copies of OUR agent modules point back at `lib/` (a copy of a copy would be a
second version of `viewxml`/`snapshot` in one process). `test/vendor.test.mjs`
transformation is that relative paths point at the vendored folder and the
renderer's imports of the protocol repository's copies of OUR agent modules
point back at `lib/` (a copy of a copy would be a second version of
`viewxml`/`snapshot` in one process); a path to anything else upstream fails
the vendoring (extend `MODULES` / `DATA`). `test/vendor.test.mjs`
fails offline on a hash mismatch, a missing header, an unrecorded file or an
import into the protocol tree, and - with a protocol checkout that has the
commit (`PROTOCOL_HOME` or `../protocol`) - when the copies differ from what
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

- **Evicting an app session leaves a newer one with the same draft id
reachable.** Sessions started from one screen share its id (the
playground's Pilot starts one per change of the reader's typing), and the
eviction of the oldest made the newest an "unknown session".
- **Adaptive Cards: app text is never Markdown.** The renderer is
re-vendored from abap2UI5/protocol (with its shared `renderers/common/`
halves and the portable profile): a model value or a toast like
`[verify your account](https://...)` was a live link in Teams and Copilot;
a text with Markdown syntax is a `TextRun` now. Its `htmlToText` (a
message box's details) is linear.
- **System mode: the app tools against a real SAP system.** With
`A2UI5_MCP_SYSTEM_URL` (the launch URL, `{class}` as a query parameter),
`A2UI5_MCP_SYSTEM_USER` and `A2UI5_MCP_SYSTEM_PASSWORD` - or
Expand Down
6 changes: 5 additions & 1 deletion lib/appclient.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,11 @@ export function createAppClient({
while (sessions.length > maxSessions) {
const old = sessions.shift();
old.evicted = true; // an act still in flight must not bring it back unlisted (adopt)
for (const id of old.ids) byId.delete(id);
/* only the ids that still name IT: sessions started from one screen
* share its draft id (the playground's Pilot starts one from its
* mirror on every change of the reader's typing), and evicting the
* oldest took the newest's id with it - "unknown session" */
for (const id of old.ids) if (byId.get(id) === old) byId.delete(id);
}
}

Expand Down
107 changes: 107 additions & 0 deletions lib/vendor/adaptive-cards/common/request.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* VENDORED - do not edit. abap2UI5/protocol renderers/common/request.mjs
* at commit 29aad60639a5ff3451d592a474cad8af082a2c55,
* copied by scripts/vendor-adaptive-cards.mjs; the only change is that the
* relative paths point at the vendored folder, and the imports of the agent
* modules at lib/ (the originals the protocol repository vendors) instead of
* its copies. `node scripts/vendor-adaptive-cards.mjs
* --check` fails when this copy drifts from that commit. Change it upstream,
* then re-vendor.
*/
/*
* The way back, shared by the portable renderers of this repository: an
* action payload -> the next protocol request (spec/request.md). Pure, no
* I/O:
*
* submitToRequest(state, payload, { pending }) ->
* { kind: "event", request, model, edits } a roundtrip to send
* { kind: "client", action, model, edits } a frontend-only wire (.eF)
* { kind: "box", action, model, edits } a message box button
*
* A payload is the action data of common/view.mjs `wireData` ({ event, args,
* refs, slot } / { client } / { box }) merged with input values keyed by
* their binding path - what an Adaptive Cards host submits (every input of
* the card) and what the terminal renderer submits (nothing: its edits are
* in the model already and travel as `pending`). Every input whose value
* differs from the model the screen was rendered from is an edit: it is
* written into a copy of the slot's model (`model`) and travels as the model
* delta the UI5 frontend builds (the vendored buildDelta: a scalar or
* structure whole, table cells as `__delta` rows). Values may arrive as
* strings (that is what a card submits) and are read back in the type the
* model holds there.
*/
import { buildDelta } from "../../../appclient.mjs";
import { getAt, setAt } from "../../../snapshot.mjs";
import { modelKeyOf } from "./view.mjs";

const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));

/** A submitted input value in the type the model holds at that path. */
export function coerce(value, current) {
if (typeof current === "number") {
const n = Number(value);
return value === "" || value === null || Number.isNaN(n) ? value : n;
}
if (typeof current === "boolean") return value === true || value === "true";
if (Array.isArray(current)) return String(value ?? "").split(",").map((x) => x.trim()).filter(Boolean);
if (value === null || value === undefined) return "";
return typeof value === "boolean" || typeof value === "number" ? value : String(value);
}

const same = (a, b) => JSON.stringify(a ?? "") === JSON.stringify(b ?? "");

/** The input values of a payload: the keys that are model paths (`/X`,
* `/T/1/C`; a duplicate id carries `#<n>`) - only those of `ids` when given
* (the inputs of the action's slot: a card submits all of its inputs). */
export function inputsOf(payload, ids) {
return Object.entries(payload || {})
.filter(([k]) => k.startsWith("/") && (!ids || ids.includes(k)))
.map(([k, v]) => [k.replace(/#\d+$/, ""), v]);
}

/**
* state the folded response state the screen was rendered from
* payload { ...action.data, "<input id>": value, ... } - data.slot names
* the slot of the action (absent: MAIN)
* ids the input ids of that slot on screen (default: every path key)
* pending paths of the slot's model edited earlier and not sent yet
*/
export function submitToRequest(state, payload, { pending = [], ids } = {}) {
const slot = payload.slot || "MAIN";
const key = modelKeyOf(slot);
const model = clone((state.models[key] && state.models[key].data) || {});
const edits = [];
for (const [path, raw] of inputsOf(payload, ids)) {
const current = getAt(model, path);
const value = coerce(raw, current);
if (same(value, current)) continue;
setAt(model, path, value);
if (!edits.includes(path)) edits.push(path);
}
const base = { slot, model, edits };
if (Array.isArray(payload.client)) return { kind: "client", action: payload.client, ...base };
if (payload.box !== undefined) return { kind: "box", action: String(payload.box), ...base };
if (!payload.event) throw new Error("the payload names no event - not an action of this renderer");
const args = Array.isArray(payload.args) ? payload.args.slice() : [];
// an argument read from the model reads it after this submit's own edits
(payload.refs || []).forEach((p, i) => {
if (p) args[i] = getAt(model, p) ?? null;
});
return { kind: "event", request: eventRequest(state, payload.event, args, buildDelta([...new Set([...pending, ...edits])], model)), ...base };
}

/** An event request continuing the state's draft id (spec/request.md#event-requests). */
export function eventRequest(state, event, args = [], delta = {}) {
const front = { ID: state.id, EVENT: event };
if (args.length) front.T_EVENT_ARG = args;
const request = { S_FRONT: front };
if (delta && Object.keys(delta).length) request.MODEL = delta;
return request;
}

/** The app-start request (spec/request.md#app-start-shaped-requests). */
export function startRequest({ origin, pathname, search }) {
const front = { ORIGIN: origin, PATHNAME: pathname };
if (search) front.SEARCH = search;
return { S_FRONT: front };
}
Loading
Loading