@f5-sales-demo/xcsh 22.2.0 → 22.3.1

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.
@@ -1,5 +1,11 @@
1
1
  import { ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
2
- import { getSupportedReasoningEfforts, type Model, modelsAreEqual, ReasoningEffort } from "@f5-sales-demo/pi-ai";
2
+ import {
3
+ type CodexContextTier,
4
+ getSupportedReasoningEfforts,
5
+ type Model,
6
+ modelsAreEqual,
7
+ ReasoningEffort,
8
+ } from "@f5-sales-demo/pi-ai";
3
9
  import {
4
10
  Container,
5
11
  getKeybindings,
@@ -58,6 +64,10 @@ export function reasoningLabel(level: ThinkingLevel): string {
58
64
  }[level] ?? level
59
65
  );
60
66
  }
67
+
68
+ function formatContextSize(tokens: number): string {
69
+ return `${Math.round(tokens / 1_000)}K`;
70
+ }
61
71
  export interface ModelNavigationContext {
62
72
  initialSearchInput?: string;
63
73
  initialProvider?: string;
@@ -312,6 +322,8 @@ export interface ModelSelection {
312
322
  model: Model;
313
323
  selector: string;
314
324
  thinkingLevel: ThinkingLevel;
325
+ /** Provider-wide context policy, present only for supported OpenAI routes. */
326
+ contextTier?: CodexContextTier;
315
327
  role?: string;
316
328
  }
317
329
  type RoleSelectCallback = (selection: ModelSelection) => boolean | undefined | Promise<boolean | undefined>;
@@ -398,19 +410,28 @@ export class ModelSelectorComponent extends Container {
398
410
  ];
399
411
  if (this.#isMenuOpen && selected) {
400
412
  details.push(...wrapTextWithAnsi(selected.selector, inner));
401
- title = getModelDisplayName(selected.model);
413
+ title =
414
+ this.#menuStep === "thinking"
415
+ ? "Reasoning"
416
+ : this.#menuStep === "context"
417
+ ? "Context window"
418
+ : getModelDisplayName(selected.model);
402
419
  const labels =
403
- this.#menuStep === "scope"
404
- ? this.#scopeActions()
405
- : this.#menuStep === "role"
406
- ? this.#menuRoleActions.map(action => action.label)
407
- : this.#getThinkingLevelsForModel(selected.model).map(reasoningLabel);
420
+ this.#menuStep === "context"
421
+ ? this.#contextActions(selected.model).map(action => action.label)
422
+ : this.#menuStep === "scope"
423
+ ? this.#scopeActions()
424
+ : this.#menuStep === "role"
425
+ ? this.#menuRoleActions.map(action => action.label)
426
+ : this.#getThinkingLevelsForModel(selected.model).map(reasoningLabel);
408
427
  purpose =
409
428
  this.#menuStep === "thinking"
410
- ? `Reasoning · ${this.#menuScope === "conversation" ? "This conversation" : this.#menuScope === "default" ? "Saved default" : `Role: ${this.#menuSelectedRole}`}`
411
- : this.#menuStep === "scope"
412
- ? "Choose where this model applies."
413
- : "Choose a specialist role.";
429
+ ? "Choose reasoning for this model."
430
+ : this.#menuStep === "context"
431
+ ? "This provider-wide setting controls the usable context window. Scope controls only the model and reasoning assignment."
432
+ : this.#menuStep === "scope"
433
+ ? "Choose where this model applies."
434
+ : "Choose a specialist role.";
414
435
  body = labels.map((label, i) => selectorRow([label], [inner - 2], i === this.#menuSelectedIndex));
415
436
  selectedBodyIndex = this.#menuSelectedIndex;
416
437
  if (this.#menuStep === "scope")
@@ -422,10 +443,17 @@ export class ModelSelectorComponent extends Container {
422
443
  ][this.#menuSelectedIndex],
423
444
  );
424
445
  else if (this.#menuStep === "thinking") details.push(`Reasoning: ${labels[this.#menuSelectedIndex]}`);
446
+ else if (this.#menuStep === "context") {
447
+ const context = this.#contextActions(selected.model)[this.#menuSelectedIndex];
448
+ if (context) {
449
+ details.push(`Usable window: ${formatContextSize(context.effectiveContextWindow)}.`);
450
+ details.push(`Automatic compaction begins at ${formatContextSize(context.autoCompactTokenLimit)}.`);
451
+ }
452
+ }
425
453
  if (this.#errorMessage)
426
454
  details.push(`Could not apply: ${String(this.#errorMessage)}. ${selectorKeys("confirm")} to retry.`);
427
455
  footer = [
428
- selectorNavigationHint(this.#menuStep === "thinking" ? "confirm" : "continue"),
456
+ selectorNavigationHint(this.#menuStep === "scope" || this.#menuStep === "role" ? "continue" : "confirm"),
429
457
  selectorCancelHint(),
430
458
  ];
431
459
  } else {
@@ -585,13 +613,15 @@ export class ModelSelectorComponent extends Container {
585
613
  // Context menu state
586
614
  #isMenuOpen: boolean = false;
587
615
  #menuSelectedIndex: number = 0;
588
- #menuStep: "scope" | "role" | "thinking" = "scope";
616
+ #menuStep: "thinking" | "context" | "scope" | "role" = "thinking";
589
617
  #menuScope: ModelSelection["scope"] = "conversation";
590
618
  #menuItem?: ModelItem | CanonicalModelItem;
591
619
  #applying = false;
592
620
  #onLogin?: () => void;
593
621
  #currentThinkingLevel: ThinkingLevel = ThinkingLevel.Inherit;
594
622
  #menuSelectedRole: string | null = null;
623
+ #menuThinkingLevel: ThinkingLevel = ThinkingLevel.Inherit;
624
+ #menuContextTier?: CodexContextTier;
595
625
 
596
626
  constructor(
597
627
  tui: TUI,
@@ -1197,6 +1227,47 @@ export class ModelSelectorComponent extends Container {
1197
1227
  return foundIndex >= 0 ? foundIndex : 0;
1198
1228
  }
1199
1229
 
1230
+ #contextSettingFor(model: Model): "providers.openaiContextTier" | "providers.litellmContextTier" | undefined {
1231
+ if (model.provider === "openai-codex") return "providers.openaiContextTier";
1232
+ if (model.provider === "litellm") return "providers.litellmContextTier";
1233
+ return undefined;
1234
+ }
1235
+
1236
+ #contextActions(model: Model): Array<{
1237
+ tier: CodexContextTier;
1238
+ label: string;
1239
+ effectiveContextWindow: number;
1240
+ autoCompactTokenLimit: number;
1241
+ }> {
1242
+ const setting = this.#contextSettingFor(model);
1243
+ if (!setting || !model.maxContextWindow) return [];
1244
+ // The registry has already clamped this to the safe advertised provider limit.
1245
+ const providerMaximum = model.providerContextWindow ?? model.maxContextWindow;
1246
+ const standard = Math.min(272_000, providerMaximum);
1247
+ const codexMaximum = Math.min(model.maxContextWindow, providerMaximum);
1248
+ const effectivePercent = model.effectiveContextWindowPercent ?? 95;
1249
+ const compactPercent = model.autoCompactThresholdPercent ?? 90;
1250
+ const current = this.#settings.get(setting) as CodexContextTier;
1251
+ return [
1252
+ ["standard", "Standard", standard],
1253
+ ["codex-max", "Codex maximum", codexMaximum],
1254
+ ["provider-max", "Provider maximum", providerMaximum],
1255
+ ].map(([tier, label, size]) => ({
1256
+ tier: tier as CodexContextTier,
1257
+ label: `${label} — ${formatContextSize(size as number)}${tier === current ? " (current/default)" : ""}`,
1258
+ effectiveContextWindow: Math.floor(((size as number) * effectivePercent) / 100),
1259
+ autoCompactTokenLimit: Math.floor(((size as number) * compactPercent) / 100),
1260
+ }));
1261
+ }
1262
+
1263
+ #contextPreselectIndex(model: Model): number {
1264
+ const setting = this.#contextSettingFor(model);
1265
+ if (!setting) return 0;
1266
+ const current = this.#settings.get(setting) as CodexContextTier;
1267
+ const index = this.#contextActions(model).findIndex(action => action.tier === current);
1268
+ return index >= 0 ? index : 0;
1269
+ }
1270
+
1200
1271
  #getSelectedItem(): ModelItem | CanonicalModelItem | undefined {
1201
1272
  return this.#isCanonicalTab()
1202
1273
  ? this.#filteredCanonicalModels[this.#selectedIndex]
@@ -1214,29 +1285,31 @@ export class ModelSelectorComponent extends Container {
1214
1285
  if (!this.#menuItem || this.#isItemDisabled(this.#menuItem)) return;
1215
1286
  this.#isMenuOpen = true;
1216
1287
  this.#menuScope = "conversation";
1217
- this.#menuStep = "scope";
1288
+ this.#menuStep = "thinking";
1218
1289
  this.#menuSelectedRole = null;
1219
- this.#menuSelectedIndex = 0;
1290
+ this.#menuThinkingLevel = this.#getThinkingLevelsForModel(this.#menuItem.model)[0] ?? ThinkingLevel.Inherit;
1291
+ this.#menuContextTier = undefined;
1292
+ this.#menuSelectedIndex = this.#getThinkingPreselectIndex("default", this.#menuItem.model);
1220
1293
  this.#updateMenu();
1221
1294
  }
1222
1295
 
1223
- #openThinkingMenu(role: string): void {
1296
+ #openScopeMenu(): void {
1224
1297
  const selected = this.#menuItem ?? this.#getSelectedItem();
1225
1298
  if (!selected) return;
1226
1299
  this.#menuItem = selected;
1227
1300
  this.#isMenuOpen = true;
1228
- this.#menuStep = "thinking";
1229
- this.#menuSelectedRole = role;
1230
- this.#menuSelectedIndex = this.#getThinkingPreselectIndex(role, selected.model);
1301
+ this.#menuStep = "scope";
1302
+ this.#menuSelectedIndex = 0;
1231
1303
  this.#updateMenu();
1232
1304
  }
1233
1305
 
1234
1306
  #closeMenu(): void {
1235
1307
  this.#resetDetailPage();
1236
1308
  this.#isMenuOpen = false;
1237
- this.#menuStep = "role";
1309
+ this.#menuStep = "thinking";
1238
1310
  this.#menuSelectedRole = null;
1239
1311
  this.#menuItem = undefined;
1312
+ this.#menuContextTier = undefined;
1240
1313
  this.#updateList();
1241
1314
  }
1242
1315
 
@@ -1336,11 +1409,13 @@ export class ModelSelectorComponent extends Container {
1336
1409
  if (!selectedItem) return;
1337
1410
 
1338
1411
  const optionCount =
1339
- this.#menuStep === "thinking" && this.#menuSelectedRole !== null
1412
+ this.#menuStep === "thinking"
1340
1413
  ? this.#getThinkingLevelsForModel(selectedItem.model).length
1341
- : this.#menuStep === "scope"
1342
- ? this.#scopeActions().length
1343
- : this.#menuRoleActions.length;
1414
+ : this.#menuStep === "context"
1415
+ ? this.#contextActions(selectedItem.model).length
1416
+ : this.#menuStep === "scope"
1417
+ ? this.#scopeActions().length
1418
+ : this.#menuRoleActions.length;
1344
1419
  if (optionCount === 0) return;
1345
1420
 
1346
1421
  if (matchesSelectorKey(keyData, "up")) {
@@ -1356,13 +1431,31 @@ export class ModelSelectorComponent extends Container {
1356
1431
  }
1357
1432
 
1358
1433
  if (matchesSelectorKey(keyData, "confirm")) {
1434
+ if (this.#menuStep === "thinking") {
1435
+ const thinking = this.#getThinkingLevelsForModel(selectedItem.model)[this.#menuSelectedIndex];
1436
+ if (!thinking) return;
1437
+ this.#menuThinkingLevel = thinking;
1438
+ if (this.#contextActions(selectedItem.model).length > 0) {
1439
+ this.#menuStep = "context";
1440
+ this.#menuSelectedIndex = this.#contextPreselectIndex(selectedItem.model);
1441
+ this.#updateMenu();
1442
+ } else this.#openScopeMenu();
1443
+ return;
1444
+ }
1445
+ if (this.#menuStep === "context") {
1446
+ const context = this.#contextActions(selectedItem.model)[this.#menuSelectedIndex];
1447
+ if (!context) return;
1448
+ this.#menuContextTier = context.tier;
1449
+ this.#openScopeMenu();
1450
+ return;
1451
+ }
1359
1452
  if (this.#menuStep === "scope") {
1360
1453
  this.#menuScope = (["conversation", "default", "role"] as const)[this.#menuSelectedIndex];
1361
1454
  if (this.#menuScope === "role") {
1362
1455
  this.#menuStep = "role";
1363
1456
  this.#menuSelectedIndex = 0;
1364
1457
  this.#updateMenu();
1365
- } else this.#openThinkingMenu("default");
1458
+ } else void this.#handleSelect(selectedItem, this.#menuThinkingLevel);
1366
1459
  return;
1367
1460
  }
1368
1461
  if (this.#menuStep === "role") {
@@ -1370,27 +1463,41 @@ export class ModelSelectorComponent extends Container {
1370
1463
  const action = this.#menuRoleActions[this.#menuSelectedIndex];
1371
1464
  if (!action) return;
1372
1465
  this.#menuSelectedRole = action.role;
1373
- this.#menuStep = "thinking";
1374
- this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model);
1375
- this.#updateMenu();
1466
+ void this.#handleSelect(selectedItem, this.#menuThinkingLevel);
1376
1467
  return;
1377
1468
  }
1378
-
1379
- if (!this.#menuSelectedRole) return;
1380
- const thinkingOptions = this.#getThinkingLevelsForModel(selectedItem.model);
1381
- const thinkingLevel = thinkingOptions[this.#menuSelectedIndex];
1382
- if (!thinkingLevel) return;
1383
- void this.#handleSelect(selectedItem, thinkingLevel);
1384
- return;
1385
1469
  }
1386
1470
 
1387
1471
  if (getKeybindings().matches(keyData, "tui.select.cancel")) {
1388
- if (this.#menuStep === "thinking" && this.#menuSelectedRole !== null) {
1389
- this.#menuStep = this.#menuScope === "role" ? "role" : "scope";
1390
- const roleIndex = this.#menuRoleActions.findIndex(action => action.role === this.#menuSelectedRole);
1472
+ if (this.#menuStep === "role") {
1473
+ this.#menuStep = "scope";
1391
1474
  this.#menuSelectedRole = null;
1392
- this.#menuSelectedIndex =
1393
- this.#menuScope === "role" ? Math.max(0, roleIndex) : this.#menuScope === "default" ? 1 : 0;
1475
+ this.#menuSelectedIndex = 2;
1476
+ this.#updateMenu();
1477
+ return;
1478
+ }
1479
+ if (this.#menuStep === "scope") {
1480
+ if (this.#contextActions(selectedItem.model).length > 0) {
1481
+ this.#menuStep = "context";
1482
+ this.#menuSelectedIndex = this.#contextActions(selectedItem.model).findIndex(
1483
+ action => action.tier === this.#menuContextTier,
1484
+ );
1485
+ if (this.#menuSelectedIndex < 0)
1486
+ this.#menuSelectedIndex = this.#contextPreselectIndex(selectedItem.model);
1487
+ } else {
1488
+ this.#menuStep = "thinking";
1489
+ this.#menuSelectedIndex = this.#getThinkingLevelsForModel(selectedItem.model).indexOf(
1490
+ this.#menuThinkingLevel,
1491
+ );
1492
+ }
1493
+ this.#updateMenu();
1494
+ return;
1495
+ }
1496
+ if (this.#menuStep === "context") {
1497
+ this.#menuStep = "thinking";
1498
+ this.#menuSelectedIndex = this.#getThinkingLevelsForModel(selectedItem.model).indexOf(
1499
+ this.#menuThinkingLevel,
1500
+ );
1394
1501
  this.#updateMenu();
1395
1502
  return;
1396
1503
  }
@@ -1411,6 +1518,7 @@ export class ModelSelectorComponent extends Container {
1411
1518
  model: item.model,
1412
1519
  selector: item.selector,
1413
1520
  thinkingLevel,
1521
+ ...(this.#menuContextTier ? { contextTier: this.#menuContextTier } : {}),
1414
1522
  ...(this.#menuScope === "role" ? { role: this.#menuSelectedRole! } : {}),
1415
1523
  };
1416
1524
  this.#applying = true;
@@ -17,8 +17,21 @@ export function prepareModelSelection(session: AgentSession, selection: ModelSel
17
17
  : "None";
18
18
  const selectedModel = formatModelSelectorValue(selection.selector, selection.thinkingLevel);
19
19
  const role = selection.scope === "default" ? "default" : selection.role;
20
+ const contextSetting =
21
+ selection.contextTier && model.provider === "openai-codex"
22
+ ? "providers.openaiContextTier"
23
+ : selection.contextTier && model.provider === "litellm"
24
+ ? "providers.litellmContextTier"
25
+ : undefined;
20
26
  const routingState = session.getRoutingState();
21
27
  const changes: Array<{ field: string; before: string; after: string }> = [];
28
+ if (contextSetting) {
29
+ changes.push({
30
+ field: "Provider-wide context tier",
31
+ before: String(session.settings.get(contextSetting)),
32
+ after: selection.contextTier!,
33
+ });
34
+ }
22
35
  if (selection.scope !== "conversation") {
23
36
  if (!role) return undefined;
24
37
  changes.push({
@@ -56,7 +69,7 @@ export function prepareModelSelection(session: AgentSession, selection: ModelSel
56
69
  changes: changed,
57
70
  consequence:
58
71
  selection.scope === "conversation"
59
- ? "Switches this conversation and records a manual routing pin; user model defaults are unchanged."
72
+ ? "Switches this conversation and records a manual routing pin; the selected provider-wide context tier is saved."
60
73
  : selection.scope === "default"
61
74
  ? "Saves the user default role, then switches this conversation and records a manual routing pin."
62
75
  : `Saves the ${role} role for future routed work; the active conversation model is unchanged.`,
@@ -71,6 +84,13 @@ export async function applyModelSelection(session: AgentSession, selection: Mode
71
84
  const previousModel = session.model;
72
85
  const previousThinking = session.thinkingLevel;
73
86
  const previousRoles = { ...session.settings.get("modelRoles") };
87
+ const contextSetting =
88
+ selection.contextTier && model.provider === "openai-codex"
89
+ ? "providers.openaiContextTier"
90
+ : selection.contextTier && model.provider === "litellm"
91
+ ? "providers.litellmContextTier"
92
+ : undefined;
93
+ const previousContextTier = contextSetting ? session.settings.get(contextSetting) : undefined;
74
94
  const previousRouting = session.getRoutingState();
75
95
  let switched = false;
76
96
  const role = scope === "default" ? "default" : selection.role;
@@ -80,10 +100,16 @@ export async function applyModelSelection(session: AgentSession, selection: Mode
80
100
  throw new Error(`No API key for ${selector}`);
81
101
  }
82
102
  try {
83
- if (scope !== "conversation") {
84
- session.settings.setModelRole(role!, formatModelSelectorValue(selector, thinkingLevel));
103
+ if (scope !== "conversation" || contextSetting) {
104
+ if (scope !== "conversation")
105
+ session.settings.setModelRole(role!, formatModelSelectorValue(selector, thinkingLevel));
106
+ if (contextSetting) session.settings.set(contextSetting, selection.contextTier!);
85
107
  await session.settings.flush({ throwOnError: true });
86
108
  }
109
+ if (contextSetting) {
110
+ if (model.provider === "openai-codex") session.modelRegistry.setOpenAIContextTier(selection.contextTier!);
111
+ else session.modelRegistry.setLiteLLMContextTier(selection.contextTier!);
112
+ }
87
113
  if (scope !== "role") {
88
114
  switched = true;
89
115
  await session.setModelTemporary(model, thinkingLevel);
@@ -101,10 +127,15 @@ export async function applyModelSelection(session: AgentSession, selection: Mode
101
127
  await session.sessionManager.flush();
102
128
  }
103
129
  } catch (error) {
104
- if (scope !== "conversation") {
130
+ if (scope !== "conversation" || contextSetting) {
105
131
  session.settings.set("modelRoles", previousRoles);
132
+ if (contextSetting) session.settings.set(contextSetting, previousContextTier!);
106
133
  await session.settings.flush({ throwOnError: true }).catch(() => {});
107
134
  }
135
+ if (contextSetting) {
136
+ if (model.provider === "openai-codex") session.modelRegistry.setOpenAIContextTier(previousContextTier!);
137
+ else session.modelRegistry.setLiteLLMContextTier(previousContextTier!);
138
+ }
108
139
  if (switched && previousModel) await session.setModelTemporary(previousModel, previousThinking);
109
140
  session.restoreRoutingState(previousRouting);
110
141
  if (switched) {
@@ -3,7 +3,9 @@
3
3
  For conceptual, product, operational, and support questions, search `xcsh://documentation/?search=<query>` first.
4
4
  Select the best result and read its exact follow-up URL,
5
5
  `xcsh://documentation/<source>/<stable-path>/index.md`, before answering. Use the source filter when the question
6
- clearly targets `docs-cloud-f5-com` or `my-f5-com`.
6
+ clearly targets `docs-cloud-f5-com`, `my-f5-com`, or `www-f5-com`. Prefer `www-f5-com` for conceptual and product
7
+ questions, `docs-cloud-f5-com` for configuration and procedures, and `my-f5-com` for troubleshooting and support.
8
+ An explicit source filter is authoritative.
7
9
 
8
10
  Render documentation images only when the user explicitly asks to see them. Extract all unique Markdown image references
9
11
  from the exact document, resolve relative references against its exact `xcsh://documentation/` URI, and
@@ -494,6 +494,8 @@ For conceptual, product, operational, or support questions about F5 Distributed
494
494
  search `xcsh://documentation/?search=<query>` first, then read the exact
495
495
  `xcsh://documentation/<source>/<stable-path>/index.md` result before answering. The search result
496
496
  is discovery; the exact Markdown document is the evidence.
497
+ Prefer `www-f5-com` for conceptual and product questions, `docs-cloud-f5-com` for configuration and
498
+ procedural questions, and `my-f5-com` for troubleshooting and support. An explicit source filter is authoritative.
497
499
 
498
500
  ### Documentation images
499
501
 
package/src/qmd-smoke.ts CHANGED
@@ -6,7 +6,11 @@ import {
6
6
  createEmbeddedDocumentationRepository,
7
7
  type EmbeddedDocumentationRepository,
8
8
  } from "./internal-urls/documentation-repository";
9
- import type { DocumentationRepository, DocumentationSearchResult } from "./internal-urls/documentation-resolve";
9
+ import type {
10
+ DocumentationRepository,
11
+ DocumentationSearchResult,
12
+ DocumentationSource,
13
+ } from "./internal-urls/documentation-resolve";
10
14
  import { convertToPng } from "./utils/image-convert";
11
15
 
12
16
  export const QMD_SMOKE_SUCCESS = "XCSH_QMD_SMOKE_OK";
@@ -17,6 +21,10 @@ const EXPECTED_CATEGORY = "dns-dns-zone-clone-from-dns-domain";
17
21
  const WAF_QUERY = "configure web application firewall";
18
22
  const WAF_PATH = "docs/how-to/app-security/web-app-firewall";
19
23
  const WAF_TITLE = "Create Web Application Firewall";
24
+ const MARKETING_QUERY = "what is client side defense";
25
+ const MARKETING_PATH = "products/distributed-cloud-services/client-side-defense";
26
+ const MARKETING_TITLE = "F5 Distributed Cloud Client-Side Defense";
27
+ const MARKETING_SOURCE = "www-f5-com" as const;
20
28
  const DNS_QUERY = "set up DNS load balancer";
21
29
  const DNS_PATH = "dns-management/how-to/configure-dns-load-balancer";
22
30
  const DNS_TITLE = "Set Up DNS Load Balancer";
@@ -31,6 +39,8 @@ const EXPECTED_EVENTS = [
31
39
  "api-catalog-rank",
32
40
  "documentation-search",
33
41
  "documentation-read",
42
+ "documentation-marketing-search",
43
+ "documentation-marketing-read",
34
44
  "documentation-search",
35
45
  "documentation-read",
36
46
  "documentation-follow-up-read",
@@ -47,7 +57,7 @@ export interface QmdSmokeTraceEntry {
47
57
  readonly event: QmdSmokeEvent;
48
58
  readonly resource?: string;
49
59
  readonly title?: string;
50
- readonly source?: typeof SOURCE;
60
+ readonly source?: DocumentationSource;
51
61
  readonly category?: string;
52
62
  readonly outcome?: string;
53
63
  }
@@ -59,12 +69,12 @@ export interface QmdSmokeOptions {
59
69
  documentationRepository?: DocumentationRepository;
60
70
  }
61
71
 
62
- function searchUri(query: string): string {
63
- return `xcsh://documentation/?search=${encodeURIComponent(query)}&source=${SOURCE}&limit=1`;
72
+ function searchUri(query: string, source: DocumentationSource = SOURCE): string {
73
+ return `xcsh://documentation/?search=${encodeURIComponent(query)}&source=${source}&limit=1`;
64
74
  }
65
75
 
66
- function documentUri(stablePath: string): string {
67
- return `xcsh://documentation/${SOURCE}/${stablePath}/index.md`;
76
+ function documentUri(stablePath: string, source: DocumentationSource = SOURCE): string {
77
+ return `xcsh://documentation/${source}/${stablePath}/index.md`;
68
78
  }
69
79
 
70
80
  function assetUri(stablePath: string, filename: string): string {
@@ -75,11 +85,12 @@ function assertSearchResult(
75
85
  result: DocumentationSearchResult | undefined,
76
86
  expectedPath: string,
77
87
  expectedTitle: string,
78
- ): DocumentationSearchResult & { source: typeof SOURCE } {
79
- if (result?.source !== SOURCE || result.stablePath !== expectedPath || result.title !== expectedTitle) {
80
- throw new Error(`Documentation QMD smoke expected ${expectedTitle} at ${SOURCE}/${expectedPath}`);
88
+ expectedSource: DocumentationSource = SOURCE,
89
+ ): DocumentationSearchResult {
90
+ if (result?.source !== expectedSource || result.stablePath !== expectedPath || result.title !== expectedTitle) {
91
+ throw new Error(`Documentation QMD smoke expected ${expectedTitle} at ${expectedSource}/${expectedPath}`);
81
92
  }
82
- return result as DocumentationSearchResult & { source: typeof SOURCE };
93
+ return result;
83
94
  }
84
95
 
85
96
  /** Parse and strictly validate stdout from the installed-binary QMD smoke driver. */
@@ -109,6 +120,8 @@ export function parseQmdSmokeOutput(output: string): QmdSmokeTraceEntry[] {
109
120
  const expectedResources = [
110
121
  searchUri(WAF_QUERY),
111
122
  documentUri(WAF_PATH),
123
+ searchUri(MARKETING_QUERY, MARKETING_SOURCE),
124
+ documentUri(MARKETING_PATH, MARKETING_SOURCE),
112
125
  searchUri(DNS_QUERY),
113
126
  documentUri(DNS_PATH),
114
127
  documentUri(DNS_PATH),
@@ -126,16 +139,20 @@ export function parseQmdSmokeOutput(output: string): QmdSmokeTraceEntry[] {
126
139
  if (trace[index]?.title !== WAF_TITLE || trace[index]?.source !== SOURCE)
127
140
  throw new Error("QMD smoke trace contains unexpected WAF identity");
128
141
  }
129
- for (const index of [3, 4, 5]) {
142
+ for (const index of [3, 4]) {
143
+ if (trace[index]?.title !== MARKETING_TITLE || trace[index]?.source !== MARKETING_SOURCE)
144
+ throw new Error("QMD smoke trace contains unexpected marketing identity");
145
+ }
146
+ for (const index of [5, 6, 7]) {
130
147
  if (trace[index]?.title !== DNS_TITLE || trace[index]?.source !== SOURCE)
131
148
  throw new Error("QMD smoke trace contains unexpected DNS identity");
132
149
  }
133
- if (trace[6]?.outcome !== "no-match-no-fetch") throw new Error("QMD smoke trace did not fail closed");
134
- if (trace[7]?.outcome !== DOCUMENTATION_TOOLS_DISABLED_MESSAGE)
150
+ if (trace[8]?.outcome !== "no-match-no-fetch") throw new Error("QMD smoke trace did not fail closed");
151
+ if (trace[9]?.outcome !== DOCUMENTATION_TOOLS_DISABLED_MESSAGE)
135
152
  throw new Error("QMD smoke trace omitted the tools-disabled explanation");
136
- if (trace[8]?.source !== SOURCE || trace[8].outcome !== "image/png")
153
+ if (trace[10]?.source !== SOURCE || trace[10].outcome !== "image/png")
137
154
  throw new Error("QMD smoke trace contains an unexpected SVG conversion result");
138
- if (trace[9]?.outcome !== "ok") throw new Error("QMD smoke trace did not confirm SQLite startup");
155
+ if (trace[11]?.outcome !== "ok") throw new Error("QMD smoke trace did not confirm SQLite startup");
139
156
  return trace;
140
157
  }
141
158
 
@@ -173,6 +190,32 @@ export async function runQmdSmoke(options: QmdSmokeOptions = {}): Promise<string
173
190
  }
174
191
  emit({ event: "documentation-read", resource: documentUri(WAF_PATH), title: wafDocument.title, source: SOURCE });
175
192
 
193
+ const marketing = assertSearchResult(
194
+ (await documentation.search(MARKETING_QUERY, MARKETING_SOURCE, 1))[0],
195
+ MARKETING_PATH,
196
+ MARKETING_TITLE,
197
+ MARKETING_SOURCE,
198
+ );
199
+ emit({
200
+ event: "documentation-marketing-search",
201
+ resource: searchUri(MARKETING_QUERY, MARKETING_SOURCE),
202
+ title: marketing.title,
203
+ source: marketing.source,
204
+ });
205
+ const marketingDocument = await documentation.readDocument(MARKETING_SOURCE, MARKETING_PATH);
206
+ if (
207
+ marketingDocument?.title !== MARKETING_TITLE ||
208
+ !marketingDocument.markdown.includes("# F5 Client-Side Defense")
209
+ ) {
210
+ throw new Error("Documentation QMD smoke could not read exact marketing Markdown");
211
+ }
212
+ emit({
213
+ event: "documentation-marketing-read",
214
+ resource: documentUri(MARKETING_PATH, MARKETING_SOURCE),
215
+ title: marketingDocument.title,
216
+ source: MARKETING_SOURCE,
217
+ });
218
+
176
219
  const dns = assertSearchResult((await documentation.search(DNS_QUERY, SOURCE, 1))[0], DNS_PATH, DNS_TITLE);
177
220
  emit({ event: "documentation-search", resource: searchUri(DNS_QUERY), title: dns.title, source: dns.source });
178
221
  const dnsDocument = await documentation.readDocument(SOURCE, DNS_PATH);