@itpay/cli 2.1.19 → 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.
Files changed (86) hide show
  1. package/README.md +2 -2
  2. package/dist/src/commands/buy.js +3 -3
  3. package/dist/src/commands/cart.js +15 -15
  4. package/dist/src/commands/catalog.js +10 -7
  5. package/dist/src/commands/checkout.js +8 -11
  6. package/dist/src/commands/docs.js +3 -14
  7. package/dist/src/commands/guidance.js +41 -25
  8. package/dist/src/commands/install.js +2 -2
  9. package/dist/src/commands/order.js +4 -3
  10. package/dist/src/commands/orders.js +4 -9
  11. package/dist/src/commands/pay.js +1 -1
  12. package/dist/src/commands/rail_catalog.js +160 -11
  13. package/dist/src/commands/refund.js +5 -5
  14. package/dist/src/commands/services.js +658 -361
  15. package/dist/src/commands/skill.js +1 -1
  16. package/dist/src/commands/vault.js +27 -20
  17. package/dist/src/main.js +39 -24
  18. package/dist/src/render/markdown.js +0 -3
  19. package/dist/src/state/account_auth.js +9 -5
  20. package/dist/src/state/cart_session.js +4 -1
  21. package/dist/src/state/config.js +1 -1
  22. package/dist/src/state/task_journal.js +2 -0
  23. package/docs/agent/buyer/cart-checkout.json +5 -4
  24. package/docs/agent/buyer/catalog-list.json +24 -6
  25. package/docs/agent/buyer/identity-and-sessions.json +16 -15
  26. package/docs/agent/buyer/install-and-setup.json +5 -4
  27. package/docs/agent/buyer/orders-refunds.json +11 -5
  28. package/docs/agent/buyer/payment-flow.json +3 -3
  29. package/docs/agent/buyer/purchased-content.json +18 -4
  30. package/docs/agent/buyer/quickstart.json +22 -14
  31. package/docs/agent/buyer/rail-booking.json +125 -171
  32. package/docs/agent/buyer/rail-fast-checkout.json +21 -44
  33. package/docs/agent/buyer/render-hosts.json +9 -6
  34. package/docs/agent/buyer/service-feedback.json +2 -1
  35. package/docs/cli-reference/agent-types.md +1 -1
  36. package/docs/cli-reference/commands/auth.md +2 -0
  37. package/docs/cli-reference/commands/buy.md +1 -1
  38. package/docs/cli-reference/commands/cart/add.md +1 -1
  39. package/docs/cli-reference/commands/cart/clear.md +1 -1
  40. package/docs/cli-reference/commands/cart/index.md +1 -1
  41. package/docs/cli-reference/commands/cart/next.md +1 -1
  42. package/docs/cli-reference/commands/cart/remove.md +1 -1
  43. package/docs/cli-reference/commands/cart/show.md +1 -1
  44. package/docs/cli-reference/commands/catalog/index.md +1 -1
  45. package/docs/cli-reference/commands/catalog/list.md +1 -1
  46. package/docs/cli-reference/commands/checkout.md +1 -1
  47. package/docs/cli-reference/commands/device.md +8 -7
  48. package/docs/cli-reference/commands/docs/index.md +1 -1
  49. package/docs/cli-reference/commands/docs/list.md +1 -1
  50. package/docs/cli-reference/commands/docs/search.md +1 -1
  51. package/docs/cli-reference/commands/docs/show.md +1 -1
  52. package/docs/cli-reference/commands/install.md +2 -2
  53. package/docs/cli-reference/commands/next.md +1 -1
  54. package/docs/cli-reference/commands/order.md +1 -1
  55. package/docs/cli-reference/commands/pay.md +1 -1
  56. package/docs/cli-reference/commands/readyz.md +1 -1
  57. package/docs/cli-reference/commands/refund/cancel.md +1 -1
  58. package/docs/cli-reference/commands/refund/create.md +1 -1
  59. package/docs/cli-reference/commands/refund/get.md +2 -2
  60. package/docs/cli-reference/commands/refund/index.md +1 -1
  61. package/docs/cli-reference/commands/refund/list.md +1 -1
  62. package/docs/cli-reference/commands/refund/watch.md +1 -1
  63. package/docs/cli-reference/commands/services/action.md +3 -3
  64. package/docs/cli-reference/commands/services/checkout.md +1 -1
  65. package/docs/cli-reference/commands/services/events.md +1 -1
  66. package/docs/cli-reference/commands/services/get.md +1 -1
  67. package/docs/cli-reference/commands/services/index.md +1 -1
  68. package/docs/cli-reference/commands/services/invoke.md +5 -13
  69. package/docs/cli-reference/commands/services/list.md +1 -1
  70. package/docs/cli-reference/commands/services/next.md +12 -10
  71. package/docs/cli-reference/commands/services/page.md +9 -7
  72. package/docs/cli-reference/commands/services/quote.md +1 -1
  73. package/docs/cli-reference/commands/services/read-result.md +16 -9
  74. package/docs/cli-reference/commands/services/run.md +4 -2
  75. package/docs/cli-reference/commands/services/start.md +1 -1
  76. package/docs/cli-reference/conventions.md +2 -2
  77. package/package.json +7 -5
  78. package/skills/itpay/SKILL.md +77 -238
  79. package/docs/reviews/agent-ux-handoff-2026-09-23/IMPLEMENTATION-EVIDENCE.md +0 -174
  80. package/docs/skill-bundle-rollout/01-mcp-authentication.md +0 -134
  81. package/docs/skill-bundle-rollout/02-platform-bundle-repositories.md +0 -210
  82. package/docs/skill-bundle-rollout/03-platform-publishing.md +0 -161
  83. package/docs/skill-bundle-rollout/04-first-wave-platforms.md +0 -70
  84. package/docs/skill-bundle-rollout/05-sync-operations.md +0 -62
  85. package/docs/skill-bundle-rollout/README.md +0 -151
  86. /package/bin/{itp → itp.mjs} +0 -0
@@ -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-rail-smart。",
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
- "站名不确定就改用 itpay-rail-smart 先解析位置,不要在两个服务之间反复试错",
48
+ "查询为空只说明这一已解析站对;允许中转可用Smart保持双站;改变用户指定站须先取得同意",
48
49
  ],
49
50
  },
50
51
  "itpay-rail-smart": {
51
- when_to_use: "只有城市名、模糊位置或需要中转规划与方案推荐时使用本服务;平台负责把位置解析到车站。",
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,48 +61,51 @@ 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" },
71
+ { name: "passengers", description: "成人乘客整数 1-5;不填写身份资料" },
66
72
  ],
67
- input_example: { origin: "中山古镇", destination: "广州南", travel_date: "2026-09-19", priority: "fastest" },
73
+ input_example: { origin: "<用户出发地点>", destination: "<用户目的地>", travel_date: "<用户出行日期 YYYY-MM-DD>", priority: "fastest", passengers: 1 },
68
74
  notes: [
69
75
  "位置有歧义时会进入位置确认步骤,请用户选定后继续同一执行",
70
76
  "推荐结果含每趟车的席别与余票;购票在后续受保护流程完成",
71
77
  ],
72
78
  },
73
79
  };
74
- function railServiceGuidance(serviceID) {
75
- 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
+ };
76
96
  }
77
97
  const WORKFLOW_STEP_GUIDANCE = {
78
- input: { meaning: "输入校验", hint: "对照服务声明的输入契约补齐字段后重新发起" },
79
- quota: { meaning: "额度检查", hint: "免费额度或限流未通过;登录或稍后重试" },
80
- geo: { meaning: "位置解析", hint: "检查 origin/destination 是否为真实地名;可附 origin_city 或坐标对象提示" },
81
- geo_confirm: { meaning: "确认后位置解析", hint: "用户确认的地点仍未解析成功;重新执行并让用户从候选项中按名称+坐标选择" },
82
- resolved: { meaning: "位置解析复核", hint: "位置解析未满足继续条件;检查 origin/destination 后新建执行重试" },
83
- resolved_after_confirm: { meaning: "位置确认复核", hint: "确认后的位置仍未通过复核;重新执行并核对用户所选候选项" },
84
- search: { meaning: "供应商车次检索", hint: "最常见是字段名错误(必须是 travel_date)或站名不存在;按 itpay docs show rail-booking 核对输入" },
85
- catalog: { meaning: "可行车次计算", hint: "位置已解析但无可行车次;换日期或换站点重试" },
86
- recommend: { meaning: "方案推荐", hint: "候选集无法产出推荐;放宽条件或换日期重试" },
87
- delivery: { meaning: "交付", hint: "结果组装失败;稍后重试或联系运营" },
98
+ input: { meaning: "输入校验", hint: "核对本服务的输入契约和服务端错误码;本执行的输入不能凭猜测修复" },
99
+ quota: { meaning: "额度检查", hint: "查看实际额度及授权状态;只按返回的登录或等待动作处理" },
100
+ geo: { meaning: "位置解析", hint: "查看实际地点解析错误;有歧义时让用户选择返回的候选" },
101
+ geo_confirm: { meaning: "确认后位置解析", hint: "核对服务端记录的候选与用户实际选择" },
102
+ resolved: { meaning: "位置解析复核", hint: "查看未满足的实际位置条件" },
103
+ resolved_after_confirm: { meaning: "位置确认复核", hint: "核对已确认地点与服务端复核结果" },
104
+ search: { meaning: "供应商车次检索", hint: "查看供应商返回的错误码与当前站对;不要推断是字段或站名错误" },
105
+ catalog: { meaning: "可行车次计算", hint: "查看已保存结果和实际过滤原因;不要无原因改日期或站点" },
106
+ recommend: { meaning: "方案推荐", hint: "查看已保存候选和实际推荐失败原因;不要无原因放宽条件" },
107
+ delivery: { meaning: "交付", hint: "读取当前执行状态;已有订单或付款状态不明时先核实,不重建" },
88
108
  };
89
- function failedWorkflowStep(steps, errorCode) {
90
- if (!steps)
91
- return undefined;
92
- for (const [step, status] of Object.entries(steps)) {
93
- if (status === "failure" && step !== "failure")
94
- return step;
95
- }
96
- // Only a backend-recorded condition_unmet proves a false branch routed to failure.
97
- if (errorCode === "condition_unmet") {
98
- for (const [step, status] of Object.entries(steps)) {
99
- if (status === "false" && step !== "failure")
100
- return step;
101
- }
102
- }
103
- return undefined;
104
- }
105
109
  export async function runServicesStart(backend, serviceID, options = {}) {
106
110
  const host = options.host ?? "terminal";
107
111
  const response = await backend.startServiceExecution({
@@ -114,11 +118,11 @@ export async function runServicesStart(backend, serviceID, options = {}) {
114
118
  },
115
119
  });
116
120
  if (response.workflow_entry) {
117
- const guidance = railServiceGuidance(serviceID);
121
+ const guidance = railServiceGuidance(serviceID, response.workflow_entry.input_schema);
118
122
  writeCommandEnvelope({
119
123
  status: "input_required",
120
124
  result: { service_execution_id: response.execution.service_execution_id, service_id: serviceID, input_schema: response.workflow_entry.input_schema, ...(guidance ? { guidance } : {}) },
121
- instruction: guidance ? "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 可直接照抄),然后继续同一服务执行。不要臆造字段名。" : "根据服务声明填写输入,然后继续同一服务执行。",
125
+ instruction: guidance ? "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 是待填示意,必须替换用户真实值),然后继续同一服务执行。不要臆造字段名。" : "根据服务声明填写输入,然后继续同一服务执行。",
122
126
  next: null,
123
127
  interaction: {
124
128
  schema_version: "itpay.interaction.v1",
@@ -191,18 +195,29 @@ function locationConfirmationEnvelope(executionID, capabilityID, input, confirma
191
195
  input = (confirmation.input ?? input);
192
196
  const endpoints = (confirmation.endpoints ?? []);
193
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
+ : "尚未查票。仅向用户确认有真实歧义的端点,展示候选名称、地址和高德链接。用户选择后保留原输入,回到同一服务。";
194
209
  return {
195
210
  status: "location_confirmation_required",
196
211
  result: { service_execution_id: executionID, query: input, location_confirmation: confirmation },
197
- instruction: "尚未查票。仅向用户确认 endpoints 中有歧义的地点,展示真实候选名称、地址和高德链接。不要自行选择候选、把区域改成车站或重复调用原查询。用户选择后保留原输入,带 location_confirmation 回到同一服务。没有候选时请用户补充已知城市或准确地名,再使用修正输入查询。",
212
+ instruction,
198
213
  next: null,
199
- ...(confirmation.can_resume ? {
214
+ ...(confirmation.can_resume && !missing.length && endpoints.length ? {
200
215
  interaction: {
201
216
  schema_version: "itpay.interaction.v1",
202
217
  stage: "location_confirmation_required",
203
218
  input_template: {
204
- command: `itpay services invoke ${executionID} --capability ${capabilityID}${formatInputOptions({ ...input,
205
- 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 } },
206
221
  required_input: ["choices"],
207
222
  executable: false,
208
223
  },
@@ -217,6 +232,8 @@ function requiredInputFields(schema) {
217
232
  }
218
233
  function locationInputInstruction(schema) {
219
234
  const properties = schema?.properties;
235
+ if (properties?.endpoints)
236
+ return " 两端分别填写 endpoints.origin/destination 的 kind 与完整 text;不确定 kind 可省略。区域保留范围、精确车站仅在目录验证后锁定。不要混用旧 origin/destination、手填锁站或伪造坐标。唯一地点会自动解析;只有真实多候选才让用户选返回的候选 id,地图失败应按当前执行恢复。";
220
237
  if (!properties?.origin_location || !properties?.destination_location)
221
238
  return "";
222
239
  if (!properties.location_confirmation)
@@ -234,13 +251,9 @@ export async function runServicesInvoke(backend, config, serviceExecutionID, cap
234
251
  }
235
252
  const missingInput = missingRequiredInput(requestedCapability.input_schema, input);
236
253
  if (missingInput.length > 0) {
237
- const correctedInput = { ...input };
238
- for (const field of missingInput)
239
- correctedInput[field] = "<value>";
240
- throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐 required_input 后重试同一个 execution;本次没有调用 Provider。", [{
241
- command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(correctedInput)} --json`,
242
- reason: "提交完整 capability 输入",
243
- }]);
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 } });
244
257
  }
245
258
  const idempotencyKey = await operationID(config, `service.invoke:${serviceExecutionID}:${capabilityID}:${stableInput(input)}`);
246
259
  let response;
@@ -293,9 +306,16 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
293
306
  ...(quota ? { quota } : {}),
294
307
  };
295
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;
296
316
  if (!response.effective_quota?.exhausted && preview?.search_status === "LOCATION_CONFIRMATION_REQUIRED" && preview.location_confirmation) {
297
317
  return { value: locationConfirmationEnvelope(response.execution.service_execution_id, requestedCapability.capability_id, input, preview.location_confirmation),
298
- plainResult: [JSON.stringify(preview.location_confirmation)] };
318
+ plainResult: plainValueLines(preview.location_confirmation) };
299
319
  }
300
320
  if (typeof preview?.catalog_total === "number") {
301
321
  baseResult.catalog = {
@@ -303,7 +323,7 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
303
323
  recommendation: preview.recommendation,
304
324
  decision_source: preview.decision_source,
305
325
  coverage: preview.coverage,
306
- notices: preview.notices,
326
+ notices,
307
327
  search_status: preview.search_status,
308
328
  searched_scope: preview.searched_scope,
309
329
  effective_policy_hash: preview.effective_policy_hash,
@@ -320,15 +340,15 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
320
340
  let instruction = items.length > 0
321
341
  ? "用编号、名称和可公开字段向用户说明候选;若候选列表已满足目标就停止。只有用户明确选择并希望继续时,才提交对应编号;不要向用户提及 safe_payload、Execution 或内部 ID。"
322
342
  : `没有找到与“${queryText(input)}”匹配的结果。向用户展示本次为 0 个结果并停止。不要修改、缩短或猜测其他输入;只有用户明确提供新输入后,才能启动新的查询。`;
323
- if (items.length === 0 && Array.isArray(preview?.notices)) {
324
- const railNotice = preview.notices.find((notice) => {
343
+ if (items.length === 0 && Array.isArray(notices)) {
344
+ const railNotice = notices.find((notice) => {
325
345
  if (!notice || typeof notice !== "object")
326
346
  return false;
327
347
  const value = notice;
328
348
  return ["RAIL_TRANSFER_SCOPE_LIMIT", "RAIL_TRANSFER_SEARCH_INCOMPLETE"].includes(String(value.code)) && typeof value.message === "string";
329
349
  });
330
350
  if (railNotice)
331
- instruction = `向用户展示官方提示:${railNotice.message} 不要断言该行程必须多次中转或没有车。等待用户选择分段查询的起终点,不自动更换输入或重试。`;
351
+ instruction = `向用户展示官方提示:${railNotice.message} 说明本次已查范围和未完成原因,按服务端提供的当前动作恢复;不能据此断言没有车,不默认扩大范围或让用户另选分段站点。`;
332
352
  }
333
353
  let next = null;
334
354
  if (items.length > 0 && baseResult.catalog) {
@@ -374,13 +394,6 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
374
394
  ? {
375
395
  schema_version: "itpay.interaction.v1",
376
396
  stage: "query_results_ready",
377
- by_goal: {
378
- compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
379
- prepare_checkout: {
380
- action: "select_under_user_rules_then_prepare",
381
- requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
382
- },
383
- },
384
397
  input_template: {
385
398
  command: `itpay services action ${response.execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
386
399
  required_input: ["rank"],
@@ -396,40 +409,7 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
396
409
  };
397
410
  }
398
411
  function serviceResultPlainLines(result) {
399
- const lines = [
400
- `service_execution_id: ${String(result.service_execution_id)}`,
401
- `capability_id: ${String(result.capability_id)}`,
402
- ];
403
- const items = result.items;
404
- const query = result.query;
405
- if (query) {
406
- for (const [key, value] of Object.entries(query))
407
- lines.push(`${key}: ${String(value)}`);
408
- }
409
- if (items.length === 0)
410
- lines.push("results: 0");
411
- const catalog = result.catalog;
412
- if (catalog?.resolved_locations)
413
- lines.push(`resolved_locations: ${JSON.stringify(catalog.resolved_locations)}`);
414
- if (catalog?.journey_summary) {
415
- for (const key of ["journey_summary", "train_services", "journeys"]) {
416
- lines.push(`${key}: ${JSON.stringify(catalog[key])}`);
417
- }
418
- }
419
- if (result.quota)
420
- lines.push(`quota: ${JSON.stringify(result.quota)}`);
421
- if (result.checkout)
422
- lines.push(`checkout: ${JSON.stringify(result.checkout)}`);
423
- if (items.length > 0) {
424
- lines.push("items:");
425
- for (const item of items) {
426
- lines.push(` ${item.rank}. ${item.title}`);
427
- for (const [key, value] of Object.entries(item.safe_payload)) {
428
- lines.push(` ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
429
- }
430
- }
431
- }
432
- return lines;
412
+ return plainValueLines(result);
433
413
  }
434
414
  function queryText(input) {
435
415
  const value = Object.values(input).find((item) => typeof item === "string" && item.trim() !== "");
@@ -523,11 +503,6 @@ function formatInputOptions(input) {
523
503
  : ` --input ${shellArgument(`${key}=${typeof value === "object" && value !== null ? JSON.stringify(value) : String(value)}`)}`)
524
504
  .join("");
525
505
  }
526
- function shellArgument(value) {
527
- if (/^[\p{L}\p{N}._:=/-]+$/u.test(value))
528
- return value;
529
- return `'${value.replaceAll("'", `'"'"'`)}'`;
530
- }
531
506
  export async function runServicesAction(backend, serviceExecutionID, actionType, input, options = {}) {
532
507
  const selection = await resolveCandidateSelection(backend, serviceExecutionID, actionType, options);
533
508
  const request = {
@@ -673,13 +648,10 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
673
648
  const lockedInput = options.lockedInput ?? {};
674
649
  const missingInput = missingRequiredInput(capability.input_schema, lockedInput);
675
650
  if (missingInput.length > 0 && !readModel.workflow_entry && readModel.execution.next_action !== "create_checkout") {
676
- 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 } });
677
652
  }
678
653
  if (capability.delivery_email_required && readModel.workflow_entry?.capability_id !== capability.capability_id && String(deliveryContact.email ?? "").trim() === "") {
679
- throw new CommandContractError("delivery_email_required", "delivery email is required before creating this service checkout", "该 capability 的交付链接会发送到用户邮箱;先向用户说明用途并询问邮箱,不要代填。", [{
680
- command: `itpay services checkout ${serviceExecutionID} --capability ${capability.capability_id}${formatInputOptions(lockedInput)} --email <email> --json`,
681
- reason: "使用用户提供的邮箱创建 Checkout",
682
- }]);
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 } });
683
655
  }
684
656
  }
685
657
  const response = await backend.createServiceExecutionCheckout(serviceExecutionID, {
@@ -695,6 +667,33 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
695
667
  const checkoutURL = tokenizedCheckoutURL(checkout.checkout_url, displayToken, checkout.qr_payload);
696
668
  const cardURL = localizeCardURL(absolutePublicURL(config.baseURL, checkout.card_url ?? fallbackCardURL(config.baseURL, checkoutID, displayToken)), locale);
697
669
  const cardPNGURL = localizeCardURL(absolutePublicURL(config.baseURL, checkout.card_png_url ?? checkout.qr_png_url ?? fallbackCardPNGURL(config.baseURL, checkoutID, displayToken)), locale);
670
+ let itineraryTitle;
671
+ let itinerary;
672
+ try {
673
+ const booking = await backend.getServiceExecution(serviceExecutionID);
674
+ if (booking.execution.service_id === "itpay-rail-booking") {
675
+ const leg = booking.rail_booking?.legs?.[0];
676
+ const review = booking.workflow?.human_action?.context?.review;
677
+ const reviewLeg = Array.isArray(review?.legs) ? review.legs[0] : undefined;
678
+ const facts = leg ?? reviewLeg;
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 };
686
+ itineraryTitle = [facts.travel_date, facts.train_code,
687
+ facts.from ?? facts.from_station,
688
+ "→", facts.to ?? facts.to_station,
689
+ facts.seat_name ?? facts.seat_type_name]
690
+ .filter((value) => typeof value === "string" && value.length > 0).join(" ");
691
+ }
692
+ }
693
+ }
694
+ catch {
695
+ // Checkout already exists; a display-only title lookup cannot hide its official handoff.
696
+ }
698
697
  const plan = buildCheckoutQRPlan({
699
698
  host,
700
699
  checkoutID,
@@ -705,7 +704,7 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
705
704
  qrPNGURL: cardPNGURL,
706
705
  nextAction: checkout.checkout.next_action,
707
706
  orderItems: response.cart.items.map((item) => ({
708
- title: item.title,
707
+ title: itineraryTitle || item.title,
709
708
  quantity: item.quantity,
710
709
  amountMinor: item.amount_minor,
711
710
  currency: item.currency,
@@ -742,13 +741,9 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
742
741
  });
743
742
  }
744
743
  const envelope = buildServicesCheckoutEnvelope(response, checkoutURL, plan, options.agentType, options.target);
745
- const plainResult = [
746
- `service_execution_id: ${response.binding.service_execution_id}`,
747
- `checkout_id: ${checkoutID}`,
748
- `capability_id: ${checkoutCapabilityID(response, capabilityID)}`,
749
- `locked_input: ${JSON.stringify(response.locked_input)}`,
750
- `amount: ${formatMoney(checkout.checkout.amount_minor, checkout.checkout.currency)}`,
751
- ];
744
+ if (itinerary)
745
+ envelope.result = { ...envelope.result, itinerary };
746
+ const plainResult = plainValueLines(envelope.result);
752
747
  if (!options.jsonOutput && platform === "terminal") {
753
748
  plainResult.push("qr:", await renderTerminalQR(checkoutURL, options.qrFormat ?? "terminal"));
754
749
  }
@@ -768,14 +763,14 @@ export async function runServicesQuote(backend, serviceExecutionID, capabilityID
768
763
  model.allowed_actions?.some((action) => action.type === "prepare_quote" && action.capability_id === capabilityID);
769
764
  const missingInput = missingRequiredInput(capability.input_schema, input);
770
765
  if (missingInput.length > 0 && !selectionBacked && model.workflow_entry?.capability_id !== capability.capability_id) {
771
- 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 } });
772
767
  }
773
768
  const deliveryContact = {
774
769
  ...(options.deliveryContact ?? {}),
775
770
  ...(options.email ? { email: options.email } : {}),
776
771
  };
777
772
  if (capability.delivery_email_required && model.workflow_entry?.capability_id !== capability.capability_id && String(deliveryContact.email ?? "").trim() === "") {
778
- 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 } });
779
774
  }
780
775
  const quote = await backend.prepareServiceQuote(serviceExecutionID, {
781
776
  capability_id: capabilityID,
@@ -840,27 +835,44 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
840
835
  status: nextState.status,
841
836
  result,
842
837
  instruction: nextState.instruction,
843
- next: nextState.next ? { command: nextState.next.command, reason: "继续当前首选动作" } : null,
844
- recovery: nextState.recovery,
838
+ next: nextState.next,
839
+ ...(nextState.interaction ? { interaction: nextState.interaction } : {}),
840
+ ...(nextState.communication ? { communication: nextState.communication } : {}),
841
+ ...(nextState.handoff ? { handoff: nextState.handoff } : {}),
842
+ recovery: nextState.recovery.filter(action => action.command !== `itpay services get ${serviceExecutionID} --json`),
845
843
  };
846
844
  writeCommandEnvelope(envelope, {
847
845
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
848
846
  ...(options.output ? { output: options.output } : {}),
849
- plainResult: [
850
- `service_execution_id: ${execution.service_execution_id}`,
851
- `service_id: ${execution.service_id}`,
852
- `state: ${execution.status}/${execution.phase}`,
853
- ...(execution.current_capability_id ? [`current_capability_id: ${execution.current_capability_id}`] : []),
854
- ...timeline.map((event) => `${event.sequence}. ${event.step} ${event.status}/${event.phase} ${event.occurred_at}`),
855
- ],
847
+ plainResult: plainValueLines(envelope.result),
856
848
  });
857
849
  }
850
+ function shouldWaitForServiceResult(model) {
851
+ if (model.current_delivery || model.rail_booking || isTerminalServiceExecutionStatus(model.execution.status))
852
+ return false;
853
+ if (model.rail_planning) {
854
+ const plan = model.rail_planning;
855
+ return !plan.recommendation && !(plan.alternatives?.length) &&
856
+ !["complete", "paused", "failed", "cancelled", "expired"].includes(plan.search?.expansion_status ?? "") &&
857
+ !plan.available_actions?.some((action) => ["select_journey", "confirm_location", "expand_search"].includes(action.type));
858
+ }
859
+ return ["queued", "running", "delivery"].includes(model.workflow?.status ?? "");
860
+ }
858
861
  export async function runServicesNext(backend, serviceExecutionID, options = {}) {
859
- const response = await backend.getServiceExecution(serviceExecutionID, options.sinceSnapshot ? { sinceSnapshot: options.sinceSnapshot } : {});
862
+ const started = Date.now();
863
+ const until = started + (options.timeoutSeconds ?? 0) * 1000;
864
+ const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
865
+ let response = await backend.getServiceExecution(serviceExecutionID, options.sinceSnapshot ? { sinceSnapshot: options.sinceSnapshot } : {});
866
+ while (shouldWaitForServiceResult(response) && Date.now() < until) {
867
+ await sleep(options.pollIntervalMS ?? Math.max(1500, response.rail_planning?.search?.poll_after_ms ?? 0));
868
+ response = await backend.getServiceExecution(serviceExecutionID, options.sinceSnapshot ? { sinceSnapshot: options.sinceSnapshot } : {});
869
+ }
860
870
  const envelope = servicesNextEnvelope(response);
871
+ if (shouldWaitForServiceResult(response) && options.timeoutSeconds) {
872
+ envelope.result = { ...envelope.result, waited_seconds: Math.round((Date.now() - started) / 1000), wait_timed_out: true };
873
+ envelope.next = { command: `itpay services next ${serviceExecutionID} --timeout 120 --json`, reason: "稍后继续等待同一任务;不重新提交查询" };
874
+ }
861
875
  journalTaskState(serviceExecutionID, envelope, options.env ?? process.env);
862
- if (response.rail_booking)
863
- envelope.result = { ...envelope.result, rail_booking: response.rail_booking };
864
876
  writeCommandEnvelope(envelope, {
865
877
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
866
878
  ...(options.output ? { output: options.output } : {}),
@@ -879,6 +891,33 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
879
891
  const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, resultItemID, offset, limit);
880
892
  const page = response.page;
881
893
  const container = (page.result ?? page);
894
+ const exactCandidate = limit === 1 && Array.isArray(container.candidates) && container.candidates.length === 1
895
+ ? container.candidates[0] : undefined;
896
+ if (exactCandidate && typeof exactCandidate.train_code === "string" && Array.isArray(exactCandidate.seats)) {
897
+ const offer = exactCandidate.booking_offer;
898
+ const { booking_offer: _bookingOffer, ...candidateFacts } = exactCandidate;
899
+ const seats = exactCandidate.seats.filter((seat) => seat.purchase_supported === true);
900
+ writeCommandEnvelope({
901
+ status: "exact_train_detail",
902
+ result: {
903
+ service_execution_id: serviceExecutionID, result_item_id: resultItemID, result_offset: offset,
904
+ candidate: candidateFacts,
905
+ ...(offer?.service_id && offer.selection_token ? { booking_template: {
906
+ command: `itpay services run ${offer.service_id} --input-json <file> --json`,
907
+ executable: false, required_input: ["file", "passengers", "seat_type"],
908
+ seat_choices: seats.map((choice) => ({ seat_type: choice.seat_type, seat_name: choice.seat_name })),
909
+ input_example: { selection: { token: offer.selection_token, seat_type: "<用户授权席别对应代码>" }, passengers: "<真实人数>" },
910
+ } } : {}),
911
+ },
912
+ instruction: offer?.selection_token
913
+ ? "这是已保存车次的当前详情;只按 purchase_supported 席别代码、库存与报价状态选择。购票模板的人数须按用户真实需求填写;查询价需在后续报价确认。若用户仅要求比较,答复后停止。"
914
+ : "这条车次当前没有可用的购买凭据;说明席别与余票事实,不要猜测代码或构造订单。",
915
+ next: null, recovery: [],
916
+ }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
917
+ ...(options.output ? { output: options.output } : {}),
918
+ });
919
+ return;
920
+ }
882
921
  // rail.progressive.v2 saved pages key journeys/journey_page; legacy result
883
922
  // items key candidates/catalog_page. Both are committed, read-only slices.
884
923
  const v2Page = (container.journey_page ?? {});
@@ -897,7 +936,9 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
897
936
  total: catalogPage.total ?? candidates.length,
898
937
  count: catalogPage.count ?? candidates.length,
899
938
  next_offset: nextOffset,
900
- 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 }),
901
942
  },
902
943
  instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。先读完所需页,再合并回答用户;不要逐页播报。",
903
944
  next: nextOffset !== null
@@ -913,11 +954,7 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
913
954
  writeCommandEnvelope(envelope, {
914
955
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
915
956
  ...(options.output ? { output: options.output } : {}),
916
- plainResult: candidates.map((candidate) => {
917
- const c = candidate;
918
- const title = typeof c.title === "string" ? c.title : JSON.stringify(c);
919
- return `${title}`;
920
- }),
957
+ plainResult: plainValueLines(envelope.result),
921
958
  });
922
959
  }
923
960
  export async function runServicesList(backend, options = {}) {
@@ -932,6 +969,7 @@ export async function runServicesList(backend, options = {}) {
932
969
  status: execution.status,
933
970
  phase: execution.phase,
934
971
  updated_at: execution.updated_at,
972
+ reader: { command: `itpay services next ${execution.service_execution_id} --json`, reason: "读取这笔已有服务" },
935
973
  }));
936
974
  const latest = executions[0];
937
975
  const envelope = {
@@ -940,7 +978,7 @@ export async function runServicesList(backend, options = {}) {
940
978
  instruction: executions.length === 1
941
979
  ? "只有一条可恢复记录;继续读取同一笔服务。"
942
980
  : latest
943
- ? "用服务和状态说明这些可恢复记录;多个结果必须让用户选择。"
981
+ ? "按原任务中已明确的执行ID读取对应reader;只有无法确定属于哪一笔任务时才询问用户。"
944
982
  : "当前设备没有可恢复的 Service Execution;先读取已发布目录,不要猜测 ID。",
945
983
  next: executions.length === 1
946
984
  ? { command: `itpay services next ${latest.service_execution_id} --json`, reason: "继续唯一可恢复的服务" }
@@ -952,55 +990,178 @@ export async function runServicesList(backend, options = {}) {
952
990
  writeCommandEnvelope(envelope, {
953
991
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
954
992
  ...(options.output ? { output: options.output } : {}),
955
- plainResult: executions.map((execution) => `${execution.service_execution_id}: ${execution.service_id} ${execution.status}/${execution.phase} updated=${execution.updated_at}`),
956
993
  });
957
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
+ }
958
1022
  export async function runServicesReadResult(backend, serviceExecutionID, options = {}) {
1023
+ if (!options.snapshot && !options.journey) {
1024
+ const model = await backend.getServiceExecution(serviceExecutionID);
1025
+ if (model.execution.service_id === "itpay-rail-exact") {
1026
+ const item = (model.current_result_items?.length ? model.current_result_items : model.result_items)?.[0];
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
+ }
1039
+ await runServicesNext(backend, serviceExecutionID, options);
1040
+ return;
1041
+ }
1042
+ const rows = [];
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;
1049
+ let total = 0;
1050
+ let resolvedStationPair;
1051
+ let nextOffset = startOffset;
1052
+ do {
1053
+ const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, item.service_capability_result_item_id, offset, limit);
1054
+ const container = (response.page.result ?? response.page);
1055
+ const candidates = Array.isArray(container.candidates) ? container.candidates : [];
1056
+ resolvedStationPair ??= container.resolved_station_pair;
1057
+ rows.push(...candidates.map((value, index) => exactTrainRow(value, serviceExecutionID, item.service_capability_result_item_id, offset + index)));
1058
+ const page = (container.catalog_page ?? {});
1059
+ total = typeof container.catalog_total === "number" ? container.catalog_total : Math.max(total, rows.length);
1060
+ nextOffset = typeof page.next_offset === "number" && page.next_offset > offset ? page.next_offset : null;
1061
+ offset = nextOffset ?? offset;
1062
+ } while (options.all && nextOffset !== null);
1063
+ writeSavedRailPage({
1064
+ status: "ready",
1065
+ result: { service_execution_id: serviceExecutionID, scope: "station_pair", resolved_station_pair: resolvedStationPair,
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) } : {}),
1069
+ trains: rows, ...(nextOffset !== null ? { next_offset: nextOffset } : {}) },
1070
+ instruction: "这是同一已保存站对的车次行。按用户时限、席别和人数筛选;零条仅代表这一站对。纯比较答复后结束;已有购票委托则执行所选行 detail.command 读取当前席别、库存和购买凭据。",
1071
+ next: nextOffset !== null ? { command: `itpay services read-result ${serviceExecutionID} --offset ${nextOffset} --limit ${limit} --json`, reason: "继续读取同一结果的剩余车次" } : null,
1072
+ recovery: [],
1073
+ }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1074
+ ...(options.output ? { output: options.output } : {}),
1075
+ all: Boolean(options.all),
1076
+ }, "trains", startOffset, next => ({ command: `itpay services read-result ${serviceExecutionID} --offset ${next} --limit ${limit} --json`, reason: "读取同一保存结果的下一页" }));
1077
+ return;
1078
+ }
1079
+ }
959
1080
  // rail.progressive.v2 planning read: --journey/--snapshot select committed
960
1081
  // planning evidence — a free owner-validated read that never touches the
961
1082
  // grant/Vault path. Without the selectors the authorized-delivery flow is
962
1083
  // unchanged.
963
- // rail.planning full catalog read: --snapshot alone returns the complete
964
- // committed rail.catalog.v3 — shared dictionaries and field_legend are
965
- // preserved verbatim; one catalog, never split into candidates+journeys.
1084
+ // rail.planning catalog read: --snapshot returns compact rows for the
1085
+ // committed rail.catalog.v3; journey detail remains available separately.
966
1086
  if (options.snapshot && !options.journey) {
967
- const committed = await backend.getRailPlanningCatalog(serviceExecutionID, options.snapshot);
968
- const catalog = committed.catalog;
969
- // The committed catalog may ship shared_rows.v1 positional rows — decode
970
- // the summary through the column legend; an unknown encoding is an
971
- // explicit upgrade error, never an empty catalog.
972
- const decoded = catalog
973
- ? decodeRailCatalogJourneys(catalog)
974
- : { journeys: [], packed: false };
975
- const journeys = decoded.journeys;
976
- const counts = (catalog?.counts ?? {});
977
- const journeyCount = journeys.length || Number(counts?.combinations ?? 0);
978
- 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({
979
1128
  status: "ready",
980
1129
  result: {
981
1130
  service_execution_id: serviceExecutionID,
982
- plan_id: committed.plan_id,
983
- snapshot_id: committed.snapshot_id,
984
- query_revision: committed.query_revision,
985
- catalog,
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,
1139
+ journeys,
986
1140
  },
987
- instruction: "catalog 是本次查询的完整已保存目录。按用户问题筛选全部journeys并一次回答;只有需要单程细节时才读取journey。价格和余票供比较,购买前须实时报价。",
988
- 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: "仅当前问题还需未读线路时继续;已有合格完整结果可答复或按委托继续" },
989
1147
  recovery: [],
990
1148
  }, {
991
1149
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
992
1150
  ...(options.output ? { output: options.output } : {}),
993
- plainResult: journeys.length > 0
994
- ? [
995
- `${journeyCount} combinations:`,
996
- ...journeys.map((j) => `${j.defaultLayer === "backup" ? "[备选] " : "[主选择] "}${j.ref} ${j.route}`),
997
- ]
998
- : [`catalog: ${committed.snapshot_id} (no combinations)`],
999
- });
1151
+ all: Boolean(options.all),
1152
+ }, "journeys", offset, next => ({ command: `itpay services read-result ${serviceExecutionID} --snapshot ${options.snapshot} --offset ${next} --limit ${limit} --json`, reason: "仅当前问题还需未读线路时继续;已有合格完整结果可答复或按委托继续" }));
1000
1153
  return;
1001
1154
  }
1002
1155
  if (options.journey) {
1003
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;
1004
1165
  writeCommandEnvelope({
1005
1166
  status: "ready",
1006
1167
  result: {
@@ -1008,17 +1169,22 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1008
1169
  plan_id: detail.plan_id,
1009
1170
  snapshot_id: detail.snapshot_id,
1010
1171
  query_revision: detail.query_revision,
1011
- 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")),
1012
1176
  },
1013
- instruction: "展示该 journey 的完整明细(车次、分段、席别报价、接驳估计与风险标注)。rail_payable 只是该行程当前可购报价的参考价,不是锁价;下单前须走受保护 Checkout 收集乘车人。",
1014
- next: detail.journey?.booking_support === "single_leg"
1015
- ? { command: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${detail.journey.journey_id} --json`, reason: "选定此行程" }
1016
- : { command: `itpay services next ${serviceExecutionID} --since-snapshot ${detail.snapshot_id} --json`, reason: "返回规划进展" },
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: "仅需要其他席别时读取;已满足用户条件则停止翻页" },
1179
+ ...(detail.journey?.booking_support === "single_leg" ? { interaction: {
1180
+ schema_version: "itpay.interaction.v1", stage: "query_results_ready",
1181
+ select_user_chosen: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${detail.journey.journey_id} --json`,
1182
+ select_delegated: `itpay services action ${serviceExecutionID} --action select_journey --actor-type agent --status approved --input journey_id=${detail.journey.journey_id} --input selection_mode=delegated --json`,
1183
+ } } : {}),
1017
1184
  recovery: [],
1018
1185
  }, {
1019
1186
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1020
1187
  ...(options.output ? { output: options.output } : {}),
1021
- plainResult: servicesNextPlainResult(detail.journey),
1022
1188
  });
1023
1189
  return;
1024
1190
  }
@@ -1038,6 +1204,43 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1038
1204
  plainResult: servicesNextPlainResult(envelope.result),
1039
1205
  });
1040
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
+ }
1225
+ function compactExactTrain(candidate) {
1226
+ const seats = Array.isArray(candidate.seats) ? candidate.seats : [];
1227
+ return {
1228
+ candidate_id: candidate.candidate_id,
1229
+ train_code: candidate.train_code,
1230
+ from_station: candidate.from_station,
1231
+ from_station_code: candidate.from_station_code,
1232
+ to_station: candidate.to_station,
1233
+ to_station_code: candidate.to_station_code,
1234
+ travel_date: candidate.travel_date,
1235
+ departure: candidate.departure,
1236
+ arrival: candidate.arrival,
1237
+ purchase_supported: candidate.purchase_supported ?? Boolean(candidate.booking_offer),
1238
+ seats: seats.map((seat) => ({ seat_type: seat.seat_type, seat_name: seat.seat_name,
1239
+ availability_text: seat.availability_text, remaining: seat.remaining,
1240
+ fare_minor: seat.fare_minor, quoted_total_minor: seat.quoted_total_minor,
1241
+ purchase_supported: seat.purchase_supported, requires_price_refresh: seat.requires_price_refresh })),
1242
+ };
1243
+ }
1041
1244
  function railLegSeatSummary(seat) {
1042
1245
  return {
1043
1246
  passenger_index: seat.passenger_index,
@@ -1071,15 +1274,14 @@ function railBookingEnvelope(model) {
1071
1274
  service_execution_id: execution.service_execution_id,
1072
1275
  ...(orderID ? { order_id: orderID } : {}),
1073
1276
  rail: { state: rail.state, issued_legs: rail.issued_legs, legs },
1074
- ...(model.workflow ? { workflow: model.workflow } : {}),
1075
1277
  };
1076
1278
  if (rail.state === "issued") {
1077
1279
  const detailsPending = rail.legs.some((leg) => leg.details_pending);
1078
1280
  return {
1079
1281
  status: "issued",
1080
- result,
1282
+ result: { ...result, ...(orderID ? { aftercare: { command: `itpay order ${orderID} --json`, when: "仅用户请求核对订单或售后时" } } : {}) },
1081
1283
  instruction: `告诉用户:车票已出票,座位以实际出票为准,平台不提供 12306 票号,可在订单页核对行程。不要在对话中索要乘车人身份信息。${detailsPending ? "部分席位信息未能同步,请前往 12306 核对行程;不要重复购买。" : ""}`,
1082
- next: orderID ? { command: `itpay order ${orderID} --json`, reason: "查看订单及退款入口" } : null,
1284
+ next: null,
1083
1285
  recovery: [],
1084
1286
  };
1085
1287
  }
@@ -1113,18 +1315,124 @@ function railBookingEnvelope(model) {
1113
1315
  recovery: [],
1114
1316
  };
1115
1317
  }
1116
- 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) {
1117
1417
  const rides = Array.isArray(card.rides) ? card.rides : [];
1118
1418
  const trains = rides.map((ride) => ride.train_code).filter(Boolean);
1119
1419
  const first = rides[0];
1120
1420
  const last = rides.at(-1);
1121
1421
  const offer = card.representative_offer;
1422
+ const metrics = card.representative_metrics;
1122
1423
  return {
1123
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 } : {}) }; })() : {}),
1124
1426
  route: (card.route_names?.length ? card.route_names : card.route ?? []).join("→"),
1125
1427
  ...(trains.length ? { trains } : {}),
1126
1428
  ...(first?.departure || last?.arrival ? { time: `${first?.departure ?? ""}–${last?.arrival ?? ""}` } : {}),
1127
1429
  ...(offer ? { price: formatMoney(offer.rail_payable_minor, offer.currency) } : { price: "unknown" }),
1430
+ ...(metrics ? { calculation_scope: {
1431
+ origin: metrics.origin_scope ?? "legacy_unknown", destination: metrics.destination_scope ?? "legacy_unknown",
1432
+ duration_basis: metrics.duration_basis ?? "legacy_unknown", arrival_basis: metrics.arrival_basis ?? "legacy_unknown",
1433
+ cost_basis: metrics.cost_basis ?? "legacy_unknown",
1434
+ duration_minutes: metrics.door_to_door_min ?? null,
1435
+ } } : {}),
1128
1436
  ...(card.decision_role ? { decision_role: card.decision_role } : {}),
1129
1437
  ...(card.explanation?.length ? { explanation: card.explanation } : {}),
1130
1438
  ...(card.reason_codes?.length ? { reason_codes: card.reason_codes } : {}),
@@ -1137,35 +1445,12 @@ function railJourneySummary(card, serviceExecutionID) {
1137
1445
  ...(card.risk_notes?.length ? { risk_notes: card.risk_notes } : {}),
1138
1446
  ...(card.booking_support === "single_leg"
1139
1447
  ? {
1140
- select: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${card.journey_id} --json`,
1448
+ select_user_chosen: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${card.journey_id} --json`,
1141
1449
  // Delegated selection under explicit user rules is an agent action —
1142
1450
  // never record it as a human pick.
1143
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`,
1144
1452
  }
1145
1453
  : {}),
1146
- ...(card.booking_offer
1147
- ? {
1148
- booking_offer: card.booking_offer,
1149
- booking_template: {
1150
- command: `itpay services run ${card.booking_offer.service_id} --input-json <file> --json`,
1151
- required_input: ["file"],
1152
- executable: false,
1153
- seat_choices: (card.representative_offer?.ticket_offers ?? []).map((offer) => ({
1154
- seat_type: offer.seat_type,
1155
- seat_name: offer.seat_name,
1156
- })).filter((seat) => typeof seat.seat_type === "string"),
1157
- input_example: {
1158
- selection: {
1159
- token: card.booking_offer.selection_token,
1160
- ...(typeof card.representative_offer?.ticket_offers?.[0]?.seat_type === "string"
1161
- ? { seat_type: card.representative_offer.ticket_offers[0].seat_type }
1162
- : {}),
1163
- },
1164
- passengers: card.passengers ?? 1,
1165
- },
1166
- },
1167
- }
1168
- : {}),
1169
1454
  };
1170
1455
  }
1171
1456
  // rail.progressive.v2 read projection: committed snapshots only; polling never
@@ -1177,11 +1462,19 @@ function railPlanningEnvelope(model) {
1177
1462
  const expansion = plan.search?.expansion_status ?? "running";
1178
1463
  const counts = plan.search?.counts;
1179
1464
  const journeys = [plan.recommendation, ...(plan.alternatives ?? [])].filter(Boolean);
1180
- 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));
1181
1474
  // Journey counts come from the committed catalog — real combination counts,
1182
1475
  // never seat-row counts. Transfer buckets follow the verified transfer_count.
1183
1476
  const journeyMix = counts && (counts.journeys_total ?? 0) > 0
1184
- ? `${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};这些不是不同站序的数量)`
1185
1478
  : "";
1186
1479
  const pairProgress = counts && (counts.pairs_total ?? 0) > 0
1187
1480
  ? `已检查${counts.pairs_checked ?? 0}/${counts.pairs_total}个附近站对`
@@ -1201,6 +1494,7 @@ function railPlanningEnvelope(model) {
1201
1494
  rail_planning: {
1202
1495
  readiness: plan.readiness,
1203
1496
  query_revision: plan.query_revision,
1497
+ ...(plan.query_input ? { endpoints: plan.query_input.endpoints } : {}),
1204
1498
  snapshot_id: plan.snapshot_id,
1205
1499
  expansion_status: expansion,
1206
1500
  ...(plan.search?.phase ? { phase: plan.search.phase } : {}),
@@ -1214,83 +1508,57 @@ function railPlanningEnvelope(model) {
1214
1508
  ...(plan.search?.poll_after_ms ? { poll_after_ms: plan.search.poll_after_ms } : {}),
1215
1509
  ...(plan.result_not_updated ? { result_not_updated: true } : {}),
1216
1510
  ...(counts ? { counts } : {}),
1217
- ...(plan.budgets ? { budgets: plan.budgets } : {}),
1218
1511
  ...(plan.coverage ? { coverage: plan.coverage } : {}),
1219
1512
  },
1220
1513
  ...(plan.notices?.length ? { notices: plan.notices } : {}),
1221
- ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se) } : {}),
1514
+ ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se, plan.catalog_snapshot_id ?? plan.snapshot_id) } : {}),
1222
1515
  ...(cards.length ? { journeys: cards } : {}),
1223
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
+ } } : {}),
1224
1523
  ...(plan.available_actions?.length ? { available_actions: plan.available_actions } : {}),
1225
1524
  };
1226
1525
  const nextPoll = {
1227
- command: `itpay services next ${se}${plan.snapshot_id ? ` --since-snapshot ${plan.snapshot_id}` : ""} --json`,
1228
- reason: "读取同一规划的增量进展(不触发供应商调用)",
1526
+ command: `itpay services next ${se} --timeout 120${plan.snapshot_id ? ` --since-snapshot ${plan.snapshot_id}` : ""} --json`,
1527
+ reason: "仍需更多结果时有界等待同一规划(不触发供应商调用)",
1229
1528
  };
1230
- // Two-goal interaction contract (spec 03 §2): the local Agent picks compare
1231
- // or prepare_checkout from full context — the envelope teaches both branches
1232
- // so a delegated purchase keeps moving and a comparison never buys.
1233
- const bookable = journeys.find((card) => card.booking_support === "single_leg" && card.booking_offer);
1234
- const queryResultsInteraction = journeys.length > 0
1235
- ? {
1236
- schema_version: "itpay.interaction.v1",
1237
- stage: "query_results_ready",
1238
- by_goal: {
1239
- compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
1240
- prepare_checkout: {
1241
- action: "select_under_user_rules_then_prepare",
1242
- recipe: "rail.selected-to-checkout.v1",
1243
- requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
1244
- ...(bookable
1245
- ? {
1246
- steps: [
1247
- "按用户明确规则在 journeys 中选择合格者(卡片 select_delegated 提交 agent 委托选择;人类亲自选择用 select)",
1248
- `用所选卡片的 booking_template 写 owner-only 临时输入(selection token + 席别 + 人数,不含身份信息),运行 itpay services run itpay-rail-booking --input-json <file> --json`,
1249
- "owner 要求聊天 review 时一次合并补齐必要字段,真实确认后提交原 workflow:confirm_booking;不得伪造 accept",
1250
- `确认后恢复同一购买执行:itpay services run itpay-rail-booking --execution <实际ID> --json,沿用有界等待与受保护 Checkout`,
1251
- ],
1252
- }
1253
- : { unavailable: "当前结果无可整段购买映射;分别说明各段风险,不得合成原子联程。" }),
1254
- },
1255
- },
1256
- }
1257
- : undefined;
1258
- const queryResultsCommunication = journeys.length > 0
1259
- ? {
1260
- schema_version: "itpay.communication.v1",
1261
- status_line: expansion === "paused"
1262
- ? "首批结果已就绪,扩展搜索已暂停等待用户决定"
1263
- : "查询结果就绪",
1264
- recommended_reason: plan.recommendation?.explanation?.[0],
1265
- human_steps: [
1266
- "compare:比较首选与最多两个备选后告知取舍",
1267
- "prepare_checkout:委托仍在时确认所选,继续官方确认页",
1268
- "姓名、证件、手机号只在受保护 Checkout 页填写",
1269
- ],
1270
- next_expectation: "compare:用户选定其一;prepare_checkout:委托仍在时继续到官方确认入口,人完成review与付款",
1271
- must_convey: [
1272
- "推荐与备选的时间/费用/便利性取舍",
1273
- "金额为查询时参考价,下单前须核验",
1274
- "姓名、证件、手机号只在受保护网页填写",
1275
- ],
1276
- }
1277
- : undefined;
1278
1529
  switch (expansion) {
1279
1530
  case "complete":
1531
+ if (plan.search?.reason === "ground_evidence_required") {
1532
+ return {
1533
+ status: "awaiting_input", result,
1534
+ instruction: "已查到铁路班次,但具体地址的接驳或出站证据不足,无法验证到达时限;剩余站对未继续查询,不能说没有车。向用户说明已查范围,并请其补充准确起终点地址或可验证的接驳/出站信息;取得新事实后再按其要求重新规划,不要默认扩大铁路搜索。",
1535
+ next: null, recovery: [],
1536
+ };
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
+ }
1280
1545
  return {
1281
1546
  status: "ready",
1282
1547
  result,
1283
1548
  // §7.3: the lead line carries real combination counts bucketed by
1284
1549
  // verified transfer count — seat rows never inflate the journey count.
1285
- instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}推荐和journeys是摘要;追问其他车次先读取 full_result 的已保存完整目录。compare:解释首选及最多两个有意义备选的时间/费用/便利性取舍后等待用户选择。prepare_checkout:用户已明确委托按规则选择时,按 interaction.recipe 选合格者并继续到官方确认页,不再次问是否下单;身份、review和最终付款边界仍然生效。乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1286
- next: journeys[0]?.booking_support === "single_leg"
1287
- ? { command: railJourneySummary(journeys[0], se).select, reason: "用户在 compare 后选定推荐行程时执行;prepare_checkout 且规则命中时用 select_delegated" }
1288
- : null,
1289
- ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1290
- ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
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 的已保存完整目录。比较需求说明首选与备选取舍后结束;已有明确代选委托则沿合格线路详情继续,只补缺失的真实同意,身份信息仍在官方页面填写。`,
1551
+ next: null,
1291
1552
  recovery: [],
1292
1553
  };
1293
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
+ }
1294
1562
  // §7.1 case 1: usable directs committed while ranked scope remains. The
1295
1563
  // message must carry real counts, and expansion is a user choice — the
1296
1564
  // Agent must not treat "expandable" as consent already given.
@@ -1313,7 +1581,7 @@ function railPlanningEnvelope(model) {
1313
1581
  // §7.1 case 3 on a recoverable pause: unverified scope is named with real
1314
1582
  // counts, and the phrasing never implies the whole nearby range ran.
1315
1583
  const partialPause = !directHint && counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1316
- ? `目前找到${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)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1317
1585
  : "";
1318
1586
  // §S5: a dispatch whose outcome is unknown pauses for reconciliation —
1319
1587
  // the message names the state, never rephrases it as a normal pause or
@@ -1331,21 +1599,29 @@ function railPlanningEnvelope(model) {
1331
1599
  return {
1332
1600
  status: "awaiting_input",
1333
1601
  result,
1334
- 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 为空表示向用户汇报并等待,不是永远没有更多。比较需求说明首选与备选取舍后结束;已有代选委托且选项合格时沿详情继续到必要确认与官方入口。`,
1335
1603
  next: null,
1336
- ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1337
- ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1338
1604
  recovery: [],
1339
1605
  };
1340
1606
  }
1341
1607
  case "failed":
1342
1608
  case "cancelled":
1343
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
+ }
1344
1620
  // §7.1 case 3: when scope was only partially verified the honest wording
1345
1621
  // is "N confirmed + X pairs unverified" — never the case-2 phrasing that
1346
1622
  // implies the whole nearby range was checked.
1347
1623
  const partial = counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1348
- ? `目前找到${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)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1349
1625
  : "";
1350
1626
  // §S5: a processing failure with a committed catalog is "saved N
1351
1627
  // verified combinations, later expansion/recommend incomplete" — the
@@ -1383,17 +1659,6 @@ function railPlanningEnvelope(model) {
1383
1659
  result,
1384
1660
  instruction: `${modelDegradedHint}${manualExpansion}${transferHint}${failedPairsHint}规划进行中:已提交的卡片可先向用户展示比较;稍后按 next 增量轮询同一执行(since-snapshot 命中时负载会被压缩,但阶段与计数仍返回最新值)。不要重新发起查询。`,
1385
1661
  next: nextPoll,
1386
- communication: {
1387
- schema_version: "itpay.communication.v1",
1388
- status_line: transferSearching && !manualExpansion
1389
- ? "直达范围暂未找到可用方案,中转组合仍在搜索"
1390
- : "规划进行中,已确认的方案可先向用户展示",
1391
- next_expectation: "按 next 增量轮询同一执行;不重新发起查询",
1392
- must_convey: [
1393
- "当前是进行中的部分结果,不代表最终答复",
1394
- ...(failedPairsHint ? ["部分站对尚未查明,不能说'没有车'"] : []),
1395
- ],
1396
- },
1397
1662
  recovery: [],
1398
1663
  };
1399
1664
  }
@@ -1443,26 +1708,9 @@ function servicesNextEnvelope(model) {
1443
1708
  const currentDelivery = model.current_delivery ?? model.delivery_bindings.at(-1);
1444
1709
  const lockedRefund = model.refunds.find((refund) => refund.access_locked);
1445
1710
  if (lockedRefund) {
1446
- const terminal = lockedRefund.status === "succeeded";
1447
- return {
1448
- status: "delivery_locked",
1449
- result: {
1450
- service_execution_id: execution.service_execution_id,
1451
- access_locked: true,
1452
- refund: {
1453
- refund_request_id: lockedRefund.refund_request_id,
1454
- status: lockedRefund.status,
1455
- },
1456
- },
1457
- instruction: terminal
1458
- ? "告诉用户退款已由 ItPay 确认成功,原交付永久关闭。Agent 停止读取和跟踪,不再创建授权。"
1459
- : "告诉用户退款仍在处理,原交付已按政策冻结。然后读取同一退款的权威状态;Agent 不读取交付、不创建授权或重复申请。",
1460
- next: terminal ? null : {
1461
- command: `itpay refund get ${lockedRefund.refund_request_id} --json`,
1462
- reason: "读取退款权威状态",
1463
- },
1464
- recovery: [],
1465
- };
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 };
1466
1714
  }
1467
1715
  const latestRun = model.execution_requests?.filter((request) => request.execution_kind === "service.execution.run").at(-1);
1468
1716
  if (latestRun && ["pending", "started"].includes(latestRun.status)) {
@@ -1497,10 +1745,48 @@ function servicesNextEnvelope(model) {
1497
1745
  }
1498
1746
  if (model.workflow_entry && !["completed", "delivery"].includes(model.workflow?.status ?? "")) {
1499
1747
  const id = execution.service_execution_id;
1748
+ const workflowReason = model.workflow?.failure?.reason_code ?? model.workflow?.error_code;
1500
1749
  const paymentVerified = model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
1501
1750
  const state = model.workflow?.status === "payment" && paymentVerified ? "running" : model.workflow?.status ?? "input_required";
1502
- if (state === "failed" && ["login_required", "rate_limited"].includes(model.workflow?.error_code ?? "")) {
1503
- 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";
1504
1790
  return {
1505
1791
  status: login ? "login_required" : "rate_limited",
1506
1792
  result: { service_execution_id: id, service_id: execution.service_id },
@@ -1538,29 +1824,42 @@ function servicesNextEnvelope(model) {
1538
1824
  return {
1539
1825
  side,
1540
1826
  status: place?.resolution_status,
1827
+ ...(place?.resolution_reason ? { reason: place.resolution_reason } : {}),
1541
1828
  ...(place?.resolution_status === "resolved"
1542
- ? { resolved_place: { name: name(place), location: place.location } }
1829
+ ? { resolved_place: { name: name(place), address: place.formatted_address, location: place.location } }
1543
1830
  : {}),
1544
1831
  ...(candidates.length
1545
- ? { 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 })) }
1546
1833
  : {}),
1547
1834
  };
1548
1835
  });
1549
1836
  return {
1550
1837
  status: "confirmation_required",
1551
- result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1552
- required_fields: requiredFields, sides },
1553
- 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 填写真实选择,已解析一端保留原值。继续同一执行;这不是购买确认。",
1554
1850
  next: null,
1555
- interaction: {
1556
- schema_version: "itpay.interaction.v1",
1557
- stage: "location_confirmation_required",
1558
- input_template: {
1559
- command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`,
1560
- required_input: requiredFields,
1561
- executable: false,
1562
- },
1563
- },
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
+ } }),
1564
1863
  recovery: [],
1565
1864
  };
1566
1865
  }
@@ -1576,24 +1875,22 @@ function servicesNextEnvelope(model) {
1576
1875
  : undefined;
1577
1876
  return {
1578
1877
  status: "booking_review_required",
1579
- result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1580
- required_fields: requiredFields,
1878
+ result: { service_execution_id: id, service_id: execution.service_id, review,
1879
+ action_type: action.action_type, required_fields: requiredFields,
1581
1880
  ...(requirementsRemaining ? { requirements_remaining: requirementsRemaining } : {}) },
1582
1881
  ...(reviewURL ? { handoff: { url: reviewURL, kind: "booking_review" } } : {}),
1583
- instruction: reviewURL
1584
- ? "已在 handoff.url 提供官方行程确认页,请用户在该页核对行程、席别与座位偏好并确认;页面打开请求不代表用户已确认。该页失效或用户更愿在对话中确认时,仍可按下述 input_template 一次合并提交。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。"
1585
- : "向用户展示 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 页填写。`,
1586
1883
  next: null,
1587
1884
  interaction: {
1588
1885
  schema_version: "itpay.interaction.v1",
1589
1886
  stage: "booking_review_required",
1590
1887
  input_template: {
1591
1888
  command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <file> --json`,
1592
- required_input: requiredFields.length > 0 ? requiredFields : ["file"],
1889
+ required_input: ["file", ...requiredFields],
1593
1890
  executable: false,
1594
1891
  },
1595
1892
  },
1596
- recovery: [{ command: `itpay services next ${id} --json`, reason: "重新读取当前 draft_revision 后重试" }],
1893
+ recovery: [],
1597
1894
  };
1598
1895
  }
1599
1896
  return {
@@ -1626,8 +1923,8 @@ function servicesNextEnvelope(model) {
1626
1923
  executable: false,
1627
1924
  }
1628
1925
  : undefined;
1629
- const guidance = railServiceGuidance(execution.service_id);
1630
- 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;
1631
1928
  const failedStepGuidance = failedStep ? WORKFLOW_STEP_GUIDANCE[failedStep] : undefined;
1632
1929
  const failedInvocation = recovery
1633
1930
  ? [...model.provider_invocations].reverse().find((item) => typeof item.status === "string" && item.status.startsWith("failed"))
@@ -1640,13 +1937,14 @@ function servicesNextEnvelope(model) {
1640
1937
  quote_seat_unavailable: "所选席别当前无足够余票;告知用户并由其决定是否换方案。",
1641
1938
  quote_refresh_expired: "实时报价过程超时;保留原选择,不把旧价当可付款价格。",
1642
1939
  }[providerErrorCode ?? ""] : undefined;
1940
+ const failureCode = model.workflow?.failure?.reason_code ?? providerErrorCode ?? model.workflow?.error_code;
1643
1941
  const failedInstruction = recovery
1644
1942
  ? quoteFailure
1645
1943
  ? quoteFailure
1646
1944
  : failedStepGuidance
1647
- ? `执行在「${failedStepGuidance.meaning}」步失败:${failedStepGuidance.hint}。本执行已终止不能续用;先核对原因,不要盲目重放。`
1945
+ ? `执行在「${failedStepGuidance.meaning}」步失败${failureCode ? `(错误码:${failureCode})` : ""}:${failedStepGuidance.hint}。先核对原因和当前执行状态,不要盲目重放。`
1648
1946
  : state === "failed"
1649
- ? `执行已失败${failedStep ? `(失败步骤:${failedStep})` : ""}且不可续用;修正输入后用同一服务新建执行重试。`
1947
+ ? `执行已失败${failureCode ? `(错误码:${failureCode})` : ""}且不可续用;核对服务端原因、订单与付款事实后再决定下一步。`
1650
1948
  : "执行未完成,请按步骤错误处理;不要重建执行或重复调用。"
1651
1949
  : undefined;
1652
1950
  return {
@@ -1654,13 +1952,15 @@ function servicesNextEnvelope(model) {
1654
1952
  result: {
1655
1953
  service_execution_id: id,
1656
1954
  service_id: execution.service_id,
1657
- 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 } : {}),
1658
1958
  ...(state === "input_required" ? { input_schema: model.workflow_entry.input_schema } : {}),
1659
1959
  ...(guidance && state === "input_required" ? { guidance } : {}),
1660
1960
  ...(failedStep ? { failed_step: failedStep, ...(failedStepGuidance ? { failed_step_meaning: failedStepGuidance.meaning } : {}) } : {}),
1661
1961
  ...(providerErrorCode ? { provider_error_code: providerErrorCode } : {}),
1662
1962
  ...(providerErrorCode && !quoteFailure ? { diagnostic_code: providerErrorCode } : {}),
1663
- ...(recovery ? { retryable: state === "failed" } : {}),
1963
+ ...(recovery ? { retryable: false } : {}),
1664
1964
  },
1665
1965
  instruction: failedInstruction
1666
1966
  ?? (state === "payment"
@@ -1670,7 +1970,7 @@ function servicesNextEnvelope(model) {
1670
1970
  : guidance
1671
1971
  ? "按 result.guidance 的字段契约填写输入后继续同一服务执行;不要臆造字段名。"
1672
1972
  : "继续读取同一执行;缺少输入时按服务声明补齐。"),
1673
- next: recovery ? null : { command, reason: "继续当前流程" },
1973
+ next: recovery || inputTemplate ? null : { command, reason: "继续当前流程" },
1674
1974
  ...(inputTemplate || (state === "payment" && model.workflow?.human_action?.context?.review) ? {
1675
1975
  interaction: {
1676
1976
  schema_version: "itpay.interaction.v1",
@@ -1685,11 +1985,8 @@ function servicesNextEnvelope(model) {
1685
1985
  } : {}),
1686
1986
  },
1687
1987
  } : {}),
1688
- recovery: recovery && state === "failed" && !quoteFailure
1689
- ? [
1690
- { command: `itpay services start ${execution.service_id} --json`, reason: "修正输入后重新发起(本执行已终止不能续用)" },
1691
- ...(guidance ? [{ command: "itpay docs show rail-booking --json", reason: "查看本服务输入字段契约与示例" }] : []),
1692
- ]
1988
+ recovery: recovery && state === "failed"
1989
+ ? [{ command: `itpay services get ${id} --json`, reason: "核对本执行的错误、结果和付款事实" }]
1693
1990
  : [],
1694
1991
  };
1695
1992
  }
@@ -1706,6 +2003,26 @@ function servicesNextEnvelope(model) {
1706
2003
  }
1707
2004
  const delivery = currentDelivery;
1708
2005
  const deliveryMode = serviceDeliveryMode(model);
2006
+ if (execution.service_id === "itpay-rail-exact" && currentItems.length > 0) {
2007
+ const item = currentItems[0];
2008
+ const container = (item.safe_payload?.result ?? item.safe_payload);
2009
+ const total = typeof container?.catalog_total === "number" ? container.catalog_total :
2010
+ Array.isArray(container?.candidates) ? container.candidates.length : 0;
2011
+ return {
2012
+ status: total > 0 ? "result_ready" : "no_result",
2013
+ result: { service_execution_id: execution.service_execution_id, scope: "station_pair",
2014
+ resolved_station_pair: container?.resolved_station_pair, total,
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) } : {}) },
2019
+ instruction: total > 0
2020
+ ? "直达站对结果已保存。执行 next.command 一次读取紧凑车次行,按用户条件筛选;若只需比较,答复后结束;已有购票委托则读取所选行详情。"
2021
+ : "这一已解析站对返回 0 条;不代表全城无车。若原需求需要更广覆盖,可核验另一可信站对或 Smart。",
2022
+ next: { command: `itpay services read-result ${execution.service_execution_id} --json`, reason: "读取这一保存站对的紧凑分页;按返回的续页覆盖所需范围" },
2023
+ recovery: [],
2024
+ };
2025
+ }
1709
2026
  const candidateSelection = model.allowed_actions?.find((action) => action.type === "select_candidate");
1710
2027
  if (candidateSelection && currentItems.length > 0) {
1711
2028
  const paidCapability = delivery?.capability_id
@@ -1918,21 +2235,7 @@ function grantedResultEnvelope(response, orderID) {
1918
2235
  };
1919
2236
  }
1920
2237
  function servicesNextPlainResult(result) {
1921
- const lines = [];
1922
- for (const [key, value] of Object.entries(result)) {
1923
- if (key === "items" && Array.isArray(value)) {
1924
- lines.push("items:");
1925
- for (const item of value) {
1926
- lines.push(` ${item.rank}. ${item.title}`);
1927
- for (const [field, fieldValue] of Object.entries(item.safe_payload)) {
1928
- lines.push(` ${field}: ${typeof fieldValue === "string" ? fieldValue : JSON.stringify(fieldValue)}`);
1929
- }
1930
- }
1931
- continue;
1932
- }
1933
- lines.push(`${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
1934
- }
1935
- return lines;
2238
+ return plainValueLines(result);
1936
2239
  }
1937
2240
  export async function runServicesEvents(backend, serviceExecutionID, options = {}) {
1938
2241
  const afterSequence = options.afterSequence ?? 0;
@@ -1963,11 +2266,8 @@ export async function runServicesEvents(backend, serviceExecutionID, options = {
1963
2266
  returned_count: events.length,
1964
2267
  events,
1965
2268
  },
1966
- instruction: "事件仅用于诊断;不要从事件重放业务步骤,回到 services next 获取当前动作。",
1967
- next: {
1968
- command: `itpay services next ${serviceExecutionID} --json`,
1969
- reason: "恢复正常服务流程",
1970
- },
2269
+ instruction: "这是已保存的诊断事件;事件不代表车票结果,也不授权重放业务步骤。已读完所需诊断时停止;实际任务沿先前返回的当前动作继续。",
2270
+ next: null,
1971
2271
  recovery: events.length === limit && events.length > 0
1972
2272
  ? [{
1973
2273
  command: `itpay services events ${serviceExecutionID} --after-sequence ${events.at(-1).sequence} --limit ${limit} --json`,
@@ -2043,7 +2343,7 @@ function buildServicesCheckoutEnvelope(response, checkoutURL, plan, agentType, t
2043
2343
  service_execution_id: response.binding.service_execution_id,
2044
2344
  checkout_id: checkout.checkout.checkout_id,
2045
2345
  capability_id: checkoutCapabilityID(response),
2046
- locked_input: response.locked_input,
2346
+ items: plan.orderItems ?? [],
2047
2347
  amount,
2048
2348
  ...(checkout.checkout.expires_at ? {
2049
2349
  // The shared server-owned payable deadline D: refreshing the page or
@@ -2141,10 +2441,7 @@ export async function runServicesRun(backend, config, serviceID, input, options
2141
2441
  const paid = () => model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
2142
2442
  // A planning projection can exist before any usable result or user action.
2143
2443
  // Keep the existing bounded wait until there is something to hand over.
2144
- const planningDeliverable = () => Boolean(model.rail_planning && (model.rail_planning.recommendation || model.rail_planning.alternatives?.length ||
2145
- ["complete", "paused", "failed", "cancelled", "expired"].includes(model.rail_planning.search?.expansion_status ?? "") ||
2146
- model.rail_planning.available_actions?.some((action) => ["select_journey", "expand_search"].includes(action.type))));
2147
- while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && !planningDeliverable() && Date.now() < until) {
2444
+ while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && shouldWaitForServiceResult(model) && Date.now() < until) {
2148
2445
  await sleep(options.pollIntervalMS ?? 1500);
2149
2446
  model = await backend.getServiceExecution(id);
2150
2447
  }
@@ -2168,11 +2465,11 @@ export async function runServicesRun(backend, config, serviceID, input, options
2168
2465
  }
2169
2466
  if (cause instanceof CommandContractError || cause instanceof HttpError || cause instanceof DeviceLockBusyError || cause instanceof DeviceStateError)
2170
2467
  throw cause;
2171
- if (cause instanceof HttpTransportError && !id) {
2172
- 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: "查找可能已经创建的服务执行" }]);
2173
2470
  }
2174
2471
  if (cause instanceof HttpTransportError)
2175
- throw cause;
2176
- 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: "读取同一执行的当前事实与恢复动作" }]);
2177
2474
  }
2178
2475
  }