@itpay/cli 2.1.22 → 2.1.29

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.
@@ -9,6 +9,7 @@ import { ensureIdeImageAttach } from "../render/ide.js";
9
9
  import { buildCheckoutHandoff, shouldPrepareLocalCheckoutImage } from "./checkout_handoff.js";
10
10
  import { localizeCardURL, normalizeCardLocale } from "../render/locale.js";
11
11
  import { buildAgentChatHandoff } from "../render/markdown.js";
12
+ import { refundStateEnvelope } from "./refund.js";
12
13
  import { decodeRailCatalogJourneys } from "./rail_catalog.js";
13
14
  import { platformKeyForHost } from "../render/plan.js";
14
15
  import { renderTerminalQR } from "../render/qr.js";
@@ -17,7 +18,7 @@ import { renderTerminalQR } from "../render/qr.js";
17
18
  // The server alone decides service version, quota and provider credentials.
18
19
  const RAIL_PROGRESSIVE_FEATURES = ["rail.progressive.v2"];
19
20
  import { buildCheckoutQRPlan } from "./buy.js";
20
- import { appendFeedbackPostmortemInstruction, CommandContractError, isTerminalServiceExecutionStatus, writeCommandEnvelope, } from "./guidance.js";
21
+ import { appendFeedbackPostmortemInstruction, CommandContractError, isTerminalServiceExecutionStatus, writeCommandEnvelope, shellArgument, plainValueLines, } from "./guidance.js";
21
22
  // AUTH04: record the pause point of each Service Execution so a later
22
23
  // `itpay auth login` can resume the SAME task. Ids/stage/resume command only —
23
24
  // no chat content, PII or credentials. Best-effort: journaling never breaks
@@ -35,24 +36,24 @@ function journalTaskState(serviceExecutionID, envelope, env) {
35
36
  const serviceActionStatuses = new Set(["pending", "approved", "rejected", "expired", "cancelled"]);
36
37
  const RAIL_SERVICE_GUIDANCE = {
37
38
  "itpay-rail-exact": {
38
- when_to_use: "已知可信站对时先用本服务核验直达;城市需求可先形成有依据的站对假设。只覆盖这一站对,不代表全城市最优。完整流程:itpay docs show rail-booking --json。",
39
+ when_to_use: "已知可信站对时先用本服务核验直达;城市需求可先形成有依据的站对假设。只覆盖这一站对,不代表全城市最优。首次查询前先留2–4行路线摘要,具体方法:itpay docs show rail-booking。",
39
40
  input_fields: [
40
- { name: "origin", required: true, description: "出发火车站名(如'古镇'),不能是城市或地址", example: "古镇" },
41
- { name: "destination", required: true, description: "到达火车站名(如'广州南')", example: "广州南" },
42
- { name: "travel_date", required: true, description: "出行日期 YYYY-MM-DD;字段名必须是 travel_date,'date' 等别名无效", example: "2026-09-19" },
41
+ { name: "origin", required: true, description: "已核实出发铁路站完整名称,不能以同名站代替区域", example: "<已核实出发站>" },
42
+ { name: "destination", required: true, description: "已核实到达铁路站完整名称", example: "<已核实到达站>" },
43
+ { name: "travel_date", required: true, description: "出行日期 YYYY-MM-DD;字段名必须是 travel_date,'date' 等别名无效", example: "<用户日期 YYYY-MM-DD>" },
43
44
  ],
44
- input_example: { origin: "古镇", destination: "广州南", travel_date: "2026-09-19" },
45
+ input_example: { origin: "<已核实出发站名>", destination: "<已核实到达站名>", travel_date: "<用户出行日期 YYYY-MM-DD>" },
45
46
  notes: [
46
47
  "本服务只接受这三个字段,多传字段会被供应商拒绝",
47
- "查询为空只说明这一已解析站对;如原意仍需更多覆盖,可验证另一可信站对或使用 Smart",
48
+ "查询为空只说明这一已解析站对;允许中转可用Smart保持双站;改变用户指定站须先取得同意",
48
49
  ],
49
50
  },
50
51
  "itpay-rail-smart": {
51
- when_to_use: "需要广泛站点比较、中转或具体地址接驳时使用;有可信站对且只需先核验直达可用 Exact。完整流程:itpay docs show rail-booking --json。",
52
+ when_to_use: "需要广泛站点比较、中转或具体地址接驳时使用;有可信站对且只需先核验直达可用 Exact。首次查询前先留2–4行路线摘要,具体方法:itpay docs show rail-booking。",
52
53
  input_fields: [
53
- { name: "origin", required: true, description: "出发地:已知最完整的位置名或区域(城市/区县/地址均可)", example: "中山古镇" },
54
- { name: "destination", required: true, description: "目的地:与 origin 同样的位置规则", example: "广州南" },
55
- { name: "travel_date", required: true, description: "出行日期 YYYY-MM-DD;字段名必须是 travel_date", example: "2026-09-19" },
54
+ { name: "origin", required: true, description: "出发地:已知最完整的位置名或区域(城市/区县/地址均可)", example: "<原始完整地点>" },
55
+ { name: "destination", required: true, description: "目的地:与 origin 同样的位置规则", example: "<已核实到达站>" },
56
+ { name: "travel_date", required: true, description: "出行日期 YYYY-MM-DD;字段名必须是 travel_date", example: "<用户日期 YYYY-MM-DD>" },
56
57
  ],
57
58
  optional_fields: [
58
59
  { name: "origin_city", description: "出发地城市提示,辅助位置解析" },
@@ -60,20 +61,38 @@ const RAIL_SERVICE_GUIDANCE = {
60
61
  { name: "origin_location", description: "已知坐标对象(高德 GCJ-02)" },
61
62
  { name: "destination_location", description: "目的地坐标对象" },
62
63
  { name: "depart_after", description: "不早于该时间出发" },
64
+ { name: "depart_before", description: "不晚于该时间出发" },
63
65
  { name: "arrive_before", description: "不晚于该时间到达" },
66
+ { name: "user_query", description: "旅行需求原话,最多4096 UTF-8字节;时间等明确条件仍填写对应结构化字段" },
67
+ { name: "seat_preferences", description: "已确认的席别代码数组,不填写旅客身份" },
64
68
  { name: "priority", enum: "balanced|fastest|cheapest|safest|flexible", description: "方案偏好" },
69
+ { name: "duration_basis", enum: "rail|door_to_door", description: "最快默认按铁路全程含换乘;明确要求且接驳证据完整时可按门到门" },
65
70
  { name: "max_transfers", description: "允许中转次数,0-2" },
66
71
  { name: "passengers", description: "成人乘客整数 1-5;不填写身份资料" },
67
72
  ],
68
- input_example: { origin: "上海", destination: "长沙", travel_date: "2026-10-06", arrive_before: "15:00", priority: "fastest", passengers: 1 },
73
+ input_example: { origin: "<用户出发地点>", destination: "<用户目的地>", travel_date: "<用户出行日期 YYYY-MM-DD>", priority: "fastest", passengers: 1 },
69
74
  notes: [
70
75
  "位置有歧义时会进入位置确认步骤,请用户选定后继续同一执行",
71
76
  "推荐结果含每趟车的席别与余票;购票在后续受保护流程完成",
72
77
  ],
73
78
  },
74
79
  };
75
- function railServiceGuidance(serviceID) {
76
- return RAIL_SERVICE_GUIDANCE[serviceID];
80
+ function railServiceGuidance(serviceID, schema) {
81
+ const guidance = RAIL_SERVICE_GUIDANCE[serviceID];
82
+ if (serviceID !== "itpay-rail-smart" || !guidance || !schema?.properties?.endpoints)
83
+ return guidance;
84
+ return {
85
+ ...guidance,
86
+ input_fields: [
87
+ { name: "endpoints", required: true, description: "origin/destination 各填 kind(area/place/station) 与完整 text;不确定 kind 可省略" },
88
+ { name: "travel_date", required: true, description: "用户出行日期 YYYY-MM-DD" },
89
+ ],
90
+ optional_fields: (guidance.optional_fields ?? []).filter((field) => !["origin_city", "destination_city", "origin_location", "destination_location"].includes(field.name)).concat([
91
+ { name: "reuse_from", description: "同 owner 的已保存 Exact result_item_id 或 Smart snapshot_id;端点/日期新查询可复用有效事实" },
92
+ ]),
93
+ input_example: { endpoints: { origin: { kind: "area", text: "<用户出发市县镇完整名称>" }, destination: { kind: "station", text: "<已核实到达站名>" } }, travel_date: "<用户出行日期 YYYY-MM-DD>" },
94
+ notes: ["仅用当前 input_schema 支持的端点写法;不要与旧 origin/destination 混用", "唯一地点直接解析;真实歧义只询问有缺口的一端,外部故障按当前执行恢复"],
95
+ };
77
96
  }
78
97
  const WORKFLOW_STEP_GUIDANCE = {
79
98
  input: { meaning: "输入校验", hint: "核对本服务的输入契约和服务端错误码;本执行的输入不能凭猜测修复" },
@@ -87,22 +106,6 @@ const WORKFLOW_STEP_GUIDANCE = {
87
106
  recommend: { meaning: "方案推荐", hint: "查看已保存候选和实际推荐失败原因;不要无原因放宽条件" },
88
107
  delivery: { meaning: "交付", hint: "读取当前执行状态;已有订单或付款状态不明时先核实,不重建" },
89
108
  };
90
- function failedWorkflowStep(steps, errorCode) {
91
- if (!steps)
92
- return undefined;
93
- for (const [step, status] of Object.entries(steps)) {
94
- if (status === "failure" && step !== "failure")
95
- return step;
96
- }
97
- // Only a backend-recorded condition_unmet proves a false branch routed to failure.
98
- if (errorCode === "condition_unmet") {
99
- for (const [step, status] of Object.entries(steps)) {
100
- if (status === "false" && step !== "failure")
101
- return step;
102
- }
103
- }
104
- return undefined;
105
- }
106
109
  export async function runServicesStart(backend, serviceID, options = {}) {
107
110
  const host = options.host ?? "terminal";
108
111
  const response = await backend.startServiceExecution({
@@ -115,11 +118,11 @@ export async function runServicesStart(backend, serviceID, options = {}) {
115
118
  },
116
119
  });
117
120
  if (response.workflow_entry) {
118
- const guidance = railServiceGuidance(serviceID);
121
+ const guidance = railServiceGuidance(serviceID, response.workflow_entry.input_schema);
119
122
  writeCommandEnvelope({
120
123
  status: "input_required",
121
124
  result: { service_execution_id: response.execution.service_execution_id, service_id: serviceID, input_schema: response.workflow_entry.input_schema, ...(guidance ? { guidance } : {}) },
122
- instruction: guidance ? "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 可直接照抄),然后继续同一服务执行。不要臆造字段名。" : "根据服务声明填写输入,然后继续同一服务执行。",
125
+ instruction: guidance ? "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 是待填示意,必须替换用户真实值),然后继续同一服务执行。不要臆造字段名。" : "根据服务声明填写输入,然后继续同一服务执行。",
123
126
  next: null,
124
127
  interaction: {
125
128
  schema_version: "itpay.interaction.v1",
@@ -192,18 +195,29 @@ function locationConfirmationEnvelope(executionID, capabilityID, input, confirma
192
195
  input = (confirmation.input ?? input);
193
196
  const endpoints = (confirmation.endpoints ?? []);
194
197
  const choices = Object.fromEntries(endpoints.map((endpoint) => [endpoint.side, "<用户选择的候选 id>"]));
198
+ const missing = endpoints.filter((endpoint) => !endpoint.candidates?.length);
199
+ const reason = missing[0]?.reason?.toUpperCase();
200
+ const instruction = missing.length
201
+ ? reason === "STATION_LOCATION_EVIDENCE_REQUIRED"
202
+ ? "尚未查票。车站身份已知,但 Smart 缺少该站地理坐标证据;请说明此系统缺口。若用户仅要双站直达,可按原站名使用 Exact;不要要求用户换站。"
203
+ : reason === "ADDRESS_REQUIRED" || reason === "ADMINISTRATIVE_AREA_UNRESOLVED"
204
+ ? "尚未查票。请只向用户询问该端点缺少的具体地址或行政归属;保留另一端和已有条件,得到信息后发起关联新查询。"
205
+ : reason === "COORDINATE_IDENTITY_UNVERIFIED" || reason === "COORDINATE_NAME_MISMATCH"
206
+ ? "尚未查票。提交的地点名称和坐标尚未得到一致证据;请核实该端点的真实名称或位置,保留另一端,不要把坐标声明当成人工确认。"
207
+ : "尚未查票。地点证据不足;请说明返回的具体原因与受影响端点,不要把外部故障当作用户地点歧义。"
208
+ : "尚未查票。仅向用户确认有真实歧义的端点,展示候选名称、地址和高德链接。用户选择后保留原输入,回到同一服务。";
195
209
  return {
196
210
  status: "location_confirmation_required",
197
211
  result: { service_execution_id: executionID, query: input, location_confirmation: confirmation },
198
- instruction: "尚未查票。仅向用户确认 endpoints 中有歧义的地点,展示真实候选名称、地址和高德链接。不要自行选择候选、把区域改成车站或重复调用原查询。用户选择后保留原输入,带 location_confirmation 回到同一服务。没有候选时请用户补充已知城市或准确地名,再使用修正输入查询。",
212
+ instruction,
199
213
  next: null,
200
- ...(confirmation.can_resume ? {
214
+ ...(confirmation.can_resume && !missing.length && endpoints.length ? {
201
215
  interaction: {
202
216
  schema_version: "itpay.interaction.v1",
203
217
  stage: "location_confirmation_required",
204
218
  input_template: {
205
- command: `itpay services invoke ${executionID} --capability ${capabilityID}${formatInputOptions({ ...input,
206
- location_confirmation: { plan_id: confirmation.plan_id, token: confirmation.token, choices } })} --json`,
219
+ command: `itpay services invoke ${executionID} --capability ${capabilityID} --input-json <地点确认.json> --json`,
220
+ input: { ...input, location_confirmation: { plan_id: confirmation.plan_id, token: confirmation.token, choices } },
207
221
  required_input: ["choices"],
208
222
  executable: false,
209
223
  },
@@ -218,6 +232,8 @@ function requiredInputFields(schema) {
218
232
  }
219
233
  function locationInputInstruction(schema) {
220
234
  const properties = schema?.properties;
235
+ if (properties?.endpoints)
236
+ return " 两端分别填写 endpoints.origin/destination 的 kind 与完整 text;不确定 kind 可省略。区域保留范围、精确车站仅在目录验证后锁定。不要混用旧 origin/destination、手填锁站或伪造坐标。唯一地点会自动解析;只有真实多候选才让用户选返回的候选 id,地图失败应按当前执行恢复。";
221
237
  if (!properties?.origin_location || !properties?.destination_location)
222
238
  return "";
223
239
  if (!properties.location_confirmation)
@@ -235,13 +251,9 @@ export async function runServicesInvoke(backend, config, serviceExecutionID, cap
235
251
  }
236
252
  const missingInput = missingRequiredInput(requestedCapability.input_schema, input);
237
253
  if (missingInput.length > 0) {
238
- const correctedInput = { ...input };
239
- for (const field of missingInput)
240
- correctedInput[field] = "<value>";
241
- throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐 required_input 后重试同一个 execution;本次没有调用 Provider。", [{
242
- command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(correctedInput)} --json`,
243
- reason: "提交完整 capability 输入",
244
- }]);
254
+ throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "只补缺失字段到小JSON文件,再提交同一执行;本次尚未调用供应商。", [], { schema_version: "itpay.interaction.v1", stage: "input_required",
255
+ input_template: { command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID} --input-json <file> --json`,
256
+ required_input: ["file", ...missingInput], executable: false } });
245
257
  }
246
258
  const idempotencyKey = await operationID(config, `service.invoke:${serviceExecutionID}:${capabilityID}:${stableInput(input)}`);
247
259
  let response;
@@ -294,9 +306,16 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
294
306
  ...(quota ? { quota } : {}),
295
307
  };
296
308
  const preview = response.invocation?.safe_result_preview;
309
+ const notices = Array.isArray(preview?.notices) ? preview.notices.map((notice) => {
310
+ if (!notice || typeof notice !== "object" || !("code" in notice) || !("message" in notice) || typeof notice.message !== "string")
311
+ return notice;
312
+ if (!["RAIL_TRANSFER_SCOPE_LIMIT", "RAIL_TRANSFER_SEARCH_INCOMPLETE"].includes(String(notice.code)))
313
+ return notice;
314
+ return { ...notice, message: notice.message.replace(/可按主要枢纽分段查询[。;]?/g, "") };
315
+ }) : preview?.notices;
297
316
  if (!response.effective_quota?.exhausted && preview?.search_status === "LOCATION_CONFIRMATION_REQUIRED" && preview.location_confirmation) {
298
317
  return { value: locationConfirmationEnvelope(response.execution.service_execution_id, requestedCapability.capability_id, input, preview.location_confirmation),
299
- plainResult: [JSON.stringify(preview.location_confirmation)] };
318
+ plainResult: plainValueLines(preview.location_confirmation) };
300
319
  }
301
320
  if (typeof preview?.catalog_total === "number") {
302
321
  baseResult.catalog = {
@@ -304,7 +323,7 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
304
323
  recommendation: preview.recommendation,
305
324
  decision_source: preview.decision_source,
306
325
  coverage: preview.coverage,
307
- notices: preview.notices,
326
+ notices,
308
327
  search_status: preview.search_status,
309
328
  searched_scope: preview.searched_scope,
310
329
  effective_policy_hash: preview.effective_policy_hash,
@@ -321,15 +340,15 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
321
340
  let instruction = items.length > 0
322
341
  ? "用编号、名称和可公开字段向用户说明候选;若候选列表已满足目标就停止。只有用户明确选择并希望继续时,才提交对应编号;不要向用户提及 safe_payload、Execution 或内部 ID。"
323
342
  : `没有找到与“${queryText(input)}”匹配的结果。向用户展示本次为 0 个结果并停止。不要修改、缩短或猜测其他输入;只有用户明确提供新输入后,才能启动新的查询。`;
324
- if (items.length === 0 && Array.isArray(preview?.notices)) {
325
- const railNotice = preview.notices.find((notice) => {
343
+ if (items.length === 0 && Array.isArray(notices)) {
344
+ const railNotice = notices.find((notice) => {
326
345
  if (!notice || typeof notice !== "object")
327
346
  return false;
328
347
  const value = notice;
329
348
  return ["RAIL_TRANSFER_SCOPE_LIMIT", "RAIL_TRANSFER_SEARCH_INCOMPLETE"].includes(String(value.code)) && typeof value.message === "string";
330
349
  });
331
350
  if (railNotice)
332
- instruction = `向用户展示官方提示:${railNotice.message} 不要断言该行程必须多次中转或没有车。等待用户选择分段查询的起终点,不自动更换输入或重试。`;
351
+ instruction = `向用户展示官方提示:${railNotice.message} 说明本次已查范围和未完成原因,按服务端提供的当前动作恢复;不能据此断言没有车,不默认扩大范围或让用户另选分段站点。`;
333
352
  }
334
353
  let next = null;
335
354
  if (items.length > 0 && baseResult.catalog) {
@@ -375,13 +394,6 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
375
394
  ? {
376
395
  schema_version: "itpay.interaction.v1",
377
396
  stage: "query_results_ready",
378
- by_goal: {
379
- compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
380
- prepare_checkout: {
381
- action: "select_under_user_rules_then_prepare",
382
- requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
383
- },
384
- },
385
397
  input_template: {
386
398
  command: `itpay services action ${response.execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
387
399
  required_input: ["rank"],
@@ -397,40 +409,7 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
397
409
  };
398
410
  }
399
411
  function serviceResultPlainLines(result) {
400
- const lines = [
401
- `service_execution_id: ${String(result.service_execution_id)}`,
402
- `capability_id: ${String(result.capability_id)}`,
403
- ];
404
- const items = result.items;
405
- const query = result.query;
406
- if (query) {
407
- for (const [key, value] of Object.entries(query))
408
- lines.push(`${key}: ${String(value)}`);
409
- }
410
- if (items.length === 0)
411
- lines.push("results: 0");
412
- const catalog = result.catalog;
413
- if (catalog?.resolved_locations)
414
- lines.push(`resolved_locations: ${JSON.stringify(catalog.resolved_locations)}`);
415
- if (catalog?.journey_summary) {
416
- for (const key of ["journey_summary", "train_services", "journeys"]) {
417
- lines.push(`${key}: ${JSON.stringify(catalog[key])}`);
418
- }
419
- }
420
- if (result.quota)
421
- lines.push(`quota: ${JSON.stringify(result.quota)}`);
422
- if (result.checkout)
423
- lines.push(`checkout: ${JSON.stringify(result.checkout)}`);
424
- if (items.length > 0) {
425
- lines.push("items:");
426
- for (const item of items) {
427
- lines.push(` ${item.rank}. ${item.title}`);
428
- for (const [key, value] of Object.entries(item.safe_payload)) {
429
- lines.push(` ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
430
- }
431
- }
432
- }
433
- return lines;
412
+ return plainValueLines(result);
434
413
  }
435
414
  function queryText(input) {
436
415
  const value = Object.values(input).find((item) => typeof item === "string" && item.trim() !== "");
@@ -524,11 +503,6 @@ function formatInputOptions(input) {
524
503
  : ` --input ${shellArgument(`${key}=${typeof value === "object" && value !== null ? JSON.stringify(value) : String(value)}`)}`)
525
504
  .join("");
526
505
  }
527
- function shellArgument(value) {
528
- if (/^[\p{L}\p{N}._:=/-]+$/u.test(value))
529
- return value;
530
- return `'${value.replaceAll("'", `'"'"'`)}'`;
531
- }
532
506
  export async function runServicesAction(backend, serviceExecutionID, actionType, input, options = {}) {
533
507
  const selection = await resolveCandidateSelection(backend, serviceExecutionID, actionType, options);
534
508
  const request = {
@@ -674,13 +648,10 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
674
648
  const lockedInput = options.lockedInput ?? {};
675
649
  const missingInput = missingRequiredInput(capability.input_schema, lockedInput);
676
650
  if (missingInput.length > 0 && !readModel.workflow_entry && readModel.execution.next_action !== "create_checkout") {
677
- throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 的 required_input;本次没有创建 quote、Checkout 或订单。", [{ command: checkoutCommand(serviceExecutionID, capability, lockedInput), reason: "提交完整且会被锁定的服务输入" }]);
651
+ throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 的 required_input;本次没有创建 quote、Checkout 或订单。", [], { schema_version: "itpay.interaction.v1", stage: "input_required", input_template: { command: checkoutCommand(serviceExecutionID, capability, lockedInput), required_input: missingInput, executable: false } });
678
652
  }
679
653
  if (capability.delivery_email_required && readModel.workflow_entry?.capability_id !== capability.capability_id && String(deliveryContact.email ?? "").trim() === "") {
680
- throw new CommandContractError("delivery_email_required", "delivery email is required before creating this service checkout", "该 capability 的交付链接会发送到用户邮箱;先向用户说明用途并询问邮箱,不要代填。", [{
681
- command: `itpay services checkout ${serviceExecutionID} --capability ${capability.capability_id}${formatInputOptions(lockedInput)} --email <email> --json`,
682
- reason: "使用用户提供的邮箱创建 Checkout",
683
- }]);
654
+ throw new CommandContractError("delivery_email_required", "delivery email is required before creating this service checkout", "该 capability 的交付链接会发送到用户邮箱;先向用户说明用途并询问邮箱,不要代填。", [], { schema_version: "itpay.interaction.v1", stage: "input_required", input_template: { command: `itpay services checkout ${serviceExecutionID} --capability ${capability.capability_id}${formatInputOptions(lockedInput)} --email <email> --json`, required_input: ["email"], executable: false } });
684
655
  }
685
656
  }
686
657
  const response = await backend.createServiceExecutionCheckout(serviceExecutionID, {
@@ -697,6 +668,7 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
697
668
  const cardURL = localizeCardURL(absolutePublicURL(config.baseURL, checkout.card_url ?? fallbackCardURL(config.baseURL, checkoutID, displayToken)), locale);
698
669
  const cardPNGURL = localizeCardURL(absolutePublicURL(config.baseURL, checkout.card_png_url ?? checkout.qr_png_url ?? fallbackCardPNGURL(config.baseURL, checkoutID, displayToken)), locale);
699
670
  let itineraryTitle;
671
+ let itinerary;
700
672
  try {
701
673
  const booking = await backend.getServiceExecution(serviceExecutionID);
702
674
  if (booking.execution.service_id === "itpay-rail-booking") {
@@ -705,6 +677,12 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
705
677
  const reviewLeg = Array.isArray(review?.legs) ? review.legs[0] : undefined;
706
678
  const facts = leg ?? reviewLeg;
707
679
  if (facts) {
680
+ const safe = facts;
681
+ itinerary = { travel_date: safe.travel_date, train_code: safe.train_code,
682
+ from_station: safe.from ?? safe.from_station, to_station: safe.to ?? safe.to_station,
683
+ departure: safe.departure, arrival: safe.arrival,
684
+ seat_name: safe.seat_name ?? safe.seat_type_name,
685
+ passengers: review?.passengers ?? safe.passengers ?? null };
708
686
  itineraryTitle = [facts.travel_date, facts.train_code,
709
687
  facts.from ?? facts.from_station,
710
688
  "→", facts.to ?? facts.to_station,
@@ -763,13 +741,9 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
763
741
  });
764
742
  }
765
743
  const envelope = buildServicesCheckoutEnvelope(response, checkoutURL, plan, options.agentType, options.target);
766
- const plainResult = [
767
- `service_execution_id: ${response.binding.service_execution_id}`,
768
- `checkout_id: ${checkoutID}`,
769
- `capability_id: ${checkoutCapabilityID(response, capabilityID)}`,
770
- `locked_input: ${JSON.stringify(response.locked_input)}`,
771
- `amount: ${formatMoney(checkout.checkout.amount_minor, checkout.checkout.currency)}`,
772
- ];
744
+ if (itinerary)
745
+ envelope.result = { ...envelope.result, itinerary };
746
+ const plainResult = plainValueLines(envelope.result);
773
747
  if (!options.jsonOutput && platform === "terminal") {
774
748
  plainResult.push("qr:", await renderTerminalQR(checkoutURL, options.qrFormat ?? "terminal"));
775
749
  }
@@ -789,14 +763,14 @@ export async function runServicesQuote(backend, serviceExecutionID, capabilityID
789
763
  model.allowed_actions?.some((action) => action.type === "prepare_quote" && action.capability_id === capabilityID);
790
764
  const missingInput = missingRequiredInput(capability.input_schema, input);
791
765
  if (missingInput.length > 0 && !selectionBacked && model.workflow_entry?.capability_id !== capability.capability_id) {
792
- throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 输入;本次没有创建 Quote、Cart 或 Checkout。", [{ command: quoteCommand(serviceExecutionID, capability, input), reason: "提交完整且会被锁定的输入" }]);
766
+ throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 输入;本次没有创建 Quote、Cart 或 Checkout。", [], { schema_version: "itpay.interaction.v1", stage: "input_required", input_template: { command: quoteCommand(serviceExecutionID, capability, input), required_input: missingInput, executable: false } });
793
767
  }
794
768
  const deliveryContact = {
795
769
  ...(options.deliveryContact ?? {}),
796
770
  ...(options.email ? { email: options.email } : {}),
797
771
  };
798
772
  if (capability.delivery_email_required && model.workflow_entry?.capability_id !== capability.capability_id && String(deliveryContact.email ?? "").trim() === "") {
799
- throw new CommandContractError("delivery_email_required", "delivery email is required before preparing this service quote", "交付链接会发送到用户邮箱;说明用途并询问邮箱,不要代填。", [{ command: quoteCommand(serviceExecutionID, capability, input), reason: "使用用户提供的邮箱创建报价" }]);
773
+ throw new CommandContractError("delivery_email_required", "delivery email is required before preparing this service quote", "交付链接会发送到用户邮箱;说明用途并询问邮箱,不要代填。", [], { schema_version: "itpay.interaction.v1", stage: "input_required", input_template: { command: quoteCommand(serviceExecutionID, capability, input), required_input: ["email"], executable: false } });
800
774
  }
801
775
  const quote = await backend.prepareServiceQuote(serviceExecutionID, {
802
776
  capability_id: capabilityID,
@@ -865,18 +839,12 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
865
839
  ...(nextState.interaction ? { interaction: nextState.interaction } : {}),
866
840
  ...(nextState.communication ? { communication: nextState.communication } : {}),
867
841
  ...(nextState.handoff ? { handoff: nextState.handoff } : {}),
868
- recovery: nextState.recovery,
842
+ recovery: nextState.recovery.filter(action => action.command !== `itpay services get ${serviceExecutionID} --json`),
869
843
  };
870
844
  writeCommandEnvelope(envelope, {
871
845
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
872
846
  ...(options.output ? { output: options.output } : {}),
873
- plainResult: [
874
- `service_execution_id: ${execution.service_execution_id}`,
875
- `service_id: ${execution.service_id}`,
876
- `state: ${execution.status}/${execution.phase}`,
877
- ...(execution.current_capability_id ? [`current_capability_id: ${execution.current_capability_id}`] : []),
878
- ...timeline.map((event) => `${event.sequence}. ${event.step} ${event.status}/${event.phase} ${event.occurred_at}`),
879
- ],
847
+ plainResult: plainValueLines(envelope.result),
880
848
  });
881
849
  }
882
850
  function shouldWaitForServiceResult(model) {
@@ -905,8 +873,6 @@ export async function runServicesNext(backend, serviceExecutionID, options = {})
905
873
  envelope.next = { command: `itpay services next ${serviceExecutionID} --timeout 120 --json`, reason: "稍后继续等待同一任务;不重新提交查询" };
906
874
  }
907
875
  journalTaskState(serviceExecutionID, envelope, options.env ?? process.env);
908
- if (response.rail_booking)
909
- envelope.result = { ...envelope.result, rail_booking: response.rail_booking };
910
876
  writeCommandEnvelope(envelope, {
911
877
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
912
878
  ...(options.output ? { output: options.output } : {}),
@@ -929,18 +895,18 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
929
895
  ? container.candidates[0] : undefined;
930
896
  if (exactCandidate && typeof exactCandidate.train_code === "string" && Array.isArray(exactCandidate.seats)) {
931
897
  const offer = exactCandidate.booking_offer;
898
+ const { booking_offer: _bookingOffer, ...candidateFacts } = exactCandidate;
932
899
  const seats = exactCandidate.seats.filter((seat) => seat.purchase_supported === true);
933
- const seat = seats[0];
934
900
  writeCommandEnvelope({
935
901
  status: "exact_train_detail",
936
902
  result: {
937
903
  service_execution_id: serviceExecutionID, result_item_id: resultItemID, result_offset: offset,
938
- candidate: exactCandidate,
904
+ candidate: candidateFacts,
939
905
  ...(offer?.service_id && offer.selection_token ? { booking_template: {
940
906
  command: `itpay services run ${offer.service_id} --input-json <file> --json`,
941
907
  executable: false, required_input: ["file", "passengers", "seat_type"],
942
908
  seat_choices: seats.map((choice) => ({ seat_type: choice.seat_type, seat_name: choice.seat_name })),
943
- input_example: { selection: { token: offer.selection_token, ...(typeof seat?.seat_type === "string" ? { seat_type: seat.seat_type } : {}) }, passengers: 1 },
909
+ input_example: { selection: { token: offer.selection_token, seat_type: "<用户授权席别对应代码>" }, passengers: "<真实人数>" },
944
910
  } } : {}),
945
911
  },
946
912
  instruction: offer?.selection_token
@@ -949,7 +915,7 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
949
915
  next: null, recovery: [],
950
916
  }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
951
917
  ...(options.output ? { output: options.output } : {}),
952
- plainResult: [`${exactCandidate.train_code} ${exactCandidate.from_station ?? ""} → ${exactCandidate.to_station ?? ""}`] });
918
+ });
953
919
  return;
954
920
  }
955
921
  // rail.progressive.v2 saved pages key journeys/journey_page; legacy result
@@ -970,7 +936,9 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
970
936
  total: catalogPage.total ?? candidates.length,
971
937
  count: catalogPage.count ?? candidates.length,
972
938
  next_offset: nextOffset,
973
- page,
939
+ ...(journeys.length ? { journeys: journeys.map(value => compactRailJourney(value, serviceExecutionID, resultItemID)) }
940
+ : candidates.some(value => typeof value === "object" && value !== null && "train_code" in value)
941
+ ? { candidates: candidates.map((value, index) => exactTrainRow(value, serviceExecutionID, resultItemID, (catalogPage.offset ?? offset) + index)) } : { page }),
974
942
  },
975
943
  instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。先读完所需页,再合并回答用户;不要逐页播报。",
976
944
  next: nextOffset !== null
@@ -986,11 +954,7 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
986
954
  writeCommandEnvelope(envelope, {
987
955
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
988
956
  ...(options.output ? { output: options.output } : {}),
989
- plainResult: candidates.map((candidate) => {
990
- const c = candidate;
991
- const title = typeof c.title === "string" ? c.title : JSON.stringify(c);
992
- return `${title}`;
993
- }),
957
+ plainResult: plainValueLines(envelope.result),
994
958
  });
995
959
  }
996
960
  export async function runServicesList(backend, options = {}) {
@@ -1005,6 +969,7 @@ export async function runServicesList(backend, options = {}) {
1005
969
  status: execution.status,
1006
970
  phase: execution.phase,
1007
971
  updated_at: execution.updated_at,
972
+ reader: { command: `itpay services next ${execution.service_execution_id} --json`, reason: "读取这笔已有服务" },
1008
973
  }));
1009
974
  const latest = executions[0];
1010
975
  const envelope = {
@@ -1013,7 +978,7 @@ export async function runServicesList(backend, options = {}) {
1013
978
  instruction: executions.length === 1
1014
979
  ? "只有一条可恢复记录;继续读取同一笔服务。"
1015
980
  : latest
1016
- ? "用服务和状态说明这些可恢复记录;多个结果必须让用户选择。"
981
+ ? "按原任务中已明确的执行ID读取对应reader;只有无法确定属于哪一笔任务时才询问用户。"
1017
982
  : "当前设备没有可恢复的 Service Execution;先读取已发布目录,不要猜测 ID。",
1018
983
  next: executions.length === 1
1019
984
  ? { command: `itpay services next ${latest.service_execution_id} --json`, reason: "继续唯一可恢复的服务" }
@@ -1025,51 +990,90 @@ export async function runServicesList(backend, options = {}) {
1025
990
  writeCommandEnvelope(envelope, {
1026
991
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1027
992
  ...(options.output ? { output: options.output } : {}),
1028
- plainResult: executions.map((execution) => `${execution.service_execution_id}: ${execution.service_id} ${execution.status}/${execution.phase} updated=${execution.updated_at}`),
1029
993
  });
1030
994
  }
995
+ // Bound the final qualified output by whole saved rows; the cursor follows the emitted rows.
996
+ function writeSavedRailPage(envelope, options, key, offset, continuation) {
997
+ const result = envelope.result ?? {};
998
+ const rows = result[key];
999
+ if (!Array.isArray(rows))
1000
+ throw new Error("saved page has no rows");
1001
+ const bound = (offset === 0 ? 12 : 32) * 1024;
1002
+ const render = (output) => writeCommandEnvelope(envelope, { ...options, output });
1003
+ if (!options.all) {
1004
+ for (;;) {
1005
+ let text = "";
1006
+ render(value => { text += value; });
1007
+ if (Buffer.byteLength(text, "utf8") <= bound)
1008
+ break;
1009
+ if (rows.length <= 1) {
1010
+ throw new CommandContractError("saved_row_too_large", "saved row exceeds the readable page limit", "这条已保存记录超过当前页面大小;事实仍保留,请读取该条详情。", rows[0]?.detail?.command ? [rows[0].detail] : []);
1011
+ }
1012
+ rows.pop();
1013
+ result.count = rows.length;
1014
+ if (key === "trains")
1015
+ result.read_count = rows.length;
1016
+ result.next_offset = offset + rows.length;
1017
+ envelope.next = continuation(offset + rows.length);
1018
+ }
1019
+ }
1020
+ writeCommandEnvelope(envelope, options);
1021
+ }
1031
1022
  export async function runServicesReadResult(backend, serviceExecutionID, options = {}) {
1032
1023
  if (!options.snapshot && !options.journey) {
1033
1024
  const model = await backend.getServiceExecution(serviceExecutionID);
1034
1025
  if (model.execution.service_id === "itpay-rail-exact") {
1035
1026
  const item = (model.current_result_items?.length ? model.current_result_items : model.result_items)?.[0];
1036
1027
  if (!item) {
1028
+ const preview = model.provider_invocations.at(-1)?.safe_result_preview;
1029
+ const template = exactSmartTemplate(preview?.resolved_station_pair, serviceExecutionID);
1030
+ if (template && preview?.catalog_total === 0) {
1031
+ writeCommandEnvelope({ status: "no_result", result: { service_execution_id: serviceExecutionID,
1032
+ scope: "station_pair", resolved_station_pair: preview.resolved_station_pair,
1033
+ total: 0, smart_template: template },
1034
+ instruction: "这一站对的成功观测为0条,不代表附近区域无车。若用户允许中转或原需求范围更广,保留原始区域/地点意图并沿 smart_template 引用本次事实;只要直达时不要自动转中转。",
1035
+ next: null, recovery: [] }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1036
+ ...(options.output ? { output: options.output } : {}) });
1037
+ return;
1038
+ }
1037
1039
  await runServicesNext(backend, serviceExecutionID, options);
1038
1040
  return;
1039
1041
  }
1040
1042
  const rows = [];
1041
- let offset = 0;
1043
+ const startOffset = options.offset ?? 0;
1044
+ const limit = options.limit ?? 20;
1045
+ if (!Number.isInteger(startOffset) || startOffset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20) {
1046
+ throw new CommandContractError("page_invalid", "offset must be non-negative and limit 1-20", "页码无效;请按返回的 next.command 读取。", []);
1047
+ }
1048
+ let offset = startOffset;
1042
1049
  let total = 0;
1043
1050
  let resolvedStationPair;
1044
- let nextOffset = 0;
1045
- while (nextOffset !== null && rows.length < 100) {
1046
- const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, item.service_capability_result_item_id, offset, 20);
1051
+ let nextOffset = startOffset;
1052
+ do {
1053
+ const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, item.service_capability_result_item_id, offset, limit);
1047
1054
  const container = (response.page.result ?? response.page);
1048
1055
  const candidates = Array.isArray(container.candidates) ? container.candidates : [];
1049
1056
  resolvedStationPair ??= container.resolved_station_pair;
1050
- rows.push(...candidates.map((value, index) => ({
1051
- ...compactExactTrain(value),
1052
- result_item_id: item.service_capability_result_item_id,
1053
- result_offset: offset + index,
1054
- detail: { command: `itpay services page ${serviceExecutionID} ${item.service_capability_result_item_id} --offset ${offset + index} --limit 1 --json`,
1055
- reason: "读取此车次的当前席别与服务端购买凭据" },
1056
- })));
1057
+ rows.push(...candidates.map((value, index) => exactTrainRow(value, serviceExecutionID, item.service_capability_result_item_id, offset + index)));
1057
1058
  const page = (container.catalog_page ?? {});
1058
1059
  total = typeof container.catalog_total === "number" ? container.catalog_total : Math.max(total, rows.length);
1059
1060
  nextOffset = typeof page.next_offset === "number" && page.next_offset > offset ? page.next_offset : null;
1060
1061
  offset = nextOffset ?? offset;
1061
- }
1062
- writeCommandEnvelope({
1062
+ } while (options.all && nextOffset !== null);
1063
+ writeSavedRailPage({
1063
1064
  status: "ready",
1064
1065
  result: { service_execution_id: serviceExecutionID, scope: "station_pair", resolved_station_pair: resolvedStationPair,
1065
- total, read_count: rows.length,
1066
+ total, offset: startOffset, count: rows.length, read_count: rows.length,
1067
+ ...(exactSmartTemplate(resolvedStationPair, serviceExecutionID, item.service_capability_result_item_id)
1068
+ ? { smart_template: exactSmartTemplate(resolvedStationPair, serviceExecutionID, item.service_capability_result_item_id) } : {}),
1066
1069
  trains: rows, ...(nextOffset !== null ? { next_offset: nextOffset } : {}) },
1067
1070
  instruction: "这是同一已保存站对的车次行。按用户时限、席别和人数筛选;零条仅代表这一站对。纯比较答复后结束;已有购票委托则执行所选行 detail.command 读取当前席别、库存和购买凭据。",
1068
- next: nextOffset !== null ? { command: `itpay services page ${serviceExecutionID} ${item.service_capability_result_item_id} --offset ${nextOffset} --limit 20 --json`, reason: "继续读取同一结果的剩余车次" } : null,
1071
+ next: nextOffset !== null ? { command: `itpay services read-result ${serviceExecutionID} --offset ${nextOffset} --limit ${limit} --json`, reason: "继续读取同一结果的剩余车次" } : null,
1069
1072
  recovery: [],
1070
1073
  }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1071
1074
  ...(options.output ? { output: options.output } : {}),
1072
- plainResult: rows.map((row) => `${row.train_code} ${row.from_station} ${row.departure} → ${row.to_station} ${row.arrival}`) });
1075
+ all: Boolean(options.all),
1076
+ }, "trains", startOffset, next => ({ command: `itpay services read-result ${serviceExecutionID} --offset ${next} --limit ${limit} --json`, reason: "读取同一保存结果的下一页" }));
1073
1077
  return;
1074
1078
  }
1075
1079
  }
@@ -1080,49 +1084,84 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1080
1084
  // rail.planning catalog read: --snapshot returns compact rows for the
1081
1085
  // committed rail.catalog.v3; journey detail remains available separately.
1082
1086
  if (options.snapshot && !options.journey) {
1083
- const committed = await backend.getRailPlanningCatalog(serviceExecutionID, options.snapshot);
1084
- const catalog = committed.catalog;
1085
- // The committed catalog may ship shared_rows.v1 positional rows — decode
1086
- // the summary through the column legend; an unknown encoding is an
1087
- // explicit upgrade error, never an empty catalog.
1088
- const decoded = catalog
1089
- ? decodeRailCatalogJourneys(catalog)
1090
- : { journeys: [], packed: false };
1091
- const journeys = decoded.journeys.map((journey) => ({ ...journey,
1092
- detail: { command: `itpay services read-result ${serviceExecutionID} --snapshot ${committed.snapshot_id} --journey ${journey.ref} --json`,
1093
- reason: "读取同一已保存快照的此行程完整购票条件" },
1094
- }));
1095
- const counts = (catalog?.counts ?? {});
1096
- const journeyCount = journeys.length || Number(counts?.combinations ?? 0);
1097
- writeCommandEnvelope({
1087
+ const offset = options.offset ?? 0;
1088
+ const limit = options.limit ?? 3;
1089
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20) {
1090
+ throw new CommandContractError("page_invalid", "offset must be non-negative and limit 1-20", "目录页码无效;请按返回的 next.command 读取。", []);
1091
+ }
1092
+ const journeys = [];
1093
+ let nextOffset = offset;
1094
+ let total = 0;
1095
+ let qualificationCounts;
1096
+ let planID;
1097
+ let queryRevision;
1098
+ while (nextOffset !== null) {
1099
+ let response;
1100
+ try {
1101
+ response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, options.snapshot, nextOffset, limit);
1102
+ }
1103
+ catch (error) {
1104
+ if (error instanceof HttpError && error.code === "result_not_pageable") {
1105
+ const committed = await backend.getRailPlanningCatalog(serviceExecutionID, options.snapshot);
1106
+ decodeRailCatalogJourneys(committed.catalog);
1107
+ }
1108
+ throw error;
1109
+ }
1110
+ if (response.snapshot_id !== options.snapshot)
1111
+ throw new Error("saved page snapshot changed");
1112
+ planID = response.plan_id;
1113
+ queryRevision = response.query_revision;
1114
+ const container = (response.page.result ?? response.page);
1115
+ const page = container.journey_page;
1116
+ const rows = Array.isArray(container.journeys) ? container.journeys : [];
1117
+ total = typeof page?.total === "number" ? page.total : rows.length;
1118
+ qualificationCounts = page?.qualification_counts ?? qualificationCounts;
1119
+ journeys.push(...rows.map((card) => compactRailJourney(card, serviceExecutionID, options.snapshot)));
1120
+ const following = page?.next_offset;
1121
+ if (typeof following === "number" && following <= nextOffset)
1122
+ throw new Error("saved page cursor did not advance");
1123
+ nextOffset = typeof following === "number" ? following : null;
1124
+ if (!options.all)
1125
+ break;
1126
+ }
1127
+ writeSavedRailPage({
1098
1128
  status: "ready",
1099
1129
  result: {
1100
1130
  service_execution_id: serviceExecutionID,
1101
- plan_id: committed.plan_id,
1102
- snapshot_id: committed.snapshot_id,
1103
- query_revision: committed.query_revision,
1104
- coverage: catalog?.coverage,
1105
- calculation_scope: catalog?.request_summary,
1106
- total: journeyCount,
1131
+ plan_id: planID,
1132
+ snapshot_id: options.snapshot,
1133
+ query_revision: queryRevision,
1134
+ total,
1135
+ qualification_counts: qualificationCounts,
1136
+ offset,
1137
+ count: journeys.length,
1138
+ next_offset: nextOffset,
1107
1139
  journeys,
1108
1140
  },
1109
- instruction: "这是同一已保存快照的紧凑行程目录。按用户问题筛选全部 journeys 并一次回答;未计入接驳或未知费用不能当免费。需要购票详情时执行所选行 detail.command;价格和余票仍须购买前实时报价。",
1110
- next: null,
1141
+ instruction: qualificationCounts === undefined
1142
+ ? "此历史快照没有完整资格计数;线路仅供核对铁路事实,不能据此认定可购买。沿同快照 detail.command 查看详情。"
1143
+ : qualificationCounts.eligible === 0
1144
+ ? `查到 ${total} 个时刻组合,当前 0 条满足购票资格。请读取诊断线路的 detail.command 查看具体原因;修改条件须依据用户意愿。`
1145
+ : "这是同一已保存快照的完整线路分页;合格线路已排在前面。若问题需要全部方案,沿 next.command 读完再一次回答;未知费用不能当免费。购票条件用合格线路的 detail.command,价格与余票在购买前实时报价。",
1146
+ next: nextOffset === null ? null : { command: `itpay services read-result ${serviceExecutionID} --snapshot ${options.snapshot} --offset ${nextOffset} --limit ${limit} --json`, reason: "仅当前问题还需未读线路时继续;已有合格完整结果可答复或按委托继续" },
1111
1147
  recovery: [],
1112
1148
  }, {
1113
1149
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1114
1150
  ...(options.output ? { output: options.output } : {}),
1115
- plainResult: journeys.length > 0
1116
- ? [
1117
- `${journeyCount} combinations:`,
1118
- ...journeys.map((j) => `${j.defaultLayer === "backup" ? "[备选] " : "[主选择] "}${j.ref} ${j.route} ${j.rides?.map((r) => `${r.train_code} ${r.departure}–${r.arrival}`).join(" / ") ?? ""}`),
1119
- ]
1120
- : [`catalog: ${committed.snapshot_id} (no combinations)`],
1121
- });
1151
+ all: Boolean(options.all),
1152
+ }, "journeys", offset, next => ({ command: `itpay services read-result ${serviceExecutionID} --snapshot ${options.snapshot} --offset ${next} --limit ${limit} --json`, reason: "仅当前问题还需未读线路时继续;已有合格完整结果可答复或按委托继续" }));
1122
1153
  return;
1123
1154
  }
1124
1155
  if (options.journey) {
1125
1156
  const detail = await backend.getRailJourneyDetail(serviceExecutionID, options.journey, options.snapshot);
1157
+ const planOffset = options.offset ?? 0;
1158
+ const planLimit = options.limit ?? 3;
1159
+ if (!Number.isInteger(planOffset) || planOffset < 0 || !Number.isInteger(planLimit) || planLimit < 1 || planLimit > 20) {
1160
+ throw new CommandContractError("page_invalid", "offset must be non-negative and limit 1-20", "席别方案页码无效;请按返回的 next.command 读取。", []);
1161
+ }
1162
+ const plans = detail.journey.ticket_plans ?? [];
1163
+ const selected = options.all ? plans : plans.slice(planOffset, planOffset + planLimit);
1164
+ const nextOffset = !options.all && planOffset + selected.length < plans.length ? planOffset + selected.length : null;
1126
1165
  writeCommandEnvelope({
1127
1166
  status: "ready",
1128
1167
  result: {
@@ -1130,10 +1169,13 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1130
1169
  plan_id: detail.plan_id,
1131
1170
  snapshot_id: detail.snapshot_id,
1132
1171
  query_revision: detail.query_revision,
1133
- journey: detail.journey,
1172
+ journey: compactRailJourney(detail.journey, serviceExecutionID, detail.snapshot_id),
1173
+ ticket_plan_total: plans.length,
1174
+ ticket_plan_offset: planOffset,
1175
+ ticket_plans: selected.map(plan => compactRailTicketPlan(plan, detail.journey.qualification_status === "eligible")),
1134
1176
  },
1135
- instruction: "展示该 journey 的完整明细(车次、分段、席别报价、接驳估计与风险标注)。rail_payable 只是该行程当前可购报价的参考价,不是锁价;下单前须走受保护 Checkout 收集乘车人。用户已明确委托按规则代选时用 select_delegated;用户亲自选定该行程时用 select_user_chosen,不能把 Agent 选择记作人类动作。",
1136
- next: null,
1177
+ instruction: "同一线路的完整车次与席别方案。已有所需席别和有效凭据即可按真实委托继续;只需比较则答复并停止,需要其他席别才读下一页。中转各段独立购买,无联合锁票或接续保证;购买前受保护 Checkout 实时报价。",
1178
+ next: nextOffset === null ? null : { command: `itpay services read-result ${serviceExecutionID} --snapshot ${detail.snapshot_id} --journey ${options.journey} --offset ${nextOffset} --limit ${planLimit} --json`, reason: "仅需要其他席别时读取;已满足用户条件则停止翻页" },
1137
1179
  ...(detail.journey?.booking_support === "single_leg" ? { interaction: {
1138
1180
  schema_version: "itpay.interaction.v1", stage: "query_results_ready",
1139
1181
  select_user_chosen: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${detail.journey.journey_id} --json`,
@@ -1143,7 +1185,6 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1143
1185
  }, {
1144
1186
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1145
1187
  ...(options.output ? { output: options.output } : {}),
1146
- plainResult: servicesNextPlainResult(detail.journey),
1147
1188
  });
1148
1189
  return;
1149
1190
  }
@@ -1163,6 +1204,24 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1163
1204
  plainResult: servicesNextPlainResult(envelope.result),
1164
1205
  });
1165
1206
  }
1207
+ function exactSmartTemplate(pair, executionID, resultItemID) {
1208
+ if (!pair || typeof pair !== "object")
1209
+ return null;
1210
+ const value = pair;
1211
+ if (!["origin_name", "origin_code", "destination_name", "destination_code", "travel_date"].every((key) => typeof value[key] === "string" && value[key]))
1212
+ return null;
1213
+ return { executable: false, verified_station_pair: value,
1214
+ instruction: "已核验站对是复用事实,不是完整用户查询。仅用户明确锁站才填 kind=station;区域/地点从原始需求填回,保留真实人数、时限和偏好,不默认为一人。",
1215
+ command: "itpay services run itpay-rail-smart --input-json <原始需求.json> --json",
1216
+ required_input: ["endpoints", "passengers", "用户时限与偏好"],
1217
+ input_example: { endpoints: { origin: { text: "<原始出发行政范围/地点/用户指定站>" }, destination: { text: "<原始到达行政范围/地点/用户指定站>" } },
1218
+ travel_date: value.travel_date, passengers: "<真实人数>", reuse_from: { execution_id: executionID,
1219
+ ...(resultItemID ? { result_item_id: resultItemID } : {}) } } };
1220
+ }
1221
+ function exactTrainRow(candidate, executionID, itemID, offset) {
1222
+ return { ...compactExactTrain(candidate), result_item_id: itemID, result_offset: offset,
1223
+ detail: { command: `itpay services page ${executionID} ${itemID} --offset ${offset} --limit 1 --json`, reason: "读取此车次席别与服务端购买模板" } };
1224
+ }
1166
1225
  function compactExactTrain(candidate) {
1167
1226
  const seats = Array.isArray(candidate.seats) ? candidate.seats : [];
1168
1227
  return {
@@ -1215,15 +1274,14 @@ function railBookingEnvelope(model) {
1215
1274
  service_execution_id: execution.service_execution_id,
1216
1275
  ...(orderID ? { order_id: orderID } : {}),
1217
1276
  rail: { state: rail.state, issued_legs: rail.issued_legs, legs },
1218
- ...(model.workflow ? { workflow: model.workflow } : {}),
1219
1277
  };
1220
1278
  if (rail.state === "issued") {
1221
1279
  const detailsPending = rail.legs.some((leg) => leg.details_pending);
1222
1280
  return {
1223
1281
  status: "issued",
1224
- result,
1282
+ result: { ...result, ...(orderID ? { aftercare: { command: `itpay order ${orderID} --json`, when: "仅用户请求核对订单或售后时" } } : {}) },
1225
1283
  instruction: `告诉用户:车票已出票,座位以实际出票为准,平台不提供 12306 票号,可在订单页核对行程。不要在对话中索要乘车人身份信息。${detailsPending ? "部分席位信息未能同步,请前往 12306 核对行程;不要重复购买。" : ""}`,
1226
- next: orderID ? { command: `itpay order ${orderID} --json`, reason: "查看订单及退款入口" } : null,
1284
+ next: null,
1227
1285
  recovery: [],
1228
1286
  };
1229
1287
  }
@@ -1257,7 +1315,105 @@ function railBookingEnvelope(model) {
1257
1315
  recovery: [],
1258
1316
  };
1259
1317
  }
1260
- function railJourneySummary(card, serviceExecutionID) {
1318
+ function compactRailJourney(card, serviceExecutionID, snapshotID) {
1319
+ if (!card.journey_id)
1320
+ throw new Error("saved journey has no stable ID");
1321
+ const rides = (card.rides ?? []).map((ride) => ({
1322
+ train_code: ride.train_code ?? (Array.isArray(ride.train_codes) ? ride.train_codes.join("/") : null),
1323
+ from_station: ride.from_station ?? null, from_station_code: ride.from_station_code ?? null,
1324
+ departure: ride.departure ?? null, to_station: ride.to_station ?? null,
1325
+ to_station_code: ride.to_station_code ?? null, arrival: ride.arrival ?? null,
1326
+ ...(typeof ride.wait_minutes === "number" ? { wait_minutes: ride.wait_minutes } : {}),
1327
+ ...(typeof ride.minimum_connection_minutes === "number" ? { required_buffer_minutes: ride.minimum_connection_minutes } : {}),
1328
+ ...(ride.same_run === true ? { same_run: true } : {}),
1329
+ }));
1330
+ const offer = card.representative_offer;
1331
+ const metrics = card.representative_metrics;
1332
+ const ticketSeats = (offer?.ticket_offers ?? []).map((raw) => ({
1333
+ leg_index: raw.leg_index, seat_type: raw.seat_type, seat_name: raw.seat_name,
1334
+ availability: raw.availability_text, remaining: raw.inventory_exact === true ? raw.remaining : null,
1335
+ }));
1336
+ const railwayTime = (value) => {
1337
+ if (typeof value !== "string")
1338
+ return NaN;
1339
+ const time = value.replace(" ", "T");
1340
+ return Date.parse(/(?:Z|[+-]\d{2}:\d{2})$/.test(time) ? time : `${time}Z`);
1341
+ };
1342
+ const start = railwayTime(rides[0]?.departure);
1343
+ const end = railwayTime(rides.at(-1)?.arrival);
1344
+ return {
1345
+ journey_ref: card.journey_id,
1346
+ ...(card.profile_refs?.balanced ? { profile_ref: card.profile_refs.balanced } : {}),
1347
+ default_layer: card.default_layer ?? "main",
1348
+ ...(card.backup_reason ? { backup_reason: card.backup_reason } : {}),
1349
+ ...(card.backup_baseline_min != null ? { backup_baseline_min: card.backup_baseline_min } : {}),
1350
+ ...(card.backup_delta_min != null ? { backup_delta_min: card.backup_delta_min } : {}),
1351
+ route: card.route_text ?? (card.route_names ?? card.route ?? []).join("→"),
1352
+ rides,
1353
+ ...(card.booking_support === "single_leg" && card.qualification_status === "eligible" && card.booking_offer ? {
1354
+ booking_template: { command: `itpay services run ${card.booking_offer.service_id} --input-json <购票.json> --json`, executable: false,
1355
+ seat_choices: ticketSeats.map(seat => ({ seat_type: seat.seat_type, seat_name: seat.seat_name })),
1356
+ input_example: { selection: { token: card.booking_offer.selection_token, seat_type: "<返回席别代码>" }, passengers: card.passengers ?? offer?.ticket_offers?.[0]?.passengers ?? "<真实人数>" },
1357
+ required_input: ["file", "真实席别选择及人数"] },
1358
+ } : {}),
1359
+ ...(ticketSeats.length ? { seats: ticketSeats } : {}),
1360
+ passengers: card.passengers ?? offer?.ticket_offers?.[0]?.passengers ?? null,
1361
+ availability: card.availability ?? "unknown",
1362
+ qualification_status: card.qualification_status ?? "unknown",
1363
+ ...(card.qualification_reasons?.length ? { qualification_reasons: card.qualification_reasons } : {}),
1364
+ ...(offer ? { rail_payable_fen: offer.rail_payable_minor, currency: offer.currency } : { rail_payable_fen: null }),
1365
+ estimated_trip_total: metrics?.estimated_total_cost ?? null,
1366
+ cost_basis: metrics?.cost_basis ?? "unknown",
1367
+ duration_basis: metrics?.duration_basis ?? "unknown",
1368
+ timezone: "Asia/Shanghai",
1369
+ ...(card.risk_notes?.length ? { risk_notes: card.risk_notes } : {}),
1370
+ rail_duration_minutes: Number.isFinite(start) && Number.isFinite(end) && end >= start ? Math.round((end - start) / 60000) : null,
1371
+ ...(card.observed_at ? { observed_at: card.observed_at } : {}),
1372
+ detail: { command: `itpay services read-result ${serviceExecutionID} --snapshot ${snapshotID} --journey ${card.journey_id} --json`,
1373
+ reason: card.qualification_status === "eligible" ? "读取此完整线路的席别、报价和购买条件" : "读取此线路的铁路事实和不可购买原因" },
1374
+ };
1375
+ }
1376
+ function compactRailTicketPlan(raw, journeyEligible) {
1377
+ const legs = Array.isArray(raw.legs) ? raw.legs : [];
1378
+ const seats = Array.isArray(raw.seat_offers) ? raw.seat_offers : [];
1379
+ const tickets = Array.isArray(raw.ticket_offers) ? raw.ticket_offers : [];
1380
+ return {
1381
+ ticket_plan_ref: raw.ticket_plan_ref,
1382
+ passengers: raw.passenger_count ?? (Array.isArray(raw.ticket_offers) ? raw.ticket_offers[0]?.passengers : null) ?? null,
1383
+ purchase_support: raw.purchase_support ?? "none",
1384
+ qualification_status: raw.qualification_status ?? "unknown",
1385
+ qualification_reasons: raw.qualification_reasons ?? [],
1386
+ rail_amount_fen: raw.rail_amount_fen ?? null,
1387
+ service_fee_fen: raw.service_fee_fen ?? null,
1388
+ price_verified: raw.rail_amount_verified === true,
1389
+ legs: legs.map((leg, index) => ({
1390
+ leg_index: index,
1391
+ travel_date: leg.travel_date ?? tickets.find(ticket => ticket.leg_index === index)?.travel_date ?? null,
1392
+ ...(() => {
1393
+ const ticket = tickets.find(ticket => ticket.leg_index === index) ?? (tickets[index]?.leg_index === undefined ? tickets[index] : undefined);
1394
+ const offer = ticket?.booking_offer;
1395
+ return offer?.service_id && offer.selection_token && journeyEligible && raw.qualification_status === "eligible" ? {
1396
+ booking_template: { command: `itpay services run ${offer.service_id} --input-json <购票.json> --json`, executable: false,
1397
+ ticket_plan_ref: raw.ticket_plan_ref, leg_index: index,
1398
+ seat_choices: [{ seat_type: ticket?.seat_type, seat_name: ticket?.seat_name }],
1399
+ input_example: { selection: { token: offer.selection_token, seat_type: ticket?.seat_type ?? "<该方案席别代码>" },
1400
+ passengers: ticket?.passengers ?? raw.passenger_count ?? "<真实人数>" },
1401
+ required_input: ["file", "真实人数", "该方案席别代码"],
1402
+ },
1403
+ } : { booking_unavailable: "本段没有当前可用的服务端凭据;不能猜代码或例行重查。保留快照,按当前执行的恢复动作处理;过期凭据需真实新验证。" };
1404
+ })(),
1405
+ train_code: leg.train_code ?? null,
1406
+ from_station: leg.from_station ?? null, from_station_code: leg.from_station_code ?? null,
1407
+ departure: leg.departure ?? null,
1408
+ to_station: leg.to_station ?? null, to_station_code: leg.to_station_code ?? null,
1409
+ arrival: leg.arrival ?? null,
1410
+ seats: (seats[index] ?? []).map((seat) => ({ seat_type: seat.seat_type, seat_name: seat.seat_name,
1411
+ availability: seat.availability_text, remaining: seat.inventory_exact === true ? seat.remaining : null,
1412
+ unit_fare_fen: seat.fare_minor, eligible: seat.eligible === true })),
1413
+ })),
1414
+ };
1415
+ }
1416
+ function railJourneySummary(card, serviceExecutionID, snapshotID) {
1261
1417
  const rides = Array.isArray(card.rides) ? card.rides : [];
1262
1418
  const trains = rides.map((ride) => ride.train_code).filter(Boolean);
1263
1419
  const first = rides[0];
@@ -1266,6 +1422,7 @@ function railJourneySummary(card, serviceExecutionID) {
1266
1422
  const metrics = card.representative_metrics;
1267
1423
  return {
1268
1424
  journey_id: card.journey_id,
1425
+ ...(snapshotID ? (() => { const detail = compactRailJourney(card, serviceExecutionID, snapshotID); return { rides: detail.rides, seats: detail.seats, default_layer: detail.default_layer, detail: detail.detail, ...(detail.booking_template ? { booking_template: detail.booking_template } : {}) }; })() : {}),
1269
1426
  route: (card.route_names?.length ? card.route_names : card.route ?? []).join("→"),
1270
1427
  ...(trains.length ? { trains } : {}),
1271
1428
  ...(first?.departure || last?.arrival ? { time: `${first?.departure ?? ""}–${last?.arrival ?? ""}` } : {}),
@@ -1294,29 +1451,6 @@ function railJourneySummary(card, serviceExecutionID) {
1294
1451
  select_delegated: `itpay services action ${serviceExecutionID} --action select_journey --actor-type agent --status approved --input journey_id=${card.journey_id} --input selection_mode=delegated --json`,
1295
1452
  }
1296
1453
  : {}),
1297
- ...(card.booking_offer
1298
- ? {
1299
- booking_offer: card.booking_offer,
1300
- booking_template: {
1301
- command: `itpay services run ${card.booking_offer.service_id} --input-json <file> --json`,
1302
- required_input: ["file"],
1303
- executable: false,
1304
- seat_choices: (card.representative_offer?.ticket_offers ?? []).map((offer) => ({
1305
- seat_type: offer.seat_type,
1306
- seat_name: offer.seat_name,
1307
- })).filter((seat) => typeof seat.seat_type === "string"),
1308
- input_example: {
1309
- selection: {
1310
- token: card.booking_offer.selection_token,
1311
- ...(typeof card.representative_offer?.ticket_offers?.[0]?.seat_type === "string"
1312
- ? { seat_type: card.representative_offer.ticket_offers[0].seat_type }
1313
- : {}),
1314
- },
1315
- passengers: card.passengers ?? 1,
1316
- },
1317
- },
1318
- }
1319
- : {}),
1320
1454
  };
1321
1455
  }
1322
1456
  // rail.progressive.v2 read projection: committed snapshots only; polling never
@@ -1328,11 +1462,19 @@ function railPlanningEnvelope(model) {
1328
1462
  const expansion = plan.search?.expansion_status ?? "running";
1329
1463
  const counts = plan.search?.counts;
1330
1464
  const journeys = [plan.recommendation, ...(plan.alternatives ?? [])].filter(Boolean);
1331
- const cards = journeys.map((card) => railJourneySummary(card, se));
1465
+ const hasEligible = typeof counts?.journeys_eligible === "number"
1466
+ ? counts.journeys_eligible > 0 : journeys.length > 0;
1467
+ const changedScopeHint = plan.query_input && !/supplier|provider|dispatch|map|amap|permission/.test(plan.search?.reason ?? "")
1468
+ ? "若原条件锁定车站,可提议仅放宽一端到原站周边;有已核实地点证据才建议具体站,不能说肯定有票。先询问用户是否接受该端范围变化;同意后按new_query_template开新查询,未同意则保持原站。"
1469
+ : /supplier|provider|dispatch|map|amap|permission/.test(plan.search?.reason ?? "")
1470
+ ? "依赖故障按原恢复动作处理,不通过换站掩盖故障。"
1471
+ : "如需改变端点,先取得真实范围变更同意,再按正式铁路指南保留原条件发起新查询。";
1472
+ const cards = journeys.filter(card => card.journey_id !== plan.recommendation?.journey_id)
1473
+ .map((card) => railJourneySummary(card, se, plan.catalog_snapshot_id ?? plan.snapshot_id));
1332
1474
  // Journey counts come from the committed catalog — real combination counts,
1333
1475
  // never seat-row counts. Transfer buckets follow the verified transfer_count.
1334
1476
  const journeyMix = counts && (counts.journeys_total ?? 0) > 0
1335
- ? `${counts.journeys_total}个铁路组合(直达${counts.journeys_direct ?? 0}/1中转${counts.journeys_one_transfer ?? 0}/2中转${counts.journeys_multi_transfer ?? 0})`
1477
+ ? `已保存${counts.journeys_total}个具体车次时刻组合,其中${counts.journeys_eligible ?? "未知"}条当前满足铁路资格(直达${counts.journeys_direct ?? 0}/1中转${counts.journeys_one_transfer ?? 0}/2中转${counts.journeys_multi_transfer ?? 0};这些不是不同站序的数量)`
1336
1478
  : "";
1337
1479
  const pairProgress = counts && (counts.pairs_total ?? 0) > 0
1338
1480
  ? `已检查${counts.pairs_checked ?? 0}/${counts.pairs_total}个附近站对`
@@ -1352,6 +1494,7 @@ function railPlanningEnvelope(model) {
1352
1494
  rail_planning: {
1353
1495
  readiness: plan.readiness,
1354
1496
  query_revision: plan.query_revision,
1497
+ ...(plan.query_input ? { endpoints: plan.query_input.endpoints } : {}),
1355
1498
  snapshot_id: plan.snapshot_id,
1356
1499
  expansion_status: expansion,
1357
1500
  ...(plan.search?.phase ? { phase: plan.search.phase } : {}),
@@ -1365,67 +1508,24 @@ function railPlanningEnvelope(model) {
1365
1508
  ...(plan.search?.poll_after_ms ? { poll_after_ms: plan.search.poll_after_ms } : {}),
1366
1509
  ...(plan.result_not_updated ? { result_not_updated: true } : {}),
1367
1510
  ...(counts ? { counts } : {}),
1368
- ...(plan.budgets ? { budgets: plan.budgets } : {}),
1369
1511
  ...(plan.coverage ? { coverage: plan.coverage } : {}),
1370
1512
  },
1371
1513
  ...(plan.notices?.length ? { notices: plan.notices } : {}),
1372
- ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se) } : {}),
1514
+ ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se, plan.catalog_snapshot_id ?? plan.snapshot_id) } : {}),
1373
1515
  ...(cards.length ? { journeys: cards } : {}),
1374
1516
  ...(plan.snapshot_id ? { full_result: { command: `itpay services read-result ${se} --snapshot ${plan.snapshot_id} --json`, meaning: "读取已保存的完整目录;推荐和journeys仅是摘要" } } : {}),
1517
+ ...(!hasEligible && ["paused", "complete", "expired"].includes(expansion) && plan.query_input && plan.catalog_snapshot_id ? { new_query_template: {
1518
+ command: "itpay services run itpay-rail-smart --input-json <file> --json",
1519
+ executable: false,
1520
+ input_example: { ...plan.query_input, reuse_from: { execution_id: se, snapshot_id: plan.catalog_snapshot_id } },
1521
+ meaning: "先取得真实范围变更同意,再复制完整input_example,仅改获准端。换具体站用station及匹配名称/站码;考虑原站周边用place及原站名,删除station_code,经正常地点解析或使用已核实POI/坐标;只有明确同意整个市才用area及完整行政名,删除POI/坐标/站码。日期、人数、时限及偏好保持;新执行不改变旧查询或订单。引用仅限同owner未过期事实,跨owner拒绝;日期变更不复用旧库存。",
1522
+ } } : {}),
1375
1523
  ...(plan.available_actions?.length ? { available_actions: plan.available_actions } : {}),
1376
1524
  };
1377
1525
  const nextPoll = {
1378
1526
  command: `itpay services next ${se} --timeout 120${plan.snapshot_id ? ` --since-snapshot ${plan.snapshot_id}` : ""} --json`,
1379
1527
  reason: "仍需更多结果时有界等待同一规划(不触发供应商调用)",
1380
1528
  };
1381
- // Two-goal interaction contract (spec 03 §2): the local Agent picks compare
1382
- // or prepare_checkout from full context — the envelope teaches both branches
1383
- // so a delegated purchase keeps moving and a comparison never buys.
1384
- const bookable = journeys.find((card) => card.booking_support === "single_leg" && card.booking_offer);
1385
- const queryResultsInteraction = journeys.length > 0
1386
- ? {
1387
- schema_version: "itpay.interaction.v1",
1388
- stage: "query_results_ready",
1389
- by_goal: {
1390
- compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
1391
- prepare_checkout: {
1392
- action: "select_under_user_rules_then_prepare",
1393
- recipe: "rail.selected-to-checkout.v1",
1394
- requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
1395
- ...(bookable
1396
- ? {
1397
- steps: [
1398
- "按用户明确规则在 journeys 中选择合格者(卡片 select_delegated 提交 agent 委托选择;人类亲自选择用 select_user_chosen)",
1399
- `用所选卡片的 booking_template 写 owner-only 临时输入(selection token + 席别 + 人数,不含身份信息),运行 itpay services run itpay-rail-booking --input-json <file> --json`,
1400
- "owner 要求聊天 review 时一次合并补齐必要字段,真实确认后提交原 workflow:confirm_booking;不得伪造 accept",
1401
- `确认后恢复同一购买执行:itpay services run itpay-rail-booking --execution <实际ID> --json,沿用有界等待与受保护 Checkout`,
1402
- ],
1403
- }
1404
- : { unavailable: "当前结果无可整段购买映射;分别说明各段风险,不得合成原子联程。" }),
1405
- },
1406
- },
1407
- }
1408
- : undefined;
1409
- const queryResultsCommunication = journeys.length > 0
1410
- ? {
1411
- schema_version: "itpay.communication.v1",
1412
- status_line: expansion === "paused"
1413
- ? "首批结果已就绪,扩展搜索已暂停等待用户决定"
1414
- : "查询结果就绪",
1415
- recommended_reason: plan.recommendation?.explanation?.[0],
1416
- human_steps: [
1417
- "compare:比较首选与最多两个备选后告知取舍",
1418
- "prepare_checkout:委托仍在时确认所选,继续官方确认页",
1419
- "姓名、证件、手机号只在受保护 Checkout 页填写",
1420
- ],
1421
- next_expectation: "compare:用户选定其一;prepare_checkout:委托仍在时继续到官方确认入口,人完成review与付款",
1422
- must_convey: [
1423
- "推荐与备选的时间/费用/便利性取舍",
1424
- "金额为查询时参考价,下单前须核验",
1425
- "姓名、证件、手机号只在受保护网页填写",
1426
- ],
1427
- }
1428
- : undefined;
1429
1529
  switch (expansion) {
1430
1530
  case "complete":
1431
1531
  if (plan.search?.reason === "ground_evidence_required") {
@@ -1435,18 +1535,30 @@ function railPlanningEnvelope(model) {
1435
1535
  next: null, recovery: [],
1436
1536
  };
1437
1537
  }
1538
+ if (!hasEligible && !plan.result_not_updated) {
1539
+ return {
1540
+ status: "no_result", result,
1541
+ instruction: `本次已查询范围内没有当前合格线路。原因以 notices、coverage 和 search.reason 为准;缺票、接续失败、依赖故障或未覆盖范围不能改说成没有车。已有铁路事实可从 full_result 读取。${changedScopeHint}后续使用 available_actions 中与用户意图一致的动作。`,
1542
+ next: null, recovery: [],
1543
+ };
1544
+ }
1438
1545
  return {
1439
1546
  status: "ready",
1440
1547
  result,
1441
1548
  // §7.3: the lead line carries real combination counts bucketed by
1442
1549
  // verified transfer count — seat rows never inflate the journey count.
1443
- instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}推荐和journeys是摘要;追问其他车次先读取 full_result 的已保存完整目录。compare:解释首选及最多两个有意义备选的时间/费用/便利性取舍后等待用户选择。prepare_checkout:用户已明确委托按规则选择时,按 interaction.recipe 选合格者并继续到官方确认页,不再次问是否下单;身份、review和最终付款边界仍然生效。乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1550
+ instruction: `${plan.result_not_updated ? "快照没有更新,空增量不表示目录为空。" : ""}${hasEligible && journeys.length === 0 ? `已保存${counts?.journeys_eligible ?? "若干"}条合格线路,执行 full_result.command 读取。` : ""}${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}推荐和journeys是摘要;追问其他车次先读取 full_result 的已保存完整目录。比较需求说明首选与备选取舍后结束;已有明确代选委托则沿合格线路详情继续,只补缺失的真实同意,身份信息仍在官方页面填写。`,
1444
1551
  next: null,
1445
- ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1446
- ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1447
1552
  recovery: [],
1448
1553
  };
1449
1554
  case "paused": {
1555
+ if (plan.search?.reason === "legacy_plan_requires_new_search") {
1556
+ return {
1557
+ status: "awaiting_input", result,
1558
+ instruction: "这次规划使用旧算法快照,当前版本已暂停后续查询。已保存的结果仍可读取;若需继续覆盖未查范围,请按原出行条件发起一项新的 Smart 查询。不要把旧快照解释为新版完整结果。",
1559
+ next: null, recovery: [],
1560
+ };
1561
+ }
1450
1562
  // §7.1 case 1: usable directs committed while ranked scope remains. The
1451
1563
  // message must carry real counts, and expansion is a user choice — the
1452
1564
  // Agent must not treat "expandable" as consent already given.
@@ -1469,7 +1581,7 @@ function railPlanningEnvelope(model) {
1469
1581
  // §7.1 case 3 on a recoverable pause: unverified scope is named with real
1470
1582
  // counts, and the phrasing never implies the whole nearby range ran.
1471
1583
  const partialPause = !directHint && counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1472
- ? `目前找到${counts.journeys_total ?? 0}个可选组合;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1584
+ ? `已保存${counts.journeys_total ?? 0}个具体车次时刻组合,其中${counts.journeys_eligible ?? "未知"}条当前满足铁路资格;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1473
1585
  : "";
1474
1586
  // §S5: a dispatch whose outcome is unknown pauses for reconciliation —
1475
1587
  // the message names the state, never rephrases it as a normal pause or
@@ -1487,21 +1599,29 @@ function railPlanningEnvelope(model) {
1487
1599
  return {
1488
1600
  status: "awaiting_input",
1489
1601
  result,
1490
- instruction: `${modelDegradedHint}${failedPairsHint}${directHint}${transferHint}${partialPause}规划已发布首批结果并暂停扩展:展示现有卡片与 available_actions,等待用户意图(${expandHint})。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。compare:解释首选及最多两个备选后等待;prepare_checkout:委托仍在且选项合格时按 interaction.recipe 继续到官方确认入口,不再次问是否下单。`,
1602
+ instruction: `${modelDegradedHint}${failedPairsHint}${directHint}${transferHint}${partialPause}${hasEligible ? "规划已发布可用结果并暂停扩展:展示现有卡片与 available_actions" : `当前已查范围尚未找到合格线路,搜索已暂停;未查范围与故障以coverage/notices为准,不是完整无解。${changedScopeHint}展示 available_actions`},等待用户意图(${expandHint})。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。比较需求说明首选与备选取舍后结束;已有代选委托且选项合格时沿详情继续到必要确认与官方入口。`,
1491
1603
  next: null,
1492
- ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1493
- ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1494
1604
  recovery: [],
1495
1605
  };
1496
1606
  }
1497
1607
  case "failed":
1498
1608
  case "cancelled":
1499
1609
  case "expired": {
1610
+ if (expansion === "failed" && !plan.snapshot_id &&
1611
+ ["address_required", "administrative_area_unresolved", "station_location_evidence_required", "coordinate_identity_unverified", "coordinate_name_mismatch", "endpoint_adcode_name_conflict", "endpoint_station_name_code_conflict", "endpoint_poi_name_conflict"].includes(plan.search?.reason ?? "")) {
1612
+ return {
1613
+ status: "awaiting_input", result,
1614
+ instruction: plan.search?.reason === "station_location_evidence_required"
1615
+ ? "尚未查票。车站身份明确,但 Smart 缺少必要地理证据;这是系统证据缺口,不要求用户换站。若双锁站仅需直达,可使用 Exact。"
1616
+ : "尚未查票。受影响端点的名称、归属或坐标尚不能一致确认;只补正该端点的真实信息,保留其余条件,不把此次失败说成无车。",
1617
+ next: null, recovery: [],
1618
+ };
1619
+ }
1500
1620
  // §7.1 case 3: when scope was only partially verified the honest wording
1501
1621
  // is "N confirmed + X pairs unverified" — never the case-2 phrasing that
1502
1622
  // implies the whole nearby range was checked.
1503
1623
  const partial = counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1504
- ? `目前找到${counts.journeys_total ?? 0}个可选组合;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1624
+ ? `已保存${counts.journeys_total ?? 0}个具体车次时刻组合,其中${counts.journeys_eligible ?? "未知"}条当前满足铁路资格;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1505
1625
  : "";
1506
1626
  // §S5: a processing failure with a committed catalog is "saved N
1507
1627
  // verified combinations, later expansion/recommend incomplete" — the
@@ -1539,17 +1659,6 @@ function railPlanningEnvelope(model) {
1539
1659
  result,
1540
1660
  instruction: `${modelDegradedHint}${manualExpansion}${transferHint}${failedPairsHint}规划进行中:已提交的卡片可先向用户展示比较;稍后按 next 增量轮询同一执行(since-snapshot 命中时负载会被压缩,但阶段与计数仍返回最新值)。不要重新发起查询。`,
1541
1661
  next: nextPoll,
1542
- communication: {
1543
- schema_version: "itpay.communication.v1",
1544
- status_line: transferSearching && !manualExpansion
1545
- ? "直达范围暂未找到可用方案,中转组合仍在搜索"
1546
- : "规划进行中,已确认的方案可先向用户展示",
1547
- next_expectation: "按 next 增量轮询同一执行;不重新发起查询",
1548
- must_convey: [
1549
- "当前是进行中的部分结果,不代表最终答复",
1550
- ...(failedPairsHint ? ["部分站对尚未查明,不能说'没有车'"] : []),
1551
- ],
1552
- },
1553
1662
  recovery: [],
1554
1663
  };
1555
1664
  }
@@ -1599,26 +1708,9 @@ function servicesNextEnvelope(model) {
1599
1708
  const currentDelivery = model.current_delivery ?? model.delivery_bindings.at(-1);
1600
1709
  const lockedRefund = model.refunds.find((refund) => refund.access_locked);
1601
1710
  if (lockedRefund) {
1602
- const terminal = lockedRefund.status === "succeeded";
1603
- return {
1604
- status: "delivery_locked",
1605
- result: {
1606
- service_execution_id: execution.service_execution_id,
1607
- access_locked: true,
1608
- refund: {
1609
- refund_request_id: lockedRefund.refund_request_id,
1610
- status: lockedRefund.status,
1611
- },
1612
- },
1613
- instruction: terminal
1614
- ? "告诉用户退款已由 ItPay 确认成功,原交付永久关闭。Agent 停止读取和跟踪,不再创建授权。"
1615
- : "告诉用户退款仍在处理,原交付已按政策冻结。然后读取同一退款的权威状态;Agent 不读取交付、不创建授权或重复申请。",
1616
- next: terminal ? null : {
1617
- command: `itpay refund get ${lockedRefund.refund_request_id} --json`,
1618
- reason: "读取退款权威状态",
1619
- },
1620
- recovery: [],
1621
- };
1711
+ const refundState = refundStateEnvelope(lockedRefund, "delivery_locked");
1712
+ return { ...refundState, result: { ...refundState.result, service_execution_id: execution.service_execution_id, refund: { refund_request_id: lockedRefund.refund_request_id, status: lockedRefund.status, ...(lockedRefund.failure_class ? { failure_class: lockedRefund.failure_class } : {}) } },
1713
+ next: refundState.next ? { command: `itpay refund get ${lockedRefund.refund_request_id} --json`, reason: "读取同一退款权威状态" } : null };
1622
1714
  }
1623
1715
  const latestRun = model.execution_requests?.filter((request) => request.execution_kind === "service.execution.run").at(-1);
1624
1716
  if (latestRun && ["pending", "started"].includes(latestRun.status)) {
@@ -1653,10 +1745,48 @@ function servicesNextEnvelope(model) {
1653
1745
  }
1654
1746
  if (model.workflow_entry && !["completed", "delivery"].includes(model.workflow?.status ?? "")) {
1655
1747
  const id = execution.service_execution_id;
1748
+ const workflowReason = model.workflow?.failure?.reason_code ?? model.workflow?.error_code;
1656
1749
  const paymentVerified = model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
1657
1750
  const state = model.workflow?.status === "payment" && paymentVerified ? "running" : model.workflow?.status ?? "input_required";
1658
- if (state === "failed" && ["login_required", "rate_limited"].includes(model.workflow?.error_code ?? "")) {
1659
- const login = model.workflow?.error_code === "login_required";
1751
+ if (["quota_paused", "failed"].includes(state) && workflowReason !== "area_scope_too_broad" && (model.workflow?.dependency ||
1752
+ /^amap_/.test(workflowReason ?? ""))) {
1753
+ const dependency = model.workflow?.dependency;
1754
+ const resumable = state === "quota_paused" && dependency?.retryable === true;
1755
+ return {
1756
+ status: "dependency_unavailable",
1757
+ result: { service_execution_id: id, service_id: execution.service_id, reason: workflowReason, failure: model.workflow?.failure, affected_endpoint: model.workflow?.failure?.endpoint ?? dependency?.endpoint, ...(dependency ? { dependency } : {}) },
1758
+ instruction: resumable
1759
+ ? "地图服务暂时限流。保留原条件及已解析端点;到 retry_at 后执行下一步,继续同一任务,不重新提交地点。"
1760
+ : "地图服务本轮未完成解析,这是外部依赖故障,不要求用户换地址或自行找坐标。有界尝试已结束;保留原条件及已有结果,交由服务维护方处理该错误。",
1761
+ next: resumable ? { command: `itpay services run ${execution.service_id} --execution ${id} --json`, reason: "等待所示恢复时间后继续同一执行" } : null,
1762
+ recovery: [],
1763
+ };
1764
+ }
1765
+ if (state === "failed" && workflowReason === "area_scope_too_broad") {
1766
+ return { status: "awaiting_input", result: { service_execution_id: id, reason: "area_scope_too_broad", failure: model.workflow?.failure, affected_endpoint: model.workflow?.failure?.endpoint ?? model.workflow?.dependency?.endpoint, input_template: { endpoints: { [model.workflow?.dependency?.endpoint ?? "<受影响侧>"]: { kind: "area", text: "<市县镇完整名称>" } } } },
1767
+ instruction: "区域范围是省或国家。只补充受影响端点的市、县或镇;保留另一端、日期与用户约束。修正模板:endpoints.<受影响侧>={kind:area,text:<市县镇完整名称>}。",
1768
+ next: null, recovery: [] };
1769
+ }
1770
+ if (state === "failed" && ["address_required", "administrative_area_unresolved", "station_location_evidence_required", "coordinate_identity_unverified"].includes(workflowReason ?? "")) {
1771
+ const code = workflowReason;
1772
+ return {
1773
+ status: "awaiting_input",
1774
+ result: { service_execution_id: id, service_id: execution.service_id, reason: code, affected_endpoint: model.workflow?.failure?.endpoint, failure: model.workflow?.failure,
1775
+ ...(["address_required", "administrative_area_unresolved"].includes(code) ? { new_query_template: {
1776
+ command: `itpay services run ${execution.service_id} --input-json <补全原始需求.json> --json`, executable: false,
1777
+ required_input: ["仅补受影响端点", "原始日期人数时限偏好"],
1778
+ instruction: "原执行输入已锁定;按原需求保留另一端与所有约束,只补缺失信息后提交新输入,不在旧执行伪造修改。"
1779
+ } } : {}) },
1780
+ instruction: code === "station_location_evidence_required"
1781
+ ? "尚未查票。车站身份已知,Smart 缺少车站坐标证据;请说明此系统缺口,不要求用户换站。双锁站直达可另用 Exact。"
1782
+ : code === "coordinate_identity_unverified"
1783
+ ? "尚未查票。名称与提交坐标缺少一致证据;请核实受影响端点的准确地点,保留其余出行条件。"
1784
+ : "尚未查票。地点缺少必要地址或行政归属;只请用户补充受影响端点,保留另一端及原条件。",
1785
+ next: null, recovery: [],
1786
+ };
1787
+ }
1788
+ if (state === "failed" && ["login_required", "rate_limited"].includes(workflowReason ?? "")) {
1789
+ const login = workflowReason === "login_required";
1660
1790
  return {
1661
1791
  status: login ? "login_required" : "rate_limited",
1662
1792
  result: { service_execution_id: id, service_id: execution.service_id },
@@ -1694,29 +1824,42 @@ function servicesNextEnvelope(model) {
1694
1824
  return {
1695
1825
  side,
1696
1826
  status: place?.resolution_status,
1827
+ ...(place?.resolution_reason ? { reason: place.resolution_reason } : {}),
1697
1828
  ...(place?.resolution_status === "resolved"
1698
- ? { resolved_place: { name: name(place), location: place.location } }
1829
+ ? { resolved_place: { name: name(place), address: place.formatted_address, location: place.location } }
1699
1830
  : {}),
1700
1831
  ...(candidates.length
1701
- ? { candidates: candidates.slice(0, 5).map((item) => ({ name: name(item), location: item.location })) }
1832
+ ? { candidates: candidates.slice(0, 3).map((item, index) => ({ id: item.id ?? `${side}_${index + 1}`, name: name(item), address: item.formatted_address, type: item.poi_type ?? item.type, location: item.location })) }
1702
1833
  : {}),
1703
1834
  };
1704
1835
  });
1705
1836
  return {
1706
1837
  status: "confirmation_required",
1707
- result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1708
- required_fields: requiredFields, sides },
1709
- instruction: "向用户展示 sides 中 status=needs_confirmation 一端的候选(名称+坐标),请用户选定后按 required_fields 逐项各传一个 --input:名称取候选 name,坐标取候选 location 的 [lng,lat];已 resolved 的一端把其 resolved_place 原样填入。继续同一执行,不重新查询;这一步不是购买确认。",
1838
+ result: { service_execution_id: id, service_id: execution.service_id,
1839
+ human_action: { action_type: action.action_type, input_schema: action.input_schema },
1840
+ required_fields: requiredFields, sides,
1841
+ ...(sides.some(side => side.status !== "resolved" && !side.candidates?.length && ["ADDRESS_REQUIRED", "ADMINISTRATIVE_AREA_UNRESOLVED"].includes(side.reason ?? "")) ? {
1842
+ new_query_template: { command: `itpay services run ${execution.service_id} --input-json <补全原始需求.json> --json`, executable: false,
1843
+ required_input: ["补缺失端点", "保留原始日期人数时限偏好及已确认端点"], instruction: "原执行锁定输入;补全原需求后用新查询,不提交不存在的候选。" },
1844
+ } : {}) },
1845
+ instruction: sides.some(side => side.status !== "resolved" && !side.candidates?.length)
1846
+ ? locationConfirmationEnvelope(id, "", {}, { endpoints: sides.filter(side => side.status !== "resolved") }).instruction
1847
+ : requiredFields.includes("choices")
1848
+ ? "尚未查票。仅向用户展示有真实歧义端点的候选编号、名称、行政地址与类型(内部 id 仅用于回填,不展示给人);用户选定后,把各未解析端点的候选 id 放入 choices 对象,继续同一执行。已解析端点无需再选;不得替用户决定,也不是购买确认。"
1849
+ : "向用户展示有歧义一端的候选;按 required_fields 填写真实选择,已解析一端保留原值。继续同一执行;这不是购买确认。",
1710
1850
  next: null,
1711
- interaction: {
1712
- schema_version: "itpay.interaction.v1",
1713
- stage: "location_confirmation_required",
1714
- input_template: {
1715
- command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`,
1716
- required_input: requiredFields,
1717
- executable: false,
1718
- },
1719
- },
1851
+ ...(sides.some(side => side.status !== "resolved" && !side.candidates?.length) ? {} : { interaction: {
1852
+ schema_version: "itpay.interaction.v1",
1853
+ stage: "location_confirmation_required",
1854
+ input_template: {
1855
+ command: requiredFields.includes("choices")
1856
+ ? `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <地点确认.json> --json`
1857
+ : `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`,
1858
+ input: { choices: Object.fromEntries(sides.filter(side => side.candidates?.length).map(side => [side.side, "<所选候选id>"])) },
1859
+ required_input: requiredFields,
1860
+ executable: false,
1861
+ },
1862
+ } }),
1720
1863
  recovery: [],
1721
1864
  };
1722
1865
  }
@@ -1732,24 +1875,22 @@ function servicesNextEnvelope(model) {
1732
1875
  : undefined;
1733
1876
  return {
1734
1877
  status: "booking_review_required",
1735
- result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1736
- required_fields: requiredFields,
1878
+ result: { service_execution_id: id, service_id: execution.service_id, review,
1879
+ action_type: action.action_type, required_fields: requiredFields,
1737
1880
  ...(requirementsRemaining ? { requirements_remaining: requirementsRemaining } : {}) },
1738
1881
  ...(reviewURL ? { handoff: { url: reviewURL, kind: "booking_review" } } : {}),
1739
- instruction: reviewURL
1740
- ? "已在 handoff.url 提供官方行程确认页,请用户在该页核对行程、席别与座位偏好并确认;页面打开请求不代表用户已确认。该页失效或用户更愿在对话中确认时,仍可按下述 input_template 一次合并提交。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。"
1741
- : "向用户展示 context.review 中的行程(legs)与可选席别/座位偏好词表(seat_options),并明确告知:座位偏好仅为请求、不保证分配(对应 notice_version 文案须经用户同意)。这是购买前的合并确认:把乘客人数、席别与每位乘客的座位偏好一次问清,然后按 required_fields 组装完整 JSON 对象,经 --input-json <file> 提交——draft_revision 必须等于服务端当前值,被拒绝(booking_review_changed)时重新读取后重试。未得真实确认不得提交,不能暗设 accept_non_guaranteed。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。",
1882
+ instruction: `${reviewURL ? "官方确认页见 handoff.url,打开不等于确认;沿用已知人数和席别。" : "当前草稿见 review。"} 对照对话只补缺失选择或真实条款同意;无字母偏好自动分配,不默认问字母。仅代码修正且车次、实际席别、人数、价格及条款不变,沿用已有明确确认;实质变化只核对变化。提交当前 draft_revision/notice_version 及必要字段,未得真实同意不能填 accept_non_guaranteed;座位请求不保证分配。身份信息只在官方 Checkout 页填写。`,
1742
1883
  next: null,
1743
1884
  interaction: {
1744
1885
  schema_version: "itpay.interaction.v1",
1745
1886
  stage: "booking_review_required",
1746
1887
  input_template: {
1747
1888
  command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <file> --json`,
1748
- required_input: requiredFields.length > 0 ? requiredFields : ["file"],
1889
+ required_input: ["file", ...requiredFields],
1749
1890
  executable: false,
1750
1891
  },
1751
1892
  },
1752
- recovery: [{ command: `itpay services next ${id} --json`, reason: "重新读取当前 draft_revision 后重试" }],
1893
+ recovery: [],
1753
1894
  };
1754
1895
  }
1755
1896
  return {
@@ -1782,8 +1923,8 @@ function servicesNextEnvelope(model) {
1782
1923
  executable: false,
1783
1924
  }
1784
1925
  : undefined;
1785
- const guidance = railServiceGuidance(execution.service_id);
1786
- const failedStep = recovery ? failedWorkflowStep(model.workflow?.steps, model.workflow?.error_code) : undefined;
1926
+ const guidance = railServiceGuidance(execution.service_id, model.workflow_entry?.input_schema);
1927
+ const failedStep = recovery ? model.workflow?.failure?.step_id : undefined;
1787
1928
  const failedStepGuidance = failedStep ? WORKFLOW_STEP_GUIDANCE[failedStep] : undefined;
1788
1929
  const failedInvocation = recovery
1789
1930
  ? [...model.provider_invocations].reverse().find((item) => typeof item.status === "string" && item.status.startsWith("failed"))
@@ -1796,7 +1937,7 @@ function servicesNextEnvelope(model) {
1796
1937
  quote_seat_unavailable: "所选席别当前无足够余票;告知用户并由其决定是否换方案。",
1797
1938
  quote_refresh_expired: "实时报价过程超时;保留原选择,不把旧价当可付款价格。",
1798
1939
  }[providerErrorCode ?? ""] : undefined;
1799
- const failureCode = providerErrorCode ?? model.workflow?.error_code;
1940
+ const failureCode = model.workflow?.failure?.reason_code ?? providerErrorCode ?? model.workflow?.error_code;
1800
1941
  const failedInstruction = recovery
1801
1942
  ? quoteFailure
1802
1943
  ? quoteFailure
@@ -1811,7 +1952,9 @@ function servicesNextEnvelope(model) {
1811
1952
  result: {
1812
1953
  service_execution_id: id,
1813
1954
  service_id: execution.service_id,
1814
- workflow: model.workflow,
1955
+ workflow_state: state,
1956
+ ...(model.workflow ? { current_step: model.workflow.current_step, workflow_revision: model.workflow.revision } : {}),
1957
+ ...(model.workflow?.failure ? { failure: model.workflow.failure } : {}),
1815
1958
  ...(state === "input_required" ? { input_schema: model.workflow_entry.input_schema } : {}),
1816
1959
  ...(guidance && state === "input_required" ? { guidance } : {}),
1817
1960
  ...(failedStep ? { failed_step: failedStep, ...(failedStepGuidance ? { failed_step_meaning: failedStepGuidance.meaning } : {}) } : {}),
@@ -1827,7 +1970,7 @@ function servicesNextEnvelope(model) {
1827
1970
  : guidance
1828
1971
  ? "按 result.guidance 的字段契约填写输入后继续同一服务执行;不要臆造字段名。"
1829
1972
  : "继续读取同一执行;缺少输入时按服务声明补齐。"),
1830
- next: recovery ? null : { command, reason: "继续当前流程" },
1973
+ next: recovery || inputTemplate ? null : { command, reason: "继续当前流程" },
1831
1974
  ...(inputTemplate || (state === "payment" && model.workflow?.human_action?.context?.review) ? {
1832
1975
  interaction: {
1833
1976
  schema_version: "itpay.interaction.v1",
@@ -1869,11 +2012,14 @@ function servicesNextEnvelope(model) {
1869
2012
  status: total > 0 ? "result_ready" : "no_result",
1870
2013
  result: { service_execution_id: execution.service_execution_id, scope: "station_pair",
1871
2014
  resolved_station_pair: container?.resolved_station_pair, total,
1872
- result_item_id: item.service_capability_result_item_id },
2015
+ result_item_id: item.service_capability_result_item_id,
2016
+ trains: Array.isArray(container?.candidates) ? container.candidates.slice(0, 3).map((value, index) => exactTrainRow(value, execution.service_execution_id, item.service_capability_result_item_id, index)) : [],
2017
+ ...(exactSmartTemplate(container?.resolved_station_pair, execution.service_execution_id, item.service_capability_result_item_id)
2018
+ ? { smart_template: exactSmartTemplate(container?.resolved_station_pair, execution.service_execution_id, item.service_capability_result_item_id) } : {}) },
1873
2019
  instruction: total > 0
1874
2020
  ? "直达站对结果已保存。执行 next.command 一次读取紧凑车次行,按用户条件筛选;若只需比较,答复后结束;已有购票委托则读取所选行详情。"
1875
2021
  : "这一已解析站对返回 0 条;不代表全城无车。若原需求需要更广覆盖,可核验另一可信站对或 Smart。",
1876
- next: { command: `itpay services read-result ${execution.service_execution_id} --json`, reason: "读取这一已保存站对的完整紧凑结果" },
2022
+ next: { command: `itpay services read-result ${execution.service_execution_id} --json`, reason: "读取这一保存站对的紧凑分页;按返回的续页覆盖所需范围" },
1877
2023
  recovery: [],
1878
2024
  };
1879
2025
  }
@@ -2089,21 +2235,7 @@ function grantedResultEnvelope(response, orderID) {
2089
2235
  };
2090
2236
  }
2091
2237
  function servicesNextPlainResult(result) {
2092
- const lines = [];
2093
- for (const [key, value] of Object.entries(result)) {
2094
- if (key === "items" && Array.isArray(value)) {
2095
- lines.push("items:");
2096
- for (const item of value) {
2097
- lines.push(` ${item.rank}. ${item.title}`);
2098
- for (const [field, fieldValue] of Object.entries(item.safe_payload)) {
2099
- lines.push(` ${field}: ${typeof fieldValue === "string" ? fieldValue : JSON.stringify(fieldValue)}`);
2100
- }
2101
- }
2102
- continue;
2103
- }
2104
- lines.push(`${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
2105
- }
2106
- return lines;
2238
+ return plainValueLines(result);
2107
2239
  }
2108
2240
  export async function runServicesEvents(backend, serviceExecutionID, options = {}) {
2109
2241
  const afterSequence = options.afterSequence ?? 0;
@@ -2134,11 +2266,8 @@ export async function runServicesEvents(backend, serviceExecutionID, options = {
2134
2266
  returned_count: events.length,
2135
2267
  events,
2136
2268
  },
2137
- instruction: "事件仅用于诊断;不要从事件重放业务步骤,回到 services next 获取当前动作。",
2138
- next: {
2139
- command: `itpay services next ${serviceExecutionID} --json`,
2140
- reason: "恢复正常服务流程",
2141
- },
2269
+ instruction: "这是已保存的诊断事件;事件不代表车票结果,也不授权重放业务步骤。已读完所需诊断时停止;实际任务沿先前返回的当前动作继续。",
2270
+ next: null,
2142
2271
  recovery: events.length === limit && events.length > 0
2143
2272
  ? [{
2144
2273
  command: `itpay services events ${serviceExecutionID} --after-sequence ${events.at(-1).sequence} --limit ${limit} --json`,
@@ -2214,7 +2343,7 @@ function buildServicesCheckoutEnvelope(response, checkoutURL, plan, agentType, t
2214
2343
  service_execution_id: response.binding.service_execution_id,
2215
2344
  checkout_id: checkout.checkout.checkout_id,
2216
2345
  capability_id: checkoutCapabilityID(response),
2217
- locked_input: response.locked_input,
2346
+ items: plan.orderItems ?? [],
2218
2347
  amount,
2219
2348
  ...(checkout.checkout.expires_at ? {
2220
2349
  // The shared server-owned payable deadline D: refreshing the page or
@@ -2336,11 +2465,11 @@ export async function runServicesRun(backend, config, serviceID, input, options
2336
2465
  }
2337
2466
  if (cause instanceof CommandContractError || cause instanceof HttpError || cause instanceof DeviceLockBusyError || cause instanceof DeviceStateError)
2338
2467
  throw cause;
2339
- if (cause instanceof HttpTransportError && !id) {
2340
- throw new CommandContractError("workflow_start_outcome_unknown", cause.message, "创建服务执行时没有收到完整响应。先查询当前身份可见的执行;不要直接重跑并创建替代执行。", [{ command: "itpay services list --json", reason: "查找可能已经创建的服务执行" }]);
2468
+ if (!id) {
2469
+ throw new CommandContractError("workflow_start_outcome_unknown", cause instanceof Error ? cause.message : "service creation outcome unknown", "创建服务执行时没有收到完整响应。先查询当前身份可见的执行;不要直接重跑并创建替代执行。", [{ command: "itpay services list --json", reason: "查找可能已经创建的服务执行" }]);
2341
2470
  }
2342
2471
  if (cause instanceof HttpTransportError)
2343
- throw cause;
2344
- throw new CommandContractError("workflow_run_failed", cause instanceof Error ? cause.message : "workflow run failed", "保留当前执行并按错误处理,不要重复创建服务执行。", [{ command: `itpay services run ${serviceID} --execution ${id} --json`, reason: "恢复同一执行" }]);
2472
+ throw new CommandContractError("workflow_outcome_unknown", cause.message, "未收到完整响应;只读同一执行的权威事实,不重放写操作或创建替代执行。", [{ command: `itpay services next ${id} --json`, reason: "核对同一执行" }]);
2473
+ throw new CommandContractError("workflow_run_failed", cause instanceof Error ? cause.message : "workflow run failed", "保留当前执行并按错误处理,不要重复创建服务执行。", [{ command: `itpay services next ${id} --json`, reason: "读取同一执行的当前事实与恢复动作" }]);
2345
2474
  }
2346
2475
  }