@cspeach/cli 1.1.34 → 1.1.35

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.
@@ -0,0 +1,87 @@
1
+ // src/renderer/wrap-rows.ts
2
+ //
3
+ // The one wrap helper for card content (fix batch 2026-10-12, Task 2).
4
+ //
5
+ // THE RULE: card content wraps; chrome may cut. Text a person reads to make
6
+ // a decision — card titles, facts, option labels and descriptions, risk and
7
+ // failure reasons, SAP error details — is pre-wrapped here into a KNOWN
8
+ // number of rows. Ink never soft-wraps a live-frame row (its erase
9
+ // accounting counts rows), so callers draw each returned row as its own
10
+ // truncate-end <Text> and count every row in their height estimate. Status
11
+ // rows, hints, palettes, spinners, traces and "… N more" counters stay one
12
+ // row and cut with truncateToWidth.
13
+ //
14
+ // Replaces factRows (confirm-card) and wrapCardText (ask-card), which did the
15
+ // same job twice.
16
+ import stringWidth from 'string-width';
17
+ import { truncateToWidth } from './ui-width.js';
18
+ import { sanitizeControlChars } from './sanitize.js';
19
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
20
+ /** The longest grapheme-whole prefix of `word` that fits `cells` (at least one grapheme). */
21
+ function takeCells(word, cells) {
22
+ let out = '';
23
+ let used = 0;
24
+ for (const { segment } of GRAPHEMES.segment(word)) {
25
+ const w = stringWidth(segment);
26
+ if (used + w > cells && out.length > 0)
27
+ break;
28
+ out += segment;
29
+ used += w;
30
+ if (used >= cells)
31
+ break;
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * `text` as the rows it is drawn on, each at most `cells` terminal cells.
37
+ * Control characters are neutralised, tabs become two spaces and newlines
38
+ * one space. Text that fits is one row, unchanged. Otherwise it wraps at
39
+ * spaces (a word wider than the row is cut where it must be; wide characters
40
+ * count two cells and are never split) onto at most `maxRows` rows; when
41
+ * more is left, the last row ends with the `more` glyph (… / ...). With
42
+ * `maxRows` 1 this is truncateToWidth: one row, cut.
43
+ */
44
+ export function wrapRows(text, cells, maxRows = 1) {
45
+ const flat = sanitizeControlChars(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
46
+ const width = Math.max(1, cells);
47
+ const max = Math.max(1, Math.floor(maxRows));
48
+ if (stringWidth(flat) <= width)
49
+ return [flat];
50
+ if (max === 1)
51
+ return [truncateToWidth(flat, width)];
52
+ const indent = /^ */.exec(flat)[0].slice(0, Math.max(0, width - 1));
53
+ const rows = [];
54
+ let cur = indent;
55
+ const curEmpty = () => cur.length === 0 || cur === indent;
56
+ for (let word of flat.trim().split(/ +/).filter((w) => w.length > 0)) {
57
+ while (stringWidth(word) > width) {
58
+ if (!curEmpty()) {
59
+ rows.push(cur);
60
+ cur = '';
61
+ }
62
+ const head = takeCells(word, width - stringWidth(cur));
63
+ rows.push(cur + head);
64
+ cur = '';
65
+ word = word.slice(head.length);
66
+ }
67
+ if (word.length === 0)
68
+ continue;
69
+ if (curEmpty())
70
+ cur += word;
71
+ else if (stringWidth(cur) + 1 + stringWidth(word) <= width)
72
+ cur += ` ${word}`;
73
+ else {
74
+ rows.push(cur);
75
+ cur = word;
76
+ }
77
+ }
78
+ if (!curEmpty() || rows.length === 0)
79
+ rows.push(cur);
80
+ if (rows.length <= max)
81
+ return rows;
82
+ const kept = rows.slice(0, max);
83
+ // The trailing space guarantees a cut (and so the more glyph) even when the
84
+ // joined rest would happen to fit.
85
+ kept[max - 1] = truncateToWidth(`${rows.slice(max - 1).join(' ')} `, width);
86
+ return kept;
87
+ }
package/dist/repl.js CHANGED
@@ -144,6 +144,7 @@ import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-m
144
144
  import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
145
145
  import { createRestoreGuard } from './repl/restore-guard.js';
146
146
  import { gracefulExit } from './lib/graceful-exit.js';
147
+ import { endSapSessionAfterTurn } from './sap/session-end.js';
147
148
  import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
148
149
  import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
149
150
  import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
@@ -2118,6 +2119,9 @@ export async function runRepl(opts) {
2118
2119
  planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
2119
2120
  }
2120
2121
  }
2122
+ // 2026-10-13 — end the stateful SAP session between turns, so a lock
2123
+ // a failed write left behind does not outlive the turn (SM12).
2124
+ await endSapSessionAfterTurn(adt);
2121
2125
  }
2122
2126
  // B3 — persist the plan revision from the resume turn's manifest
2123
2127
  // block (runs even after a turn error: partial assistant content is
@@ -4117,6 +4121,8 @@ export async function runRepl(opts) {
4117
4121
  planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
4118
4122
  }
4119
4123
  }
4124
+ // 2026-10-13 — end the stateful SAP session between turns (Ink mirror).
4125
+ await endSapSessionAfterTurn(adt);
4120
4126
  }
4121
4127
  // B3 — persist the plan revision from the resume turn's manifest block.
4122
4128
  // FALLBACK: skipped when the mid-turn onPlanManifest hook already saved it
@@ -3,8 +3,22 @@ import { SapConnection } from '@cspeach/sap-client';
3
3
  import { resolveSapTls } from '../config/loader.js';
4
4
  import { password as promptPassword } from '@inquirer/prompts';
5
5
  import { isHeadless } from '../renderer/tty.js';
6
+ import { registerExitTask } from '../lib/graceful-exit.js';
7
+ import { endSapSessions } from './session-end.js';
6
8
  const SAP_SERVICE = 'cspeach-sap';
7
9
  const cache = new Map();
10
+ let exitTaskRegistered = false;
11
+ /**
12
+ * 2026-10-13 — on exit, end the stateful SAP session of every connection this
13
+ * process opened, so locks left in it leave SM12 now instead of when SAP times
14
+ * the session out. Registered once, on the first connection.
15
+ */
16
+ function registerSessionEndOnExit() {
17
+ if (exitTaskRegistered)
18
+ return;
19
+ exitTaskRegistered = true;
20
+ registerExitTask(() => endSapSessions(cache.values()));
21
+ }
8
22
  /**
9
23
  * The single place that reads an SAP alias password: keychain first, then a
10
24
  * masked prompt when interactive. Headless callers (and the SAP link, which
@@ -69,6 +83,7 @@ export async function getConnection(alias, cfg) {
69
83
  // our existing cookies belong to a different one.
70
84
  await conn.login();
71
85
  cache.set(alias, conn);
86
+ registerSessionEndOnExit();
72
87
  return conn;
73
88
  }
74
89
  export function clearConnectionCache() {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Ending CSPeach's stateful SAP session (live finding 2026-10-13).
3
+ *
4
+ * Lock chains open a stateful ADT session on SAP. Nothing ended it, so any
5
+ * lock left inside it — after a failed write, or from a unit-test run — stayed
6
+ * in SM12 until SAP timed the session out, even after the CLI exited.
7
+ *
8
+ * When it is ended:
9
+ * - after every TOP-LEVEL turn (the REPL), so a lock left by a failed write
10
+ * never outlives the turn — except while a manual `sap_lock` is held,
11
+ * because ending the session would silently drop that lock;
12
+ * - on exit, for every connection, always (the process is going away).
13
+ *
14
+ * Both are capped so a slow or unreachable SAP never holds up the prompt or
15
+ * the exit, and neither ever throws. The next lock chain simply opens a fresh
16
+ * session (sap-client forgets the context cookie when it ends one).
17
+ */
18
+ /** Longest we wait for SAP to confirm the session ended. */
19
+ export const END_SESSION_CAP_MS = 3_000;
20
+ let manualLocks = 0;
21
+ /** A `sap_lock` call returned a handle. */
22
+ export function noteManualLock() {
23
+ manualLocks += 1;
24
+ }
25
+ /** A `sap_unlock` call ran. */
26
+ export function noteManualUnlock() {
27
+ manualLocks = Math.max(0, manualLocks - 1);
28
+ }
29
+ export function __resetManualLocksForTests() {
30
+ manualLocks = 0;
31
+ }
32
+ function capped(p, onTimeout) {
33
+ let timer;
34
+ return Promise.race([
35
+ p,
36
+ new Promise((r) => { timer = setTimeout(() => r(onTimeout), END_SESSION_CAP_MS); }),
37
+ ]).finally(() => { if (timer)
38
+ clearTimeout(timer); });
39
+ }
40
+ /** After a top-level turn: end the session unless a manual lock is still held. */
41
+ export async function endSapSessionAfterTurn(client) {
42
+ // Duck-typed: the offline client and an older sap-client have no endSession.
43
+ const adt = client;
44
+ if (!adt || typeof adt.endSession !== 'function')
45
+ return { ended: false, skipped: 'no_client' };
46
+ if (manualLocks > 0)
47
+ return { ended: false, skipped: 'manual_lock' };
48
+ try {
49
+ return await capped(adt.endSession(), { ended: false, error: 'timeout' });
50
+ }
51
+ catch (e) {
52
+ return { ended: false, error: e instanceof Error ? e.message : String(e) };
53
+ }
54
+ }
55
+ /** On exit: end the session of every connection, capped as a whole. */
56
+ export async function endSapSessions(conns) {
57
+ const all = [...conns].map(async (c) => {
58
+ if (typeof c.endStatefulSession !== 'function')
59
+ return;
60
+ try {
61
+ await c.endStatefulSession();
62
+ }
63
+ catch { /* best effort on the way out */ }
64
+ });
65
+ await capped(Promise.all(all).then(() => undefined), undefined);
66
+ }
@@ -251,7 +251,7 @@ registerTool({
251
251
  },
252
252
  multiSelect: {
253
253
  type: 'boolean',
254
- description: 'true = the user may pick several options; false/omitted = pick exactly one.',
254
+ description: 'true = the user may pick several options; false/omitted = pick exactly one. When the user ticks options and also types an answer, it comes back as free text (custom: true): the ticked labels, then the typed text, comma-separated.',
255
255
  },
256
256
  options: {
257
257
  type: 'array',
@@ -644,29 +644,29 @@ registerTool({
644
644
  handler: async (args, ctx) => {
645
645
  // runUnitTest(type, name) — type first
646
646
  const result = await ctx.adt.runUnitTest(args.type, args.name);
647
- // B2 false-green guard (project_aunit_run_gap): the ADT AUnit-run endpoint
648
- // returns HTTP 200 with an EMPTY runResult (total:0, no programs) even for a
649
- // class that is active + syntax-clean with a valid FOR TESTING local class —
650
- // a known environmental gap, exhaustively probed (sap-client buildUnitTestXml
651
- // comment). The raw {total:0} shape reads to a caller like "0 tests, nothing
652
- // failed → green", so an unguarded pass-through risks a false-green test gate.
653
- // When we detect that empty signature, wrap the (still-carried) raw result in
654
- // an explicit UNVERIFIED envelope that names Eclipse "Run As → ABAP Unit" as
655
- // the authoritative check. A genuine run (total > 0) passes through unchanged.
656
- const summary = result?.summary;
657
- const programs = result?.programs;
658
- const noTestsRun = summary?.total === 0 && (!Array.isArray(programs) || programs.length === 0);
659
- if (noTestsRun) {
647
+ // B2 false-green guard: a run in which no test actually ran reads to a
648
+ // caller like "nothing failed → green". Wrap the (still-carried) raw result
649
+ // in an explicit UNVERIFIED envelope when:
650
+ // - total is 0, missing or not a number — even when programs are present
651
+ // (a class with no methods, or only tolerable class alerts; review I1);
652
+ // - every test was skipped (total > 0, passed 0, failed 0; review M1).
653
+ // It is no longer blamed on an ADT gap: the 2026-10-12 sap-client fix sends
654
+ // the request shape Eclipse sends. A run with a pass or a failure passes
655
+ // through unchanged.
656
+ const summary = result
657
+ ?.summary;
658
+ const total = summary?.total;
659
+ const noTestsRun = typeof total !== 'number' || !Number.isFinite(total) || total === 0;
660
+ const allSkipped = !noTestsRun && summary?.passed === 0 && summary?.failed === 0;
661
+ if (noTestsRun || allSkipped) {
662
+ const n = total;
663
+ const warning = noTestsRun
664
+ ? '0 tests ran — this is not a pass. Check that the object has an active test ' +
665
+ 'class (FOR TESTING) with active test methods, then run it again.'
666
+ : `${n === 1 ? '1 test was' : `${n} tests were`} skipped and none ran — this is not ` +
667
+ 'a pass. Check the risk level and duration of the test classes, then run it again.';
660
668
  return {
661
- content: JSON.stringify({
662
- verification: 'UNVERIFIED',
663
- warning: 'ABAP Unit run enumerated 0 tests (empty runResult). This does NOT mean the tests ' +
664
- 'passed — it means none executed. A FOR TESTING class that is active + syntax-clean ' +
665
- 'still returning 0 is a known ADT AUnit-run gap (project_aunit_run_gap): the ' +
666
- 'authoritative check is Eclipse "Run As → ABAP Unit" (Ctrl+Shift+F10). Do NOT treat ' +
667
- 'this as a green test gate.',
668
- result,
669
- }, null, 2),
669
+ content: JSON.stringify({ verification: 'UNVERIFIED', warning, result }, null, 2),
670
670
  };
671
671
  }
672
672
  return { content: JSON.stringify(result, null, 2) };
@@ -16,7 +16,7 @@
16
16
  * unlock(type, name, lockHandle) → Promise<void> line 1949
17
17
  * serviceBindingPublish(name) → Promise<{status,error?}> line 2658
18
18
  * serviceBindingUnpublish(name) → Promise<{status,error?}>
19
- * messageMaintain (STUB — not yet in sap-client)
19
+ * maintainMessages({name, messages, transport?}) → Promise<MaintainMessagesResult> (0.1.19)
20
20
  * numberRangeIntervals (STUB — not yet in sap-client)
21
21
  *
22
22
  * auditLog aliased from safety/audit-log.ts#log via sap-client/src/index.ts.
@@ -26,7 +26,7 @@ import { readFileSync } from 'node:fs';
26
26
  import { registerTool } from './index.js';
27
27
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
28
28
  import { grantClaimMismatch, grantActivateMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
29
- import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
29
+ import { autoSnapshot, autoSnapshotClassInclude, autoSnapshotMessageClass } from './snapshot.js';
30
30
  import { includeSnapshotType } from './include-snapshot.js';
31
31
  import { buildWriteChainLine } from '../renderer/verify-chain.js';
32
32
  import { buildChangeCard } from '../renderer/change-card.js';
@@ -49,6 +49,7 @@ import { noteWriteTransport, setCurrentTransport } from '../repl/current-transpo
49
49
  import { markTransportCreatedThisSession } from './transport.js';
50
50
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
51
51
  import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
52
+ import { noteManualLock, noteManualUnlock } from '../sap/session-end.js';
52
53
  // ── Transcript visibility helpers (D16-display + tour slide-7, 2026-06-12) ──
53
54
  //
54
55
  // Rule 7 auto-snapshots always fired but the transcript never said so
@@ -868,7 +869,11 @@ registerTool({
868
869
  object: result.objectName,
869
870
  type: result.objectType,
870
871
  package: result.packageName,
871
- next_step: 'write_source_via_sap_set_source_then_activate',
872
+ // A message class has no source and is never activated: its messages
873
+ // go in through sap_message_maintain (bug 3, 2026-10-12).
874
+ next_step: String(result.objectType ?? '').toUpperCase() === 'MSAG'
875
+ ? 'add_messages_via_sap_message_maintain (a message class has no source and no activation)'
876
+ : 'write_source_via_sap_set_source_then_activate',
872
877
  }),
873
878
  };
874
879
  },
@@ -1573,6 +1578,9 @@ registerTool({
1573
1578
  },
1574
1579
  handler: async (args, ctx) => {
1575
1580
  const result = await ctx.adt.lock(args.type, args.name);
1581
+ // The turn-end session close keeps the session while this lock is held.
1582
+ if (result?.status === 'locked')
1583
+ noteManualLock();
1576
1584
  return { content: JSON.stringify(result) };
1577
1585
  },
1578
1586
  });
@@ -1596,6 +1604,7 @@ registerTool({
1596
1604
  },
1597
1605
  handler: async (args, ctx) => {
1598
1606
  await ctx.adt.unlock(args.type, args.name, args.lockHandle);
1607
+ noteManualUnlock();
1599
1608
  return { content: JSON.stringify({ status: 'ok' }) };
1600
1609
  },
1601
1610
  });
@@ -1644,34 +1653,256 @@ registerTool({
1644
1653
  return { content: JSON.stringify(result) };
1645
1654
  },
1646
1655
  });
1647
- // ── sap_message_maintain (STUB) ───────────────────────────────────────────────
1656
+ // ── sap_message_maintain ──────────────────────────────────────────────────────
1648
1657
  //
1649
- // STUB — MSAG message-class maintenance requires XML message-class-format
1650
- // parsing not yet implemented in @cspeach/sap-client.
1658
+ // Signature (sap-client 0.1.19): maintainMessages({name, messages, transport?})
1659
+ // → Promise<MaintainMessagesResult>
1660
+ // Adds or changes T100 messages in an existing message class, in one batch.
1661
+ // sap-client merges into the existing rows, locks, PUTs the whole class,
1662
+ // unlocks in a finally and READS BACK: success is `verified`, never HTTP 200.
1663
+ // Rule 7 snapshot = the class document (all messages) before the write.
1664
+ // No syntax check and no activation: a message class is not activatable.
1665
+ const MESSAGE_TEXT_MAX = 73;
1666
+ /** Same rule as sap-client's isValidMessageClassName (optional /NAMESPACE/). */
1667
+ const MESSAGE_CLASS_NAME_RE = /^(?:\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,20}$/;
1668
+ /** Review M-1: a plain sentence when SAP may still hold a lock, else undefined. */
1669
+ function lockWarning(className, r) {
1670
+ if (r.released !== false)
1671
+ return undefined;
1672
+ const what = (r.unlockFailures ?? []).join(', ') || 'unlock';
1673
+ return `SAP may still hold a lock on message class ${className} (failed: ${what}). Check SM12 and remove it there, or it clears when the session ends.`;
1674
+ }
1675
+ /** The rows from the tool args (`messages`, or the single-message short form). */
1676
+ function messageRowsFromArgs(args) {
1677
+ const list = Array.isArray(args['messages']) ? args['messages'] : [];
1678
+ const rows = list.map((m) => ({
1679
+ msgno: String(m?.['msgno'] ?? m?.['number'] ?? '').trim(),
1680
+ text: typeof m?.['text'] === 'string' ? m['text'] : '',
1681
+ ...(typeof m?.['selfExplanatory'] === 'boolean' ? { selfExplanatory: m['selfExplanatory'] } : {}),
1682
+ }));
1683
+ if (rows.length === 0 && args['messageNumber'] !== undefined) {
1684
+ rows.push({
1685
+ msgno: String(args['messageNumber']).trim(),
1686
+ text: typeof args['messageText'] === 'string' ? args['messageText'] : '',
1687
+ });
1688
+ }
1689
+ return rows;
1690
+ }
1691
+ /** Same rules as sap-client's validateMessageUpserts — checked here so a bad
1692
+ * request never spends the approval. A plain sentence, or null. */
1693
+ function messageRowsProblem(rows) {
1694
+ if (rows.length === 0)
1695
+ return 'Give at least one message to add or change.';
1696
+ const seen = new Set();
1697
+ for (const r of rows) {
1698
+ if (!/^\d{3}$/.test(r.msgno))
1699
+ return `Message number "${r.msgno}" is not valid. Use three digits, 000 to 999.`;
1700
+ if (seen.has(r.msgno))
1701
+ return `Message ${r.msgno} is in the request more than once.`;
1702
+ seen.add(r.msgno);
1703
+ if (r.text.trim().length === 0)
1704
+ return `Message ${r.msgno} has an empty text.`;
1705
+ const len = Array.from(r.text).length;
1706
+ if (len > MESSAGE_TEXT_MAX)
1707
+ return `Message ${r.msgno} is ${len} characters long. SAP allows ${MESSAGE_TEXT_MAX}.`;
1708
+ }
1709
+ return null;
1710
+ }
1651
1711
  registerTool({
1652
1712
  name: 'sap_message_maintain',
1653
- description: 'STUB — not currently implemented; do not call. '
1654
- + 'Maintaining MSAG message-class entries via ADT requires XML message-class-format '
1655
- + 'parsing not yet implemented in @cspeach/sap-client.',
1713
+ description: 'Add or change messages in an existing message class (MSAG, the T100 texts behind SE91). '
1714
+ + 'Create the class first with sap_create_object type MSAG. One call takes many messages. '
1715
+ + 'Each message: msgno (three digits, 000-999) and text (up to 73 characters, &1-&4 placeholders allowed). '
1716
+ + 'Messages not in the call are kept. No source, no syntax check, no activation. '
1717
+ + 'The type (E, W, I, S) is not stored: it is chosen where the message is raised, e.g. MESSAGE e001(zfoo). '
1718
+ + 'Requires approval_id.',
1656
1719
  isMutating: true,
1657
1720
  requiresSap: true,
1658
1721
  input_schema: {
1659
1722
  type: 'object',
1660
1723
  properties: {
1661
- messageClass: { type: 'string', description: 'Message class name (e.g. ZMSAG_FOO)' },
1662
- messageNumber: { type: 'string', description: 'Three-digit message number (e.g. 001)' },
1663
- messageText: { type: 'string', description: 'Message text (up to 73 characters)' },
1664
- messageType: { type: 'string', description: 'Message type: I, W, E, A, X, S' },
1724
+ messageClass: { type: 'string', description: 'Message class name (e.g. ZSALES_MSG)' },
1725
+ messages: {
1726
+ type: 'array',
1727
+ description: 'Messages to add or change',
1728
+ items: {
1729
+ type: 'object',
1730
+ properties: {
1731
+ msgno: { type: 'string', description: 'Three-digit message number (e.g. 001)' },
1732
+ text: { type: 'string', description: 'Message text, up to 73 characters (e.g. "Order &1 not found")' },
1733
+ selfExplanatory: { type: 'boolean', description: 'Optional. New messages default to true (no long text).' },
1734
+ },
1735
+ required: ['msgno', 'text'],
1736
+ },
1737
+ },
1738
+ messageNumber: { type: 'string', description: 'Short form for one message: the number (use messages for several)' },
1739
+ messageText: { type: 'string', description: 'Short form for one message: the text' },
1740
+ transport: { type: 'string', description: 'Transport request number (optional)' },
1741
+ approval_id: { type: 'string', description: 'JWT from request_approval' },
1665
1742
  },
1666
- required: ['messageClass', 'messageNumber', 'messageText'],
1743
+ required: ['messageClass', 'approval_id'],
1667
1744
  },
1668
- handler: async (_args, _ctx) => {
1745
+ handler: async (args, ctx) => {
1746
+ const className = String(args.messageClass ?? '').trim().toUpperCase();
1747
+ // ── Input check — before the approval is spent ──────────────────────────
1748
+ const rows = messageRowsFromArgs(args);
1749
+ const problem = !className
1750
+ ? 'Give the message class name.'
1751
+ : !MESSAGE_CLASS_NAME_RE.test(className)
1752
+ ? `"${String(args.messageClass)}" is not a valid message class name (letters, digits and _ only, 20 characters at most).`
1753
+ : messageRowsProblem(rows);
1754
+ if (problem) {
1755
+ return { content: JSON.stringify({ error: 'invalid_messages', detail: problem }), is_error: true };
1756
+ }
1757
+ // ── Capability — before the approval is spent (review I-1) ──────────────
1758
+ const adt = ctx.adt;
1759
+ if (typeof adt.readMessageClass !== 'function' || typeof adt.maintainMessages !== 'function') {
1760
+ return {
1761
+ content: JSON.stringify({
1762
+ error: 'message_maintain_unavailable',
1763
+ detail: 'This CSPeach build cannot write messages yet. Update CSPeach and try again.',
1764
+ }),
1765
+ is_error: true,
1766
+ };
1767
+ }
1768
+ const msagClient = adt;
1769
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
1770
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.messageClass, 'modify');
1771
+ if (!verify.ok) {
1772
+ return {
1773
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
1774
+ is_error: true,
1775
+ };
1776
+ }
1777
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
1778
+ {
1779
+ const claim = grantClaimMismatch(verify.payload, { type: 'MSAG', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
1780
+ if (claim)
1781
+ return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
1782
+ }
1783
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
1784
+ const startedAt = Date.now();
1785
+ // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
1786
+ {
1787
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
1788
+ if (args.transport && sessionTransport && args.transport !== sessionTransport) {
1789
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
1790
+ }
1791
+ }
1792
+ const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
1793
+ const resolved = await resolveWriteTransport(ctx, 'MSAG', className, effectiveTransport);
1794
+ // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
1795
+ if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
1796
+ return {
1797
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(className, resolved.owning) }),
1798
+ is_error: true,
1799
+ };
1800
+ }
1801
+ // ── Gate 2: Snapshot (Rule 7) — HARD gate, of the whole class ────────────
1802
+ let snapOutcome;
1803
+ try {
1804
+ snapOutcome = await autoSnapshotMessageClass(className, msagClient);
1805
+ }
1806
+ catch (err) {
1807
+ return {
1808
+ content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
1809
+ is_error: true,
1810
+ };
1811
+ }
1812
+ if (snapOutcome.reason === 'object_not_found') {
1813
+ return {
1814
+ content: JSON.stringify({
1815
+ error: 'message_class_not_found',
1816
+ detail: `Message class ${className} does not exist. Create it first with sap_create_object type MSAG, then add the messages.`,
1817
+ }),
1818
+ is_error: true,
1819
+ };
1820
+ }
1821
+ if (!snapOutcome.taken) {
1822
+ return {
1823
+ content: JSON.stringify({
1824
+ error: ERR.SNAPSHOT_FAILED,
1825
+ reason: snapOutcome.reason ?? 'source_read_failed',
1826
+ detail: snapOutcome.detail,
1827
+ recovery: 'The message class could not be read, so no snapshot was possible and nothing was written. If this is a connection or sign-in problem, fix it and try again.',
1828
+ }),
1829
+ is_error: true,
1830
+ };
1831
+ }
1832
+ emitSnapshotNotice(ctx, snapOutcome, 'MSAG', className);
1833
+ // ── WAL: record pending entry ────────────────────────────────────────────
1834
+ await appendPending(ctx.session, {
1835
+ tool_use_id: toolUseId,
1836
+ tool: 'sap_message_maintain',
1837
+ args: { messageClass: className, messages: rows.map((r) => r.msgno) },
1838
+ started_at: new Date().toISOString(),
1839
+ });
1840
+ // ── Write (sap-client locks, PUTs, unlocks and reads back) ───────────────
1841
+ let result;
1842
+ try {
1843
+ result = await msagClient.maintainMessages({
1844
+ name: className,
1845
+ messages: rows,
1846
+ ...(resolved.transport ? { transport: resolved.transport } : {}),
1847
+ });
1848
+ }
1849
+ catch (err) {
1850
+ const detail = err instanceof Error ? err.message : String(err);
1851
+ await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
1852
+ return {
1853
+ content: JSON.stringify({ error: 'message_maintain_failed', detail }),
1854
+ is_error: true,
1855
+ };
1856
+ }
1857
+ const lockNote = lockWarning(className, result);
1858
+ if (lockNote)
1859
+ ctx.chunkEmitter?.emit('warn', lockNote);
1860
+ if (!result.verified) {
1861
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
1862
+ return {
1863
+ content: JSON.stringify({
1864
+ error: 'message_maintain_failed',
1865
+ ...(lockNote ? { lock_warning: lockNote } : {}),
1866
+ detail: result.error ?? 'The messages could not be confirmed in SAP.',
1867
+ missing: result.missing,
1868
+ ...(result.httpStatus !== undefined ? { http_status: result.httpStatus } : {}),
1869
+ ...(result.lockedInTransport ? {
1870
+ locked_in_transport: result.lockedInTransport,
1871
+ recovery: `Retry this exact call with transport=${result.lockedInTransport} — that request already owns the message class.`,
1872
+ } : {}),
1873
+ }),
1874
+ is_error: true,
1875
+ };
1876
+ }
1877
+ if (result.noChange) {
1878
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ unchanged: true }), false, Date.now() - startedAt);
1879
+ return {
1880
+ content: JSON.stringify({
1881
+ written: false,
1882
+ unchanged: true,
1883
+ message_class: className,
1884
+ changes: result.changes,
1885
+ message: `Message class ${className} already has these texts. Nothing was changed.`,
1886
+ }),
1887
+ };
1888
+ }
1889
+ // Audit only after SAP's read-back confirmed every message.
1890
+ await auditLog('maintainMessages', 'MSAG', className, 'success', Date.now() - startedAt, result.changes.map((c) => `${c.msgno}:${c.action}`).join(' '));
1891
+ noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
1892
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, verified: true, changes: result.changes }), false, Date.now() - startedAt);
1893
+ const written = result.changes.filter((c) => c.action !== 'unchanged').map((c) => c.msgno);
1894
+ ctx.chunkEmitter?.emit('info', `messages ✓ ${className} ${written.join(' ')} (no activation needed)`);
1895
+ const first = written[0] ?? rows[0].msgno;
1669
1896
  return {
1670
1897
  content: JSON.stringify({
1671
- error: 'not_implemented',
1672
- detail: 'MSAG message maintenance via ADT requires XML message-class-format parsing not yet implemented in @cspeach/sap-client',
1898
+ written: true,
1899
+ verified: true,
1900
+ message_class: className,
1901
+ changes: result.changes,
1902
+ ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
1903
+ ...(lockNote ? { lock_warning: lockNote } : {}),
1904
+ next_step: `Done — no activation needed. Raise these from code by number, e.g. MESSAGE e${first}(${className.toLowerCase()}) WITH …, or a T100 textid in an exception class. Never hard-code the texts.`,
1673
1905
  }),
1674
- is_error: true,
1675
1906
  };
1676
1907
  },
1677
1908
  });
@@ -127,6 +127,32 @@ export async function autoSnapshotClassInclude(className, includeType, adt) {
127
127
  const entry = await snapshots.take(includeSnapshotType(includeType), className, src, 'before_write');
128
128
  return { taken: true, entry };
129
129
  }
130
+ /**
131
+ * Rule-7 snapshot of a message class, taken before sap_message_maintain
132
+ * changes it. A message class has no source: the snapshot is the whole class
133
+ * document as SAP serves it (every message), stored under type MSAG.
134
+ *
135
+ * 404 → 'object_not_found' (no such class — the write cannot go ahead)
136
+ * other read error → 'source_read_failed' (+ detail) — the write is blocked
137
+ * read OK → snapshot taken
138
+ * A snapshots.take failure THROWS, like autoSnapshot.
139
+ */
140
+ export async function autoSnapshotMessageClass(name, adt) {
141
+ let xml;
142
+ try {
143
+ xml = (await adt.readMessageClass(name)).xml;
144
+ }
145
+ catch (err) {
146
+ if (isNotFound(err))
147
+ return { taken: false, reason: 'object_not_found' };
148
+ return { taken: false, reason: 'source_read_failed', detail: shortErrorMessage(err) };
149
+ }
150
+ if (typeof xml !== 'string' || xml.length === 0) {
151
+ return { taken: false, reason: 'source_read_failed', detail: 'the message class read returned no document' };
152
+ }
153
+ const entry = await snapshots.take('MSAG', name.toUpperCase(), xml, 'before_write');
154
+ return { taken: true, entry };
155
+ }
130
156
  // ── sap_snapshot_list ─────────────────────────────────────────────────────────
131
157
  registerTool({
132
158
  name: 'sap_snapshot_list',