@itpay/cli 2.1.22 → 2.1.33

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 (38) hide show
  1. package/README.md +2 -2
  2. package/dist/src/client/backend.js +4 -1
  3. package/dist/src/client/http.js +2 -0
  4. package/dist/src/commands/buy.js +6 -4
  5. package/dist/src/commands/cart.js +15 -15
  6. package/dist/src/commands/catalog.js +10 -8
  7. package/dist/src/commands/checkout.js +9 -11
  8. package/dist/src/commands/checkout_handoff.js +7 -4
  9. package/dist/src/commands/docs.js +3 -14
  10. package/dist/src/commands/guidance.js +43 -25
  11. package/dist/src/commands/install.js +2 -2
  12. package/dist/src/commands/order.js +4 -3
  13. package/dist/src/commands/orders.js +4 -9
  14. package/dist/src/commands/pay.js +1 -1
  15. package/dist/src/commands/refund.js +5 -5
  16. package/dist/src/commands/services.js +628 -390
  17. package/dist/src/commands/vault.js +27 -20
  18. package/dist/src/main.js +35 -24
  19. package/dist/src/render/qr.js +1 -1
  20. package/dist/src/state/account_auth.js +9 -6
  21. package/dist/src/state/config.js +4 -4
  22. package/dist/src/state/device_authority.js +2 -2
  23. package/docs/agent/buyer/cart-checkout.json +1 -1
  24. package/docs/agent/buyer/catalog-list.json +1 -1
  25. package/docs/agent/buyer/identity-and-sessions.json +3 -7
  26. package/docs/agent/buyer/orders-refunds.json +8 -1
  27. package/docs/agent/buyer/payment-flow.json +1 -0
  28. package/docs/agent/buyer/purchased-content.json +1 -1
  29. package/docs/agent/buyer/quickstart.json +6 -5
  30. package/docs/agent/buyer/rail-booking.json +96 -51
  31. package/docs/agent/buyer/rail-fast-checkout.json +6 -3
  32. package/docs/agent/buyer/render-hosts.json +1 -1
  33. package/docs/cli-reference/commands/device.md +4 -5
  34. package/docs/cli-reference/commands/services/invoke.md +2 -2
  35. package/docs/cli-reference/commands/services/read-result.md +12 -5
  36. package/docs/cli-reference/commands/services/run.md +2 -0
  37. package/package.json +5 -1
  38. package/skills/itpay/SKILL.md +25 -16
@@ -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
@@ -33,26 +34,27 @@ function journalTaskState(serviceExecutionID, envelope, env) {
33
34
  }
34
35
  }
35
36
  const serviceActionStatuses = new Set(["pending", "approved", "rejected", "expired", "cancelled"]);
37
+ const RAIL_ENDPOINT_BRIEF = "查询前先留下2–4行端点理解摘要:两端各指哪里、是车站/行政范围/具体地点、依据及未确定事实,再说明日期/人数/时限与Exact/Smart选择。对话或现有route-brief.txt二选一;已有摘要沿用,只更新变化项。含义不清先澄清,定位待核验不等于含义不清;勿凭字尾猜类型。";
36
38
  const RAIL_SERVICE_GUIDANCE = {
37
39
  "itpay-rail-exact": {
38
- when_to_use: "已知可信站对时先用本服务核验直达;城市需求可先形成有依据的站对假设。只覆盖这一站对,不代表全城市最优。完整流程:itpay docs show rail-booking --json。",
40
+ when_to_use: "已知可信站对时先用本服务核验直达;城市需求可先形成有依据的站对假设。只覆盖这一站对,不代表全城市最优。只有明确可靠路线知识可直接用;大概率判断先短查,受阻且清晰双站可Exact核验。查询前记录两端含义、类别、依据、未知事实和Exact/Smart选择;格式见已读rail-booking。",
39
41
  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" },
42
+ { name: "origin", required: true, description: "已核实出发铁路站完整名称,不能以同名站代替区域", example: "<已核实出发站>" },
43
+ { name: "destination", required: true, description: "已核实到达铁路站完整名称", example: "<已核实到达站>" },
44
+ { name: "travel_date", required: true, description: "出行日期 YYYY-MM-DD;字段名必须是 travel_date,'date' 等别名无效", example: "<用户日期 YYYY-MM-DD>" },
43
45
  ],
44
- input_example: { origin: "古镇", destination: "广州南", travel_date: "2026-09-19" },
46
+ input_example: { origin: "<已核实出发站名>", destination: "<已核实到达站名>", travel_date: "<用户出行日期 YYYY-MM-DD>" },
45
47
  notes: [
46
48
  "本服务只接受这三个字段,多传字段会被供应商拒绝",
47
- "查询为空只说明这一已解析站对;如原意仍需更多覆盖,可验证另一可信站对或使用 Smart",
49
+ "查询为空只说明这一已解析站对;允许中转可用Smart保持双站;改变用户指定站须先取得同意",
48
50
  ],
49
51
  },
50
52
  "itpay-rail-smart": {
51
- when_to_use: "需要广泛站点比较、中转或具体地址接驳时使用;有可信站对且只需先核验直达可用 Exact。完整流程:itpay docs show rail-booking --json。",
53
+ when_to_use: "需要广泛站点比较、中转或具体地址接驳时使用;有可信站对且只需先核验直达可用 Exact。只有明确可靠路线知识可直接用;大概率判断先短查,受阻且清晰双站可Exact核验。查询前记录两端含义、类别、依据、未知事实和Exact/Smart选择;格式见已读rail-booking。",
52
54
  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" },
55
+ { name: "origin", required: true, description: "出发地:已知最完整的位置名或区域(城市/区县/地址均可)", example: "<原始完整地点>" },
56
+ { name: "destination", required: true, description: "目的地:与 origin 同样的位置规则", example: "<已核实到达站>" },
57
+ { name: "travel_date", required: true, description: "出行日期 YYYY-MM-DD;字段名必须是 travel_date", example: "<用户日期 YYYY-MM-DD>" },
56
58
  ],
57
59
  optional_fields: [
58
60
  { name: "origin_city", description: "出发地城市提示,辅助位置解析" },
@@ -60,20 +62,84 @@ const RAIL_SERVICE_GUIDANCE = {
60
62
  { name: "origin_location", description: "已知坐标对象(高德 GCJ-02)" },
61
63
  { name: "destination_location", description: "目的地坐标对象" },
62
64
  { name: "depart_after", description: "不早于该时间出发" },
65
+ { name: "depart_before", description: "不晚于该时间出发" },
63
66
  { name: "arrive_before", description: "不晚于该时间到达" },
67
+ { name: "user_query", description: "旅行需求原话,最多4096 UTF-8字节;时间等明确条件仍填写对应结构化字段" },
68
+ { name: "seat_preferences", description: "已确认的席别代码数组,不填写旅客身份" },
64
69
  { name: "priority", enum: "balanced|fastest|cheapest|safest|flexible", description: "方案偏好" },
70
+ { name: "duration_basis", enum: "rail|door_to_door", description: "最快默认按铁路全程含换乘;明确要求且接驳证据完整时可按门到门" },
65
71
  { name: "max_transfers", description: "允许中转次数,0-2" },
66
72
  { name: "passengers", description: "成人乘客整数 1-5;不填写身份资料" },
67
73
  ],
68
- input_example: { origin: "上海", destination: "长沙", travel_date: "2026-10-06", arrive_before: "15:00", priority: "fastest", passengers: 1 },
74
+ input_example: { origin: "<用户出发地点>", destination: "<用户目的地>", travel_date: "<用户出行日期 YYYY-MM-DD>", priority: "fastest", passengers: 1 },
69
75
  notes: [
70
76
  "位置有歧义时会进入位置确认步骤,请用户选定后继续同一执行",
71
77
  "推荐结果含每趟车的席别与余票;购票在后续受保护流程完成",
72
78
  ],
73
79
  },
74
80
  };
75
- function railServiceGuidance(serviceID) {
76
- return RAIL_SERVICE_GUIDANCE[serviceID];
81
+ function railServiceGuidance(serviceID, schema) {
82
+ const guidance = RAIL_SERVICE_GUIDANCE[serviceID];
83
+ if (serviceID !== "itpay-rail-smart" || !guidance)
84
+ return guidance;
85
+ const properties = schema?.properties;
86
+ if (!properties?.endpoints)
87
+ return properties?.origin && properties?.destination ? guidance : undefined;
88
+ return {
89
+ ...guidance,
90
+ input_fields: [
91
+ { name: "endpoints", required: true, description: "origin/destination 各填完整text;类别有依据时填kind(area/place/station)。仅对象含义明确、类别确实待核验时可省略kind;完整名称合法,坐标可选,不靠省略kind猜城市" },
92
+ { name: "travel_date", required: true, description: "用户出行日期 YYYY-MM-DD" },
93
+ ],
94
+ optional_fields: (guidance.optional_fields ?? []).filter((field) => !["origin_city", "destination_city", "origin_location", "destination_location"].includes(field.name)).concat([
95
+ { name: "reuse_from", description: "同 owner 的已保存 Exact result_item_id 或 Smart snapshot_id;端点/日期新查询可复用有效事实" },
96
+ ]).filter(field => properties[field.name] !== undefined),
97
+ input_example: { endpoints: { origin: { kind: "place", text: "<用户原始完整具体地点>" }, destination: { kind: "area", text: "<用户原始完整行政范围>" } }, travel_date: "<用户出行日期 YYYY-MM-DD>" },
98
+ notes: ["仅用当前 input_schema 支持的端点写法;不要与旧 origin/destination 混用", "唯一地点直接解析;真实候选只确认有歧义一端;地图等待按可续用状态恢复,终态有新证据才用返回新查询模板,无动作则停止"],
99
+ };
100
+ }
101
+ // Generate only declared required fields and preserve safe known query facts.
102
+ // Authorization and passenger identity are never synthesized into templates.
103
+ function serviceInputTemplate(schema, known = {}) {
104
+ const properties = schema.properties;
105
+ if (properties?.endpoints && !properties.origin && !properties.destination && !known.endpoints && (typeof known.origin === "string" || typeof known.destination === "string")) {
106
+ known = { ...known, endpoints: { origin: { text: known.origin }, destination: { text: known.destination } } };
107
+ }
108
+ if (properties?.travel_date && !known.travel_date && typeof known.date === "string")
109
+ known = { ...known, travel_date: known.date };
110
+ const result = {};
111
+ for (const field of new Set([...requiredInputFields(schema), ...Object.keys(known).filter(field => properties?.[field] !== undefined)])) {
112
+ const rule = properties?.[field] ?? {};
113
+ if (field === "accept_non_guaranteed") {
114
+ result[field] = false;
115
+ continue;
116
+ }
117
+ if (rule.type === "object") {
118
+ const value = known[field];
119
+ result[field] = serviceInputTemplate(rule, value && typeof value === "object" && !Array.isArray(value) ? value : {});
120
+ }
121
+ else if (known[field] !== undefined && (rule.type === undefined || rule.type === "array" && Array.isArray(known[field]) || rule.type === "string" && typeof known[field] === "string" || rule.type === "boolean" && typeof known[field] === "boolean" || ["integer", "number"].includes(String(rule.type)) && typeof known[field] === "number"))
122
+ result[field] = known[field];
123
+ else if (rule.const !== undefined)
124
+ result[field] = rule.const;
125
+ else if (rule.default !== undefined)
126
+ result[field] = rule.default;
127
+ else if (Array.isArray(rule.enum) && rule.enum.length)
128
+ result[field] = rule.enum[0];
129
+ else if (rule.type === "integer" || rule.type === "number")
130
+ result[field] = typeof rule.minimum === "number" ? rule.minimum : 1;
131
+ else if (rule.type === "boolean")
132
+ result[field] = false;
133
+ else if (rule.type === "array")
134
+ result[field] = [];
135
+ else
136
+ result[field] = `<${field}>`;
137
+ }
138
+ for (const field of ["kind", "text", "station_code", "poi_id", "adcode", "location", "travel_date", "passengers", "priority", "user_query", "depart_after", "depart_before", "arrive_by", "arrive_before", "seat_preferences", "max_transfers", "duration_basis"]) {
139
+ if (properties?.[field] && known[field] !== undefined)
140
+ result[field] = known[field];
141
+ }
142
+ return result;
77
143
  }
78
144
  const WORKFLOW_STEP_GUIDANCE = {
79
145
  input: { meaning: "输入校验", hint: "核对本服务的输入契约和服务端错误码;本执行的输入不能凭猜测修复" },
@@ -87,22 +153,6 @@ const WORKFLOW_STEP_GUIDANCE = {
87
153
  recommend: { meaning: "方案推荐", hint: "查看已保存候选和实际推荐失败原因;不要无原因放宽条件" },
88
154
  delivery: { meaning: "交付", hint: "读取当前执行状态;已有订单或付款状态不明时先核实,不重建" },
89
155
  };
90
- function failedWorkflowStep(steps, errorCode) {
91
- if (!steps)
92
- return undefined;
93
- for (const [step, status] of Object.entries(steps)) {
94
- if (status === "failure" && step !== "failure")
95
- return step;
96
- }
97
- // Only a backend-recorded condition_unmet proves a false branch routed to failure.
98
- if (errorCode === "condition_unmet") {
99
- for (const [step, status] of Object.entries(steps)) {
100
- if (status === "false" && step !== "failure")
101
- return step;
102
- }
103
- }
104
- return undefined;
105
- }
106
156
  export async function runServicesStart(backend, serviceID, options = {}) {
107
157
  const host = options.host ?? "terminal";
108
158
  const response = await backend.startServiceExecution({
@@ -115,18 +165,23 @@ export async function runServicesStart(backend, serviceID, options = {}) {
115
165
  },
116
166
  });
117
167
  if (response.workflow_entry) {
118
- const guidance = railServiceGuidance(serviceID);
168
+ const guidance = railServiceGuidance(serviceID, response.workflow_entry.input_schema);
169
+ if (serviceID === "itpay-rail-smart" && !guidance) {
170
+ writeCommandEnvelope({ status: "contract_unavailable", result: { service_execution_id: response.execution.service_execution_id, service_id: serviceID }, instruction: "当前执行缺少可用输入合同;只读取一次同执行合同,仍缺失则报告系统缺口并停止,不猜旧Smart写法。", next: { command: `itpay services get ${response.execution.service_execution_id} --json`, reason: "读取当前执行锁定的输入合同一次" }, recovery: [] }, options);
171
+ return;
172
+ }
119
173
  writeCommandEnvelope({
120
174
  status: "input_required",
121
175
  result: { service_execution_id: response.execution.service_execution_id, service_id: serviceID, input_schema: response.workflow_entry.input_schema, ...(guidance ? { guidance } : {}) },
122
- instruction: guidance ? "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 可直接照抄),然后继续同一服务执行。不要臆造字段名。" : "根据服务声明填写输入,然后继续同一服务执行。",
176
+ instruction: guidance ? RAIL_ENDPOINT_BRIEF + "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 是待填示意,必须替换用户真实值),然后继续同一服务执行。不要臆造字段名。" : "根据服务声明填写输入,然后继续同一服务执行。",
123
177
  next: null,
124
178
  interaction: {
125
179
  schema_version: "itpay.interaction.v1",
126
180
  stage: "input_required",
127
181
  input_template: {
128
182
  command: `itpay services run ${serviceID} --execution ${response.execution.service_execution_id} --input-json <file> --json`,
129
- required_input: ["file"],
183
+ input: guidance?.input_example ?? serviceInputTemplate(response.workflow_entry.input_schema),
184
+ required_input: ["file", ...requiredInputFields(response.workflow_entry.input_schema)],
130
185
  executable: false,
131
186
  },
132
187
  },
@@ -192,18 +247,29 @@ function locationConfirmationEnvelope(executionID, capabilityID, input, confirma
192
247
  input = (confirmation.input ?? input);
193
248
  const endpoints = (confirmation.endpoints ?? []);
194
249
  const choices = Object.fromEntries(endpoints.map((endpoint) => [endpoint.side, "<用户选择的候选 id>"]));
250
+ const missing = endpoints.filter((endpoint) => !endpoint.candidates?.length);
251
+ const reason = missing[0]?.reason?.toUpperCase();
252
+ const instruction = missing.length
253
+ ? reason === "STATION_LOCATION_EVIDENCE_REQUIRED"
254
+ ? "尚未查票。车站身份已知,但 Smart 缺少该站地理坐标证据;请说明此系统缺口。若用户仅要双站直达,可按原站名使用 Exact;不要要求用户换站。"
255
+ : reason === "ADDRESS_REQUIRED" || reason === "ADMINISTRATIVE_AREA_UNRESOLVED"
256
+ ? "尚未查票。先用已有上下文、地图或浏览补该端地点证据;含义仍不清且影响范围才问用户一个关键问题。保留另一端和原条件;只有返回完整新查询模板且已有新证据才另查,没有模板则停止,不猜旧执行输入。"
257
+ : reason === "COORDINATE_IDENTITY_UNVERIFIED" || reason === "COORDINATE_NAME_MISMATCH"
258
+ ? "尚未查票。提交的地点名称和坐标尚未得到一致证据;请核实该端点的真实名称或位置,保留另一端,不要把坐标声明当成人工确认。"
259
+ : "尚未查票。地点证据不足;说明返回的原因与受影响端,先补真实证据,不改kind绕过、不把外部故障当用户歧义。当前无候选确认或完整新查询模板则停止。"
260
+ : "尚未查票。仅向用户确认真实地点歧义,展示候选名称、地址和高德链接。具体住宅仍是目的地,附近车站只作候选;不得把选地点改成锁车站。用户选择后保留原输入,回到同一服务。";
195
261
  return {
196
262
  status: "location_confirmation_required",
197
263
  result: { service_execution_id: executionID, query: input, location_confirmation: confirmation },
198
- instruction: "尚未查票。仅向用户确认 endpoints 中有歧义的地点,展示真实候选名称、地址和高德链接。不要自行选择候选、把区域改成车站或重复调用原查询。用户选择后保留原输入,带 location_confirmation 回到同一服务。没有候选时请用户补充已知城市或准确地名,再使用修正输入查询。",
264
+ instruction,
199
265
  next: null,
200
- ...(confirmation.can_resume ? {
266
+ ...(confirmation.can_resume && !missing.length && endpoints.length ? {
201
267
  interaction: {
202
268
  schema_version: "itpay.interaction.v1",
203
269
  stage: "location_confirmation_required",
204
270
  input_template: {
205
- command: `itpay services invoke ${executionID} --capability ${capabilityID}${formatInputOptions({ ...input,
206
- location_confirmation: { plan_id: confirmation.plan_id, token: confirmation.token, choices } })} --json`,
271
+ command: `itpay services invoke ${executionID} --capability ${capabilityID} --input-json <地点确认.json> --json`,
272
+ input: { ...input, location_confirmation: { plan_id: confirmation.plan_id, token: confirmation.token, choices } },
207
273
  required_input: ["choices"],
208
274
  executable: false,
209
275
  },
@@ -218,11 +284,13 @@ function requiredInputFields(schema) {
218
284
  }
219
285
  function locationInputInstruction(schema) {
220
286
  const properties = schema?.properties;
287
+ if (properties?.endpoints)
288
+ return " " + RAIL_ENDPOINT_BRIEF + " 两端分别填写 endpoints.origin/destination 的完整text;有类别依据就填kind,只有对象含义明确而类别待核验时可省略。区域保留范围、精确车站仅在目录验证后锁定。不要混用旧 origin/destination、手填锁站或伪造坐标。唯一地点会自动解析;只有真实多候选才让用户选返回的候选 id,地图失败应按当前执行恢复。";
221
289
  if (!properties?.origin_location || !properties?.destination_location)
222
290
  return "";
223
291
  if (!properties.location_confirmation)
224
- return " 地点保留用户已知最完整名称或城市范围;不要编造坐标或把城市改成同名车站。坐标及其他可选字段严格按当前 input_schema 提交。";
225
- return " 地点输入:先向用户说明本服务接受两个地点或城市范围。已有可信高德 GCJ-02 坐标时,可用 --input 'origin_location={\"lng\":113.0,\"lat\":23.0,\"coordinate_system\":\"gcj02\",\"source\":\"amap\"}'(数值必须替换为真实查询结果,destination_location 同理);没有地图能力时,直接传用户已知最完整的 origin/destination,可附 origin_city/destination_city。不要编造坐标、补猜地址或把深圳等城市改成深圳站;精确站查须明确站名。用户只给城市、县或镇就保留区域意图,不追问门牌。明确地点由服务自动解析;只有返回 location_confirmation 才展示候选名称、地址和高德链接,等待用户选择,禁止自行选第一项。";
292
+ return " " + RAIL_ENDPOINT_BRIEF + " 地点保留用户已知最完整名称或城市范围;不要编造坐标或把城市改成同名车站。坐标及其他可选字段严格按当前 input_schema 提交。";
293
+ return " " + RAIL_ENDPOINT_BRIEF + " 地点输入:保留两个地点或城市范围。已有可信高德 GCJ-02 坐标时,可用 --input 'origin_location={\"lng\":113.0,\"lat\":23.0,\"coordinate_system\":\"gcj02\",\"source\":\"amap\"}'(数值必须替换为真实查询结果,destination_location 同理);没有地图能力时,直接传用户已知最完整的 origin/destination,可附 origin_city/destination_city。不要编造坐标、补猜地址或把深圳等城市改成深圳站;精确站查须明确站名。用户只给城市、县或镇就保留区域意图,不追问门牌。明确地点由服务自动解析;只有返回 location_confirmation 才展示候选名称、地址和高德链接,等待用户选择,禁止自行选第一项。";
226
294
  }
227
295
  export async function runServicesInvoke(backend, config, serviceExecutionID, capabilityID, input, options = {}) {
228
296
  const readModel = await backend.getServiceExecution(serviceExecutionID);
@@ -235,13 +303,9 @@ export async function runServicesInvoke(backend, config, serviceExecutionID, cap
235
303
  }
236
304
  const missingInput = missingRequiredInput(requestedCapability.input_schema, input);
237
305
  if (missingInput.length > 0) {
238
- const correctedInput = { ...input };
239
- for (const field of missingInput)
240
- correctedInput[field] = "<value>";
241
- throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐 required_input 后重试同一个 execution;本次没有调用 Provider。", [{
242
- command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(correctedInput)} --json`,
243
- reason: "提交完整 capability 输入",
244
- }]);
306
+ throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "只补缺失字段到小JSON文件,再提交同一执行;本次尚未调用供应商。", [], { schema_version: "itpay.interaction.v1", stage: "input_required",
307
+ input_template: { command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID} --input-json <file> --json`,
308
+ required_input: ["file", ...missingInput], executable: false } });
245
309
  }
246
310
  const idempotencyKey = await operationID(config, `service.invoke:${serviceExecutionID}:${capabilityID}:${stableInput(input)}`);
247
311
  let response;
@@ -294,9 +358,16 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
294
358
  ...(quota ? { quota } : {}),
295
359
  };
296
360
  const preview = response.invocation?.safe_result_preview;
361
+ const notices = Array.isArray(preview?.notices) ? preview.notices.map((notice) => {
362
+ if (!notice || typeof notice !== "object" || !("code" in notice) || !("message" in notice) || typeof notice.message !== "string")
363
+ return notice;
364
+ if (!["RAIL_TRANSFER_SCOPE_LIMIT", "RAIL_TRANSFER_SEARCH_INCOMPLETE"].includes(String(notice.code)))
365
+ return notice;
366
+ return { ...notice, message: notice.message.replace(/可按主要枢纽分段查询[。;]?/g, "") };
367
+ }) : preview?.notices;
297
368
  if (!response.effective_quota?.exhausted && preview?.search_status === "LOCATION_CONFIRMATION_REQUIRED" && preview.location_confirmation) {
298
369
  return { value: locationConfirmationEnvelope(response.execution.service_execution_id, requestedCapability.capability_id, input, preview.location_confirmation),
299
- plainResult: [JSON.stringify(preview.location_confirmation)] };
370
+ plainResult: plainValueLines(preview.location_confirmation) };
300
371
  }
301
372
  if (typeof preview?.catalog_total === "number") {
302
373
  baseResult.catalog = {
@@ -304,7 +375,7 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
304
375
  recommendation: preview.recommendation,
305
376
  decision_source: preview.decision_source,
306
377
  coverage: preview.coverage,
307
- notices: preview.notices,
378
+ notices,
308
379
  search_status: preview.search_status,
309
380
  searched_scope: preview.searched_scope,
310
381
  effective_policy_hash: preview.effective_policy_hash,
@@ -321,15 +392,15 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
321
392
  let instruction = items.length > 0
322
393
  ? "用编号、名称和可公开字段向用户说明候选;若候选列表已满足目标就停止。只有用户明确选择并希望继续时,才提交对应编号;不要向用户提及 safe_payload、Execution 或内部 ID。"
323
394
  : `没有找到与“${queryText(input)}”匹配的结果。向用户展示本次为 0 个结果并停止。不要修改、缩短或猜测其他输入;只有用户明确提供新输入后,才能启动新的查询。`;
324
- if (items.length === 0 && Array.isArray(preview?.notices)) {
325
- const railNotice = preview.notices.find((notice) => {
395
+ if (items.length === 0 && Array.isArray(notices)) {
396
+ const railNotice = notices.find((notice) => {
326
397
  if (!notice || typeof notice !== "object")
327
398
  return false;
328
399
  const value = notice;
329
400
  return ["RAIL_TRANSFER_SCOPE_LIMIT", "RAIL_TRANSFER_SEARCH_INCOMPLETE"].includes(String(value.code)) && typeof value.message === "string";
330
401
  });
331
402
  if (railNotice)
332
- instruction = `向用户展示官方提示:${railNotice.message} 不要断言该行程必须多次中转或没有车。等待用户选择分段查询的起终点,不自动更换输入或重试。`;
403
+ instruction = `向用户展示官方提示:${railNotice.message} 说明本次已查范围和未完成原因,按服务端提供的当前动作恢复;不能据此断言没有车,不默认扩大范围或让用户另选分段站点。`;
333
404
  }
334
405
  let next = null;
335
406
  if (items.length > 0 && baseResult.catalog) {
@@ -375,13 +446,6 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
375
446
  ? {
376
447
  schema_version: "itpay.interaction.v1",
377
448
  stage: "query_results_ready",
378
- by_goal: {
379
- compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
380
- prepare_checkout: {
381
- action: "select_under_user_rules_then_prepare",
382
- requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
383
- },
384
- },
385
449
  input_template: {
386
450
  command: `itpay services action ${response.execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
387
451
  required_input: ["rank"],
@@ -397,40 +461,7 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
397
461
  };
398
462
  }
399
463
  function serviceResultPlainLines(result) {
400
- const lines = [
401
- `service_execution_id: ${String(result.service_execution_id)}`,
402
- `capability_id: ${String(result.capability_id)}`,
403
- ];
404
- const items = result.items;
405
- const query = result.query;
406
- if (query) {
407
- for (const [key, value] of Object.entries(query))
408
- lines.push(`${key}: ${String(value)}`);
409
- }
410
- if (items.length === 0)
411
- lines.push("results: 0");
412
- const catalog = result.catalog;
413
- if (catalog?.resolved_locations)
414
- lines.push(`resolved_locations: ${JSON.stringify(catalog.resolved_locations)}`);
415
- if (catalog?.journey_summary) {
416
- for (const key of ["journey_summary", "train_services", "journeys"]) {
417
- lines.push(`${key}: ${JSON.stringify(catalog[key])}`);
418
- }
419
- }
420
- if (result.quota)
421
- lines.push(`quota: ${JSON.stringify(result.quota)}`);
422
- if (result.checkout)
423
- lines.push(`checkout: ${JSON.stringify(result.checkout)}`);
424
- if (items.length > 0) {
425
- lines.push("items:");
426
- for (const item of items) {
427
- lines.push(` ${item.rank}. ${item.title}`);
428
- for (const [key, value] of Object.entries(item.safe_payload)) {
429
- lines.push(` ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
430
- }
431
- }
432
- }
433
- return lines;
464
+ return plainValueLines(result);
434
465
  }
435
466
  function queryText(input) {
436
467
  const value = Object.values(input).find((item) => typeof item === "string" && item.trim() !== "");
@@ -524,11 +555,6 @@ function formatInputOptions(input) {
524
555
  : ` --input ${shellArgument(`${key}=${typeof value === "object" && value !== null ? JSON.stringify(value) : String(value)}`)}`)
525
556
  .join("");
526
557
  }
527
- function shellArgument(value) {
528
- if (/^[\p{L}\p{N}._:=/-]+$/u.test(value))
529
- return value;
530
- return `'${value.replaceAll("'", `'"'"'`)}'`;
531
- }
532
558
  export async function runServicesAction(backend, serviceExecutionID, actionType, input, options = {}) {
533
559
  const selection = await resolveCandidateSelection(backend, serviceExecutionID, actionType, options);
534
560
  const request = {
@@ -553,10 +579,13 @@ export async function runServicesAction(backend, serviceExecutionID, actionType,
553
579
  catch (error) {
554
580
  if (error instanceof HttpError && ["booking_review_invalid", "booking_review_changed", "booking_review_locked"].includes(error.code)) {
555
581
  throw new CommandContractError(error.code, error.message, error.code === "booking_review_invalid"
556
- ? "确认输入与当前草稿不符。重新读取同一booking的review模板,席别使用返回的代码,不填显示名或猜新值。若仅修正代码写法,且车次、实际席别、人数、价格及条款等已确认内容未变,可沿用用户已有明确确认后提交;草稿实质变化或缺少真实同意时,先请用户确认变化内容。"
582
+ ? "确认输入与当前草稿不符。使用返回 interaction.input_template.input 当前review模板,席别使用返回的代码,不填显示名或猜新值。若仅修正代码写法,且车次、实际席别、人数、价格及条款等已确认内容未变,可沿用用户已有明确确认后提交;草稿实质变化或缺少真实同意时,先请用户确认变化内容。"
557
583
  : error.code === "booking_review_changed"
558
- ? "草稿版本已变化;重新读取同一booking的当前review与revision,经用户核对后再提交。"
559
- : "当前购买已有付款尝试;读取同一booking及订单状态,不创建新订单或重做付款。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取同一booking的当前状态与review" }]);
584
+ ? "草稿版本已变化;使用同一booking的当前review模板。仅技术修正且车次、实际席别、人数、价格及条款未变时沿用已有明确同意;实质变化或缺少真实同意才补确认。"
585
+ : "当前购买已有付款尝试;读取同一booking及订单状态,不创建新订单或重做付款。", error.payload?.review ? [] : [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取同一booking的当前状态与review" }], error.payload?.review?.input_template ? {
586
+ schema_version: "itpay.interaction.v1", stage: "booking_review_required",
587
+ input_template: { command: `itpay services action ${serviceExecutionID} --action ${actionType} --actor-type human --status approved --input-json <file> --json`, input: error.payload?.review.input_template, required_input: ["file"], executable: false },
588
+ } : undefined, { service_execution_id: serviceExecutionID, review: error.payload?.review, input_errors: error.payload?.input_errors });
560
589
  }
561
590
  throw error;
562
591
  }
@@ -674,13 +703,10 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
674
703
  const lockedInput = options.lockedInput ?? {};
675
704
  const missingInput = missingRequiredInput(capability.input_schema, lockedInput);
676
705
  if (missingInput.length > 0 && !readModel.workflow_entry && readModel.execution.next_action !== "create_checkout") {
677
- throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 的 required_input;本次没有创建 quote、Checkout 或订单。", [{ command: checkoutCommand(serviceExecutionID, capability, lockedInput), reason: "提交完整且会被锁定的服务输入" }]);
706
+ throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 的 required_input;本次没有创建 quote、Checkout 或订单。", [], { schema_version: "itpay.interaction.v1", stage: "input_required", input_template: { command: checkoutCommand(serviceExecutionID, capability, lockedInput), required_input: missingInput, executable: false } });
678
707
  }
679
708
  if (capability.delivery_email_required && readModel.workflow_entry?.capability_id !== capability.capability_id && String(deliveryContact.email ?? "").trim() === "") {
680
- throw new CommandContractError("delivery_email_required", "delivery email is required before creating this service checkout", "该 capability 的交付链接会发送到用户邮箱;先向用户说明用途并询问邮箱,不要代填。", [{
681
- command: `itpay services checkout ${serviceExecutionID} --capability ${capability.capability_id}${formatInputOptions(lockedInput)} --email <email> --json`,
682
- reason: "使用用户提供的邮箱创建 Checkout",
683
- }]);
709
+ throw new CommandContractError("delivery_email_required", "delivery email is required before creating this service checkout", "该 capability 的交付链接会发送到用户邮箱;先向用户说明用途并询问邮箱,不要代填。", [], { schema_version: "itpay.interaction.v1", stage: "input_required", input_template: { command: `itpay services checkout ${serviceExecutionID} --capability ${capability.capability_id}${formatInputOptions(lockedInput)} --email <email> --json`, required_input: ["email"], executable: false } });
684
710
  }
685
711
  }
686
712
  const response = await backend.createServiceExecutionCheckout(serviceExecutionID, {
@@ -697,19 +723,25 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
697
723
  const cardURL = localizeCardURL(absolutePublicURL(config.baseURL, checkout.card_url ?? fallbackCardURL(config.baseURL, checkoutID, displayToken)), locale);
698
724
  const cardPNGURL = localizeCardURL(absolutePublicURL(config.baseURL, checkout.card_png_url ?? checkout.qr_png_url ?? fallbackCardPNGURL(config.baseURL, checkoutID, displayToken)), locale);
699
725
  let itineraryTitle;
726
+ let itinerary;
700
727
  try {
701
728
  const booking = await backend.getServiceExecution(serviceExecutionID);
702
729
  if (booking.execution.service_id === "itpay-rail-booking") {
703
- const leg = booking.rail_booking?.legs?.[0];
704
730
  const review = booking.workflow?.human_action?.context?.review;
705
- const reviewLeg = Array.isArray(review?.legs) ? review.legs[0] : undefined;
706
- const facts = leg ?? reviewLeg;
707
- if (facts) {
708
- itineraryTitle = [facts.travel_date, facts.train_code,
709
- facts.from ?? facts.from_station,
710
- "→", facts.to ?? facts.to_station,
711
- facts.seat_name ?? facts.seat_type_name]
712
- .filter((value) => typeof value === "string" && value.length > 0).join(" ");
731
+ const presentation = await backend.getCheckoutPresentation(checkoutID, displayToken);
732
+ const quote = presentation.rail_quote;
733
+ const facts = quote?.legs ?? booking.rail_booking?.legs ?? (Array.isArray(review?.legs) ? review.legs : []);
734
+ const legs = facts.filter(value => value && typeof value === "object").map(value => {
735
+ const leg = value;
736
+ return { travel_date: leg.travel_date, train_code: leg.train_code,
737
+ from_station: leg.from || leg.from_station || `站点代码 ${String(leg.from_station_code ?? "未知")}(名称暂缺)`,
738
+ to_station: leg.to || leg.to_station || `站点代码 ${String(leg.to_station_code ?? "未知")}(名称暂缺)`,
739
+ departure: leg.departure ?? leg.departure_time, arrival: leg.arrival ?? leg.arrival_time,
740
+ arrival_days: leg.arrival_days, seat_name: leg.seat_name || leg.seat_type_name || `席别代码 ${String(leg.seat_type ?? "未知")}(名称暂缺)` };
741
+ });
742
+ if (legs.length) {
743
+ itinerary = { legs, passengers: quote?.passengers ?? review?.passengers ?? null, purchase_unit: "journey" };
744
+ itineraryTitle = legs.map(leg => [leg.train_code, leg.from_station, "->", leg.to_station].filter(value => typeof value === "string" && value.length).join(" ")).join(" / ");
713
745
  }
714
746
  }
715
747
  }
@@ -717,6 +749,7 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
717
749
  // Checkout already exists; a display-only title lookup cannot hide its official handoff.
718
750
  }
719
751
  const plan = buildCheckoutQRPlan({
752
+ localSimulation: backend.localSimulation,
720
753
  host,
721
754
  checkoutID,
722
755
  checkoutURL,
@@ -763,13 +796,11 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
763
796
  });
764
797
  }
765
798
  const envelope = buildServicesCheckoutEnvelope(response, checkoutURL, plan, options.agentType, options.target);
766
- const plainResult = [
767
- `service_execution_id: ${response.binding.service_execution_id}`,
768
- `checkout_id: ${checkoutID}`,
769
- `capability_id: ${checkoutCapabilityID(response, capabilityID)}`,
770
- `locked_input: ${JSON.stringify(response.locked_input)}`,
771
- `amount: ${formatMoney(checkout.checkout.amount_minor, checkout.checkout.currency)}`,
772
- ];
799
+ if (itinerary) {
800
+ envelope.result = { ...envelope.result, itinerary };
801
+ envelope.instruction = "整程一次付款,各段分别出票;展示入口不等于付款,付款成功不等于全部出票。" + envelope.instruction;
802
+ }
803
+ const plainResult = plainValueLines(envelope.result);
773
804
  if (!options.jsonOutput && platform === "terminal") {
774
805
  plainResult.push("qr:", await renderTerminalQR(checkoutURL, options.qrFormat ?? "terminal"));
775
806
  }
@@ -789,14 +820,14 @@ export async function runServicesQuote(backend, serviceExecutionID, capabilityID
789
820
  model.allowed_actions?.some((action) => action.type === "prepare_quote" && action.capability_id === capabilityID);
790
821
  const missingInput = missingRequiredInput(capability.input_schema, input);
791
822
  if (missingInput.length > 0 && !selectionBacked && model.workflow_entry?.capability_id !== capability.capability_id) {
792
- throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 输入;本次没有创建 Quote、Cart 或 Checkout。", [{ command: quoteCommand(serviceExecutionID, capability, input), reason: "提交完整且会被锁定的输入" }]);
823
+ throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 输入;本次没有创建 Quote、Cart 或 Checkout。", [], { schema_version: "itpay.interaction.v1", stage: "input_required", input_template: { command: quoteCommand(serviceExecutionID, capability, input), required_input: missingInput, executable: false } });
793
824
  }
794
825
  const deliveryContact = {
795
826
  ...(options.deliveryContact ?? {}),
796
827
  ...(options.email ? { email: options.email } : {}),
797
828
  };
798
829
  if (capability.delivery_email_required && model.workflow_entry?.capability_id !== capability.capability_id && String(deliveryContact.email ?? "").trim() === "") {
799
- throw new CommandContractError("delivery_email_required", "delivery email is required before preparing this service quote", "交付链接会发送到用户邮箱;说明用途并询问邮箱,不要代填。", [{ command: quoteCommand(serviceExecutionID, capability, input), reason: "使用用户提供的邮箱创建报价" }]);
830
+ throw new CommandContractError("delivery_email_required", "delivery email is required before preparing this service quote", "交付链接会发送到用户邮箱;说明用途并询问邮箱,不要代填。", [], { schema_version: "itpay.interaction.v1", stage: "input_required", input_template: { command: quoteCommand(serviceExecutionID, capability, input), required_input: ["email"], executable: false } });
800
831
  }
801
832
  const quote = await backend.prepareServiceQuote(serviceExecutionID, {
802
833
  capability_id: capabilityID,
@@ -865,18 +896,12 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
865
896
  ...(nextState.interaction ? { interaction: nextState.interaction } : {}),
866
897
  ...(nextState.communication ? { communication: nextState.communication } : {}),
867
898
  ...(nextState.handoff ? { handoff: nextState.handoff } : {}),
868
- recovery: nextState.recovery,
899
+ recovery: nextState.recovery.filter(action => action.command !== `itpay services get ${serviceExecutionID} --json`),
869
900
  };
870
901
  writeCommandEnvelope(envelope, {
871
902
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
872
903
  ...(options.output ? { output: options.output } : {}),
873
- plainResult: [
874
- `service_execution_id: ${execution.service_execution_id}`,
875
- `service_id: ${execution.service_id}`,
876
- `state: ${execution.status}/${execution.phase}`,
877
- ...(execution.current_capability_id ? [`current_capability_id: ${execution.current_capability_id}`] : []),
878
- ...timeline.map((event) => `${event.sequence}. ${event.step} ${event.status}/${event.phase} ${event.occurred_at}`),
879
- ],
904
+ plainResult: plainValueLines(envelope.result),
880
905
  });
881
906
  }
882
907
  function shouldWaitForServiceResult(model) {
@@ -905,8 +930,6 @@ export async function runServicesNext(backend, serviceExecutionID, options = {})
905
930
  envelope.next = { command: `itpay services next ${serviceExecutionID} --timeout 120 --json`, reason: "稍后继续等待同一任务;不重新提交查询" };
906
931
  }
907
932
  journalTaskState(serviceExecutionID, envelope, options.env ?? process.env);
908
- if (response.rail_booking)
909
- envelope.result = { ...envelope.result, rail_booking: response.rail_booking };
910
933
  writeCommandEnvelope(envelope, {
911
934
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
912
935
  ...(options.output ? { output: options.output } : {}),
@@ -922,25 +945,25 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
922
945
  if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
923
946
  throw new CommandContractError("limit_invalid", "--limit must be an integer from 1 to 20", "limit 必须是 1 到 20 的整数;本次未读取服务端分页。", [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${offset} --json`, reason: "用默认页大小重试" }]);
924
947
  }
925
- const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, resultItemID, offset, limit);
948
+ const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, resultItemID, offset, limit, limit === 1 ? undefined : "summary");
926
949
  const page = response.page;
927
950
  const container = (page.result ?? page);
928
951
  const exactCandidate = limit === 1 && Array.isArray(container.candidates) && container.candidates.length === 1
929
952
  ? container.candidates[0] : undefined;
930
953
  if (exactCandidate && typeof exactCandidate.train_code === "string" && Array.isArray(exactCandidate.seats)) {
931
954
  const offer = exactCandidate.booking_offer;
955
+ const { booking_offer: _bookingOffer, ...candidateFacts } = exactCandidate;
932
956
  const seats = exactCandidate.seats.filter((seat) => seat.purchase_supported === true);
933
- const seat = seats[0];
934
957
  writeCommandEnvelope({
935
958
  status: "exact_train_detail",
936
959
  result: {
937
960
  service_execution_id: serviceExecutionID, result_item_id: resultItemID, result_offset: offset,
938
- candidate: exactCandidate,
961
+ candidate: candidateFacts,
939
962
  ...(offer?.service_id && offer.selection_token ? { booking_template: {
940
963
  command: `itpay services run ${offer.service_id} --input-json <file> --json`,
941
964
  executable: false, required_input: ["file", "passengers", "seat_type"],
942
965
  seat_choices: seats.map((choice) => ({ seat_type: choice.seat_type, seat_name: choice.seat_name })),
943
- input_example: { selection: { token: offer.selection_token, ...(typeof seat?.seat_type === "string" ? { seat_type: seat.seat_type } : {}) }, passengers: 1 },
966
+ input_example: { selection: { token: offer.selection_token, seat_type: "<用户授权席别对应代码>" }, passengers: "<真实人数>" },
944
967
  } } : {}),
945
968
  },
946
969
  instruction: offer?.selection_token
@@ -949,7 +972,7 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
949
972
  next: null, recovery: [],
950
973
  }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
951
974
  ...(options.output ? { output: options.output } : {}),
952
- plainResult: [`${exactCandidate.train_code} ${exactCandidate.from_station ?? ""} → ${exactCandidate.to_station ?? ""}`] });
975
+ });
953
976
  return;
954
977
  }
955
978
  // rail.progressive.v2 saved pages key journeys/journey_page; legacy result
@@ -968,9 +991,13 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
968
991
  offset: catalogPage.offset ?? offset,
969
992
  limit: catalogPage.limit ?? limit,
970
993
  total: catalogPage.total ?? candidates.length,
994
+ requested_limit: limit,
995
+ truncated_reason: container.journey_page?.truncated_reason ?? null,
971
996
  count: catalogPage.count ?? candidates.length,
972
997
  next_offset: nextOffset,
973
- page,
998
+ ...(journeys.length ? { journeys: journeys.map(value => compactRailJourney(value, serviceExecutionID, resultItemID)) }
999
+ : candidates.some(value => typeof value === "object" && value !== null && "train_code" in value)
1000
+ ? { candidates: candidates.map((value, index) => exactTrainRow(value, serviceExecutionID, resultItemID, (catalogPage.offset ?? offset) + index)) } : { page }),
974
1001
  },
975
1002
  instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。先读完所需页,再合并回答用户;不要逐页播报。",
976
1003
  next: nextOffset !== null
@@ -986,11 +1013,7 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
986
1013
  writeCommandEnvelope(envelope, {
987
1014
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
988
1015
  ...(options.output ? { output: options.output } : {}),
989
- plainResult: candidates.map((candidate) => {
990
- const c = candidate;
991
- const title = typeof c.title === "string" ? c.title : JSON.stringify(c);
992
- return `${title}`;
993
- }),
1016
+ plainResult: plainValueLines(envelope.result),
994
1017
  });
995
1018
  }
996
1019
  export async function runServicesList(backend, options = {}) {
@@ -1005,6 +1028,7 @@ export async function runServicesList(backend, options = {}) {
1005
1028
  status: execution.status,
1006
1029
  phase: execution.phase,
1007
1030
  updated_at: execution.updated_at,
1031
+ reader: { command: `itpay services next ${execution.service_execution_id} --json`, reason: "读取这笔已有服务" },
1008
1032
  }));
1009
1033
  const latest = executions[0];
1010
1034
  const envelope = {
@@ -1013,7 +1037,7 @@ export async function runServicesList(backend, options = {}) {
1013
1037
  instruction: executions.length === 1
1014
1038
  ? "只有一条可恢复记录;继续读取同一笔服务。"
1015
1039
  : latest
1016
- ? "用服务和状态说明这些可恢复记录;多个结果必须让用户选择。"
1040
+ ? "按原任务中已明确的执行ID读取对应reader;只有无法确定属于哪一笔任务时才询问用户。"
1017
1041
  : "当前设备没有可恢复的 Service Execution;先读取已发布目录,不要猜测 ID。",
1018
1042
  next: executions.length === 1
1019
1043
  ? { command: `itpay services next ${latest.service_execution_id} --json`, reason: "继续唯一可恢复的服务" }
@@ -1025,51 +1049,91 @@ export async function runServicesList(backend, options = {}) {
1025
1049
  writeCommandEnvelope(envelope, {
1026
1050
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1027
1051
  ...(options.output ? { output: options.output } : {}),
1028
- plainResult: executions.map((execution) => `${execution.service_execution_id}: ${execution.service_id} ${execution.status}/${execution.phase} updated=${execution.updated_at}`),
1029
1052
  });
1030
1053
  }
1054
+ // Bound the final qualified output by whole saved rows; the cursor follows the emitted rows.
1055
+ function writeSavedRailPage(envelope, options, key, offset, continuation) {
1056
+ const result = envelope.result ?? {};
1057
+ const rows = result[key];
1058
+ if (!Array.isArray(rows))
1059
+ throw new Error("saved page has no rows");
1060
+ const bound = (offset === 0 ? 12 : 32) * 1024;
1061
+ const render = (output) => writeCommandEnvelope(envelope, { ...options, output });
1062
+ if (!options.all) {
1063
+ for (;;) {
1064
+ let text = "";
1065
+ render(value => { text += value; });
1066
+ if (Buffer.byteLength(text, "utf8") <= bound)
1067
+ break;
1068
+ if (rows.length <= 1) {
1069
+ throw new CommandContractError("saved_row_too_large", "saved row exceeds the readable page limit", "这条已保存记录超过当前页面大小;事实仍保留,请读取该条详情。", rows[0]?.detail?.command ? [rows[0].detail] : []);
1070
+ }
1071
+ rows.pop();
1072
+ result.truncated_reason = "cli_output_byte_budget";
1073
+ result.count = rows.length;
1074
+ if (key === "trains")
1075
+ result.read_count = rows.length;
1076
+ result.next_offset = offset + rows.length;
1077
+ envelope.next = continuation(offset + rows.length);
1078
+ }
1079
+ }
1080
+ writeCommandEnvelope(envelope, options);
1081
+ }
1031
1082
  export async function runServicesReadResult(backend, serviceExecutionID, options = {}) {
1032
1083
  if (!options.snapshot && !options.journey) {
1033
1084
  const model = await backend.getServiceExecution(serviceExecutionID);
1034
1085
  if (model.execution.service_id === "itpay-rail-exact") {
1035
1086
  const item = (model.current_result_items?.length ? model.current_result_items : model.result_items)?.[0];
1036
1087
  if (!item) {
1088
+ const preview = model.provider_invocations.at(-1)?.safe_result_preview;
1089
+ const template = exactSmartTemplate(preview?.resolved_station_pair, serviceExecutionID);
1090
+ if (template && preview?.catalog_total === 0) {
1091
+ writeCommandEnvelope({ status: "no_result", result: { service_execution_id: serviceExecutionID,
1092
+ scope: "station_pair", resolved_station_pair: preview.resolved_station_pair,
1093
+ total: 0, smart_template: template },
1094
+ instruction: "这一站对的成功观测为0条,不代表附近区域无车。若用户允许中转或原需求范围更广,保留原始区域/地点意图并沿 smart_template 引用本次事实;只要直达时不要自动转中转。",
1095
+ next: null, recovery: [] }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1096
+ ...(options.output ? { output: options.output } : {}) });
1097
+ return;
1098
+ }
1037
1099
  await runServicesNext(backend, serviceExecutionID, options);
1038
1100
  return;
1039
1101
  }
1040
1102
  const rows = [];
1041
- let offset = 0;
1103
+ const startOffset = options.offset ?? 0;
1104
+ const limit = options.limit ?? 20;
1105
+ if (!Number.isInteger(startOffset) || startOffset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20) {
1106
+ throw new CommandContractError("page_invalid", "offset must be non-negative and limit 1-20", "页码无效;请按返回的 next.command 读取。", []);
1107
+ }
1108
+ let offset = startOffset;
1042
1109
  let total = 0;
1043
1110
  let resolvedStationPair;
1044
- let nextOffset = 0;
1045
- while (nextOffset !== null && rows.length < 100) {
1046
- const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, item.service_capability_result_item_id, offset, 20);
1111
+ let nextOffset = startOffset;
1112
+ do {
1113
+ const response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, item.service_capability_result_item_id, offset, limit);
1047
1114
  const container = (response.page.result ?? response.page);
1048
1115
  const candidates = Array.isArray(container.candidates) ? container.candidates : [];
1049
1116
  resolvedStationPair ??= container.resolved_station_pair;
1050
- rows.push(...candidates.map((value, index) => ({
1051
- ...compactExactTrain(value),
1052
- result_item_id: item.service_capability_result_item_id,
1053
- result_offset: offset + index,
1054
- detail: { command: `itpay services page ${serviceExecutionID} ${item.service_capability_result_item_id} --offset ${offset + index} --limit 1 --json`,
1055
- reason: "读取此车次的当前席别与服务端购买凭据" },
1056
- })));
1117
+ rows.push(...candidates.map((value, index) => exactTrainRow(value, serviceExecutionID, item.service_capability_result_item_id, offset + index)));
1057
1118
  const page = (container.catalog_page ?? {});
1058
1119
  total = typeof container.catalog_total === "number" ? container.catalog_total : Math.max(total, rows.length);
1059
1120
  nextOffset = typeof page.next_offset === "number" && page.next_offset > offset ? page.next_offset : null;
1060
1121
  offset = nextOffset ?? offset;
1061
- }
1062
- writeCommandEnvelope({
1122
+ } while (options.all && nextOffset !== null);
1123
+ writeSavedRailPage({
1063
1124
  status: "ready",
1064
1125
  result: { service_execution_id: serviceExecutionID, scope: "station_pair", resolved_station_pair: resolvedStationPair,
1065
- total, read_count: rows.length,
1126
+ total, offset: startOffset, count: rows.length, read_count: rows.length,
1127
+ ...(exactSmartTemplate(resolvedStationPair, serviceExecutionID, item.service_capability_result_item_id)
1128
+ ? { smart_template: exactSmartTemplate(resolvedStationPair, serviceExecutionID, item.service_capability_result_item_id) } : {}),
1066
1129
  trains: rows, ...(nextOffset !== null ? { next_offset: nextOffset } : {}) },
1067
1130
  instruction: "这是同一已保存站对的车次行。按用户时限、席别和人数筛选;零条仅代表这一站对。纯比较答复后结束;已有购票委托则执行所选行 detail.command 读取当前席别、库存和购买凭据。",
1068
- next: nextOffset !== null ? { command: `itpay services page ${serviceExecutionID} ${item.service_capability_result_item_id} --offset ${nextOffset} --limit 20 --json`, reason: "继续读取同一结果的剩余车次" } : null,
1131
+ next: nextOffset !== null ? { command: `itpay services read-result ${serviceExecutionID} --offset ${nextOffset} --limit ${limit} --json`, reason: "继续读取同一结果的剩余车次" } : null,
1069
1132
  recovery: [],
1070
1133
  }, { ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1071
1134
  ...(options.output ? { output: options.output } : {}),
1072
- plainResult: rows.map((row) => `${row.train_code} ${row.from_station} ${row.departure} → ${row.to_station} ${row.arrival}`) });
1135
+ all: Boolean(options.all),
1136
+ }, "trains", startOffset, next => ({ command: `itpay services read-result ${serviceExecutionID} --offset ${next} --limit ${limit} --json`, reason: "读取同一保存结果的下一页" }));
1073
1137
  return;
1074
1138
  }
1075
1139
  }
@@ -1080,49 +1144,88 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1080
1144
  // rail.planning catalog read: --snapshot returns compact rows for the
1081
1145
  // committed rail.catalog.v3; journey detail remains available separately.
1082
1146
  if (options.snapshot && !options.journey) {
1083
- const committed = await backend.getRailPlanningCatalog(serviceExecutionID, options.snapshot);
1084
- const catalog = committed.catalog;
1085
- // The committed catalog may ship shared_rows.v1 positional rows — decode
1086
- // the summary through the column legend; an unknown encoding is an
1087
- // explicit upgrade error, never an empty catalog.
1088
- const decoded = catalog
1089
- ? decodeRailCatalogJourneys(catalog)
1090
- : { journeys: [], packed: false };
1091
- const journeys = decoded.journeys.map((journey) => ({ ...journey,
1092
- detail: { command: `itpay services read-result ${serviceExecutionID} --snapshot ${committed.snapshot_id} --journey ${journey.ref} --json`,
1093
- reason: "读取同一已保存快照的此行程完整购票条件" },
1094
- }));
1095
- const counts = (catalog?.counts ?? {});
1096
- const journeyCount = journeys.length || Number(counts?.combinations ?? 0);
1097
- writeCommandEnvelope({
1147
+ const offset = options.offset ?? 0;
1148
+ const limit = options.limit ?? 3;
1149
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20) {
1150
+ throw new CommandContractError("page_invalid", "offset must be non-negative and limit 1-20", "目录页码无效;请按返回的 next.command 读取。", []);
1151
+ }
1152
+ const journeys = [];
1153
+ let nextOffset = offset;
1154
+ let total = 0;
1155
+ let truncatedReason = null;
1156
+ let qualificationCounts;
1157
+ let planID;
1158
+ let queryRevision;
1159
+ while (nextOffset !== null) {
1160
+ let response;
1161
+ try {
1162
+ response = await backend.getServiceExecutionResultItemPage(serviceExecutionID, options.snapshot, nextOffset, limit, "summary");
1163
+ }
1164
+ catch (error) {
1165
+ if (error instanceof HttpError && error.code === "result_not_pageable") {
1166
+ const committed = await backend.getRailPlanningCatalog(serviceExecutionID, options.snapshot);
1167
+ decodeRailCatalogJourneys(committed.catalog);
1168
+ }
1169
+ throw error;
1170
+ }
1171
+ if (response.snapshot_id !== options.snapshot)
1172
+ throw new Error("saved page snapshot changed");
1173
+ planID = response.plan_id;
1174
+ queryRevision = response.query_revision;
1175
+ const container = (response.page.result ?? response.page);
1176
+ const page = container.journey_page;
1177
+ const rows = Array.isArray(container.journeys) ? container.journeys : [];
1178
+ total = typeof page?.total === "number" ? page.total : rows.length;
1179
+ qualificationCounts = page?.qualification_counts ?? qualificationCounts;
1180
+ truncatedReason = page?.truncated_reason ?? truncatedReason;
1181
+ journeys.push(...rows.map((card) => compactRailJourney(card, serviceExecutionID, options.snapshot)));
1182
+ const following = page?.next_offset;
1183
+ if (typeof following === "number" && following <= nextOffset)
1184
+ throw new Error("saved page cursor did not advance");
1185
+ nextOffset = typeof following === "number" ? following : null;
1186
+ if (!options.all)
1187
+ break;
1188
+ }
1189
+ writeSavedRailPage({
1098
1190
  status: "ready",
1099
1191
  result: {
1100
1192
  service_execution_id: serviceExecutionID,
1101
- plan_id: committed.plan_id,
1102
- snapshot_id: committed.snapshot_id,
1103
- query_revision: committed.query_revision,
1104
- coverage: catalog?.coverage,
1105
- calculation_scope: catalog?.request_summary,
1106
- total: journeyCount,
1193
+ plan_id: planID,
1194
+ snapshot_id: options.snapshot,
1195
+ query_revision: queryRevision,
1196
+ total,
1197
+ qualification_counts: qualificationCounts,
1198
+ requested_limit: limit,
1199
+ truncated_reason: truncatedReason,
1200
+ offset,
1201
+ count: journeys.length,
1202
+ next_offset: nextOffset,
1107
1203
  journeys,
1108
1204
  },
1109
- instruction: "这是同一已保存快照的紧凑行程目录。按用户问题筛选全部 journeys 并一次回答;未计入接驳或未知费用不能当免费。需要购票详情时执行所选行 detail.command;价格和余票仍须购买前实时报价。",
1110
- next: null,
1205
+ instruction: qualificationCounts === undefined
1206
+ ? "此历史快照没有完整资格计数;线路仅供核对铁路事实,不能据此认定可购买。沿同快照 detail.command 查看详情。"
1207
+ : qualificationCounts.eligible === 0
1208
+ ? `查到 ${total} 个时刻组合,当前 0 条满足购票资格。请读取诊断线路的 detail.command 查看具体原因;修改条件须依据用户意愿。`
1209
+ : `已查范围内合格时刻组合 ${qualificationCounts.eligible};本次展示 ${journeys.length} 个方案(首屏优先既有主方案/代表,计数不是中转通道数)。最快/低价须按对应方案和比较范围说明;未全范围排序只能说已读方案中。更多条件优先同快照调整偏好或沿 next 有序读所需页,不随机翻页、不无条件读完组合。未知费用不能当免费;detail.command 读取席别和整程购买条件,购买前实时报价。`,
1210
+ next: nextOffset === null ? null : { command: `itpay services read-result ${serviceExecutionID} --snapshot ${options.snapshot} --offset ${nextOffset} --limit ${limit} --json`, reason: "仅当前问题还需未读线路时继续;已有合格完整结果可答复或按委托继续" },
1111
1211
  recovery: [],
1112
1212
  }, {
1113
1213
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1114
1214
  ...(options.output ? { output: options.output } : {}),
1115
- plainResult: journeys.length > 0
1116
- ? [
1117
- `${journeyCount} combinations:`,
1118
- ...journeys.map((j) => `${j.defaultLayer === "backup" ? "[备选] " : "[主选择] "}${j.ref} ${j.route} ${j.rides?.map((r) => `${r.train_code} ${r.departure}–${r.arrival}`).join(" / ") ?? ""}`),
1119
- ]
1120
- : [`catalog: ${committed.snapshot_id} (no combinations)`],
1121
- });
1215
+ all: Boolean(options.all),
1216
+ }, "journeys", offset, next => ({ command: `itpay services read-result ${serviceExecutionID} --snapshot ${options.snapshot} --offset ${next} --limit ${limit} --json`, reason: "仅当前问题还需未读线路时继续;已有合格完整结果可答复或按委托继续" }));
1122
1217
  return;
1123
1218
  }
1124
1219
  if (options.journey) {
1125
1220
  const detail = await backend.getRailJourneyDetail(serviceExecutionID, options.journey, options.snapshot);
1221
+ const planOffset = options.offset ?? 0;
1222
+ const planLimit = options.limit ?? 3;
1223
+ if (!Number.isInteger(planOffset) || planOffset < 0 || !Number.isInteger(planLimit) || planLimit < 1 || planLimit > 20) {
1224
+ throw new CommandContractError("page_invalid", "offset must be non-negative and limit 1-20", "席别方案页码无效;请按返回的 next.command 读取。", []);
1225
+ }
1226
+ const plans = detail.journey.ticket_plans ?? [];
1227
+ const selected = options.all ? plans : plans.slice(planOffset, planOffset + planLimit);
1228
+ const nextOffset = !options.all && planOffset + selected.length < plans.length ? planOffset + selected.length : null;
1126
1229
  writeCommandEnvelope({
1127
1230
  status: "ready",
1128
1231
  result: {
@@ -1130,10 +1233,13 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1130
1233
  plan_id: detail.plan_id,
1131
1234
  snapshot_id: detail.snapshot_id,
1132
1235
  query_revision: detail.query_revision,
1133
- journey: detail.journey,
1236
+ journey: compactRailJourney(detail.journey, serviceExecutionID, detail.snapshot_id),
1237
+ ticket_plan_total: plans.length,
1238
+ ticket_plan_offset: planOffset,
1239
+ ticket_plans: selected.map(plan => compactRailTicketPlan(plan, detail.journey.qualification_status === "eligible")),
1134
1240
  },
1135
- instruction: "展示该 journey 的完整明细(车次、分段、席别报价、接驳估计与风险标注)。rail_payable 只是该行程当前可购报价的参考价,不是锁价;下单前须走受保护 Checkout 收集乘车人。用户已明确委托按规则代选时用 select_delegated;用户亲自选定该行程时用 select_user_chosen,不能把 Agent 选择记作人类动作。",
1136
- next: null,
1241
+ instruction: "同一线路的完整车次与席别方案。已有所需席别和有效凭据即可按真实委托继续;只需比较则答复并停止,需要其他席别才读下一页。使用所选 ticket_plan 的整程 booking_template:一个 booking、统一报价、一个官方付款入口,各段分别出票,无联合锁票或接续保证。未返回整程模板时不能拼不同方案的腿或退回逐腿付款,按同一执行恢复。身份资料只在受保护 Checkout 页面填写。",
1242
+ next: nextOffset === null ? null : { command: `itpay services read-result ${serviceExecutionID} --snapshot ${detail.snapshot_id} --journey ${options.journey} --offset ${nextOffset} --limit ${planLimit} --json`, reason: "仅需要其他席别时读取;已满足用户条件则停止翻页" },
1137
1243
  ...(detail.journey?.booking_support === "single_leg" ? { interaction: {
1138
1244
  schema_version: "itpay.interaction.v1", stage: "query_results_ready",
1139
1245
  select_user_chosen: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${detail.journey.journey_id} --json`,
@@ -1143,7 +1249,6 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1143
1249
  }, {
1144
1250
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
1145
1251
  ...(options.output ? { output: options.output } : {}),
1146
- plainResult: servicesNextPlainResult(detail.journey),
1147
1252
  });
1148
1253
  return;
1149
1254
  }
@@ -1163,6 +1268,24 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
1163
1268
  plainResult: servicesNextPlainResult(envelope.result),
1164
1269
  });
1165
1270
  }
1271
+ function exactSmartTemplate(pair, executionID, resultItemID) {
1272
+ if (!pair || typeof pair !== "object")
1273
+ return null;
1274
+ const value = pair;
1275
+ if (!["origin_name", "origin_code", "destination_name", "destination_code", "travel_date"].every((key) => typeof value[key] === "string" && value[key]))
1276
+ return null;
1277
+ return { executable: false, verified_station_pair: value,
1278
+ instruction: "已核验站对是复用事实,不是完整用户查询。仅用户明确锁站才填 kind=station;区域/地点从原始需求填回,保留真实人数、时限和偏好,不默认为一人。",
1279
+ command: "itpay services run itpay-rail-smart --input-json <原始需求.json> --json",
1280
+ required_input: ["endpoints", "passengers", "用户时限与偏好"],
1281
+ input_example: { endpoints: { origin: { text: "<原始出发行政范围/地点/用户指定站>" }, destination: { text: "<原始到达行政范围/地点/用户指定站>" } },
1282
+ travel_date: value.travel_date, passengers: "<真实人数>", reuse_from: { execution_id: executionID,
1283
+ ...(resultItemID ? { result_item_id: resultItemID } : {}) } } };
1284
+ }
1285
+ function exactTrainRow(candidate, executionID, itemID, offset) {
1286
+ return { ...compactExactTrain(candidate), result_item_id: itemID, result_offset: offset,
1287
+ detail: { command: `itpay services page ${executionID} ${itemID} --offset ${offset} --limit 1 --json`, reason: "读取此车次席别与服务端购买模板" } };
1288
+ }
1166
1289
  function compactExactTrain(candidate) {
1167
1290
  const seats = Array.isArray(candidate.seats) ? candidate.seats : [];
1168
1291
  return {
@@ -1200,6 +1323,8 @@ function railBookingEnvelope(model) {
1200
1323
  leg_index: leg.leg_index,
1201
1324
  state: leg.state,
1202
1325
  issued: leg.issued,
1326
+ ...(leg.error_code ? { error_code: leg.error_code } : {}),
1327
+ arrival_days: leg.arrival_days ?? 0,
1203
1328
  ...(leg.details_pending ? { details_pending: true } : {}),
1204
1329
  ...(leg.supplier_state ? { supplier_state: leg.supplier_state } : {}),
1205
1330
  ...(leg.train_code ? { train_code: leg.train_code } : {}),
@@ -1214,16 +1339,15 @@ function railBookingEnvelope(model) {
1214
1339
  const result = {
1215
1340
  service_execution_id: execution.service_execution_id,
1216
1341
  ...(orderID ? { order_id: orderID } : {}),
1217
- rail: { state: rail.state, issued_legs: rail.issued_legs, legs },
1218
- ...(model.workflow ? { workflow: model.workflow } : {}),
1342
+ rail: { state: rail.state, issued_legs: rail.issued_legs, total_legs: rail.legs.length, message: rail.message, legs },
1219
1343
  };
1220
- if (rail.state === "issued") {
1344
+ if (rail.state === "issued" && rail.issued_legs === rail.legs.length && rail.legs.length > 0) {
1221
1345
  const detailsPending = rail.legs.some((leg) => leg.details_pending);
1222
1346
  return {
1223
1347
  status: "issued",
1224
- result,
1348
+ result: { ...result, ...(orderID ? { aftercare: { command: `itpay order ${orderID} --json`, when: "仅用户请求核对订单或售后时" } } : {}) },
1225
1349
  instruction: `告诉用户:车票已出票,座位以实际出票为准,平台不提供 12306 票号,可在订单页核对行程。不要在对话中索要乘车人身份信息。${detailsPending ? "部分席位信息未能同步,请前往 12306 核对行程;不要重复购买。" : ""}`,
1226
- next: orderID ? { command: `itpay order ${orderID} --json`, reason: "查看订单及退款入口" } : null,
1350
+ next: null,
1227
1351
  recovery: [],
1228
1352
  };
1229
1353
  }
@@ -1231,7 +1355,7 @@ function railBookingEnvelope(model) {
1231
1355
  return {
1232
1356
  status: "manual_review",
1233
1357
  result,
1234
- instruction: "告诉用户:付款已确认,订单需要人工核对;请勿重复付款或重新下单,已出票的车票会保留。不要承诺退款结果或时效。",
1358
+ instruction: `告诉用户:付款已确认,已出票 ${rail.issued_legs}/${rail.legs.length},其余需要人工核对;请勿重复付款或重新下单,已出票的车票会保留。未知结果先对账,退款按对应段处理,不承诺整笔退款或时效。`,
1235
1359
  next: orderID ? { command: `itpay order ${orderID} --json`, reason: "查看同一订单当前状态" } : null,
1236
1360
  recovery: [],
1237
1361
  };
@@ -1244,7 +1368,7 @@ function railBookingEnvelope(model) {
1244
1368
  return {
1245
1369
  status: "issuing",
1246
1370
  result,
1247
- instruction: "告诉用户:付款已确认,后台正在出票;付款成功不代表已出票,请勿重复购买或再次付款。稍后只读取同一任务。",
1371
+ instruction: `告诉用户:付款已确认,已出票 ${rail.issued_legs}/${rail.legs.length},后台继续处理;付款成功不代表整程出票,请勿重复购买或再次付款。稍后只读取同一任务。`,
1248
1372
  next: { command: `itpay services next ${execution.service_execution_id} --json`, reason: "稍后读取同一出票任务" },
1249
1373
  recovery: [],
1250
1374
  };
@@ -1257,7 +1381,111 @@ function railBookingEnvelope(model) {
1257
1381
  recovery: [],
1258
1382
  };
1259
1383
  }
1260
- function railJourneySummary(card, serviceExecutionID) {
1384
+ function compactRailJourney(card, serviceExecutionID, snapshotID) {
1385
+ if (!card.journey_id)
1386
+ throw new Error("saved journey has no stable ID");
1387
+ const rides = (card.rides ?? []).map((ride) => ({
1388
+ train_code: ride.train_code ?? (Array.isArray(ride.train_codes) ? ride.train_codes.join("/") : null),
1389
+ from_station: ride.from_station ?? null, from_station_code: ride.from_station_code ?? null,
1390
+ departure: ride.departure ?? null, to_station: ride.to_station ?? null,
1391
+ to_station_code: ride.to_station_code ?? null, arrival: ride.arrival ?? null,
1392
+ ...(typeof ride.wait_minutes === "number" ? { wait_minutes: ride.wait_minutes } : {}),
1393
+ ...(typeof ride.minimum_connection_minutes === "number" ? { required_buffer_minutes: ride.minimum_connection_minutes } : {}),
1394
+ ...(ride.same_run === true ? { same_run: true } : {}),
1395
+ }));
1396
+ const offer = card.representative_offer;
1397
+ const metrics = card.representative_metrics;
1398
+ const ticketSeats = (offer?.ticket_offers ?? []).map((raw) => ({
1399
+ leg_index: raw.leg_index, seat_type: raw.seat_type, seat_name: raw.seat_name,
1400
+ availability: raw.availability_text, remaining: raw.inventory_exact === true ? raw.remaining : null,
1401
+ }));
1402
+ const railwayTime = (value) => {
1403
+ if (typeof value !== "string")
1404
+ return NaN;
1405
+ const time = value.replace(" ", "T");
1406
+ return Date.parse(/(?:Z|[+-]\d{2}:\d{2})$/.test(time) ? time : `${time}Z`);
1407
+ };
1408
+ const start = railwayTime(rides[0]?.departure);
1409
+ const end = railwayTime(rides.at(-1)?.arrival);
1410
+ return {
1411
+ journey_ref: card.journey_id,
1412
+ ...(card.profile_refs?.balanced ? { profile_ref: card.profile_refs.balanced } : {}),
1413
+ default_layer: card.default_layer ?? "main",
1414
+ ...(card.backup_reason ? { backup_reason: card.backup_reason } : {}),
1415
+ ...(card.backup_baseline_min != null ? { backup_baseline_min: card.backup_baseline_min } : {}),
1416
+ ...(card.backup_delta_min != null ? { backup_delta_min: card.backup_delta_min } : {}),
1417
+ route: card.route_text ?? (card.route_names ?? card.route ?? []).join("→"),
1418
+ rides,
1419
+ ...(card.booking_support === "single_leg" && card.qualification_status === "eligible" && card.booking_offer ? {
1420
+ booking_template: { command: `itpay services run ${card.booking_offer.service_id} --input-json <购票.json> --json`, executable: false,
1421
+ seat_choices: ticketSeats.map(seat => ({ seat_type: seat.seat_type, seat_name: seat.seat_name })),
1422
+ input_example: { selection: { token: card.booking_offer.selection_token, seat_type: "<返回席别代码>" }, passengers: card.passengers ?? offer?.ticket_offers?.[0]?.passengers ?? "<真实人数>" },
1423
+ required_input: ["file", "真实席别选择及人数"] },
1424
+ } : {}),
1425
+ ...(ticketSeats.length ? { seats: ticketSeats } : {}),
1426
+ ticket_plan_refs: (card.ticket_plans ?? []).map(plan => plan.ticket_plan_ref),
1427
+ transfer_wait_minutes: card.transfer_wait_minutes ?? [],
1428
+ ground_summary: card.ground_summary ?? null,
1429
+ passengers: card.passengers ?? offer?.ticket_offers?.[0]?.passengers ?? null,
1430
+ availability: card.availability ?? "unknown",
1431
+ qualification_status: card.qualification_status ?? "unknown",
1432
+ ...(card.qualification_reasons?.length ? { qualification_reasons: card.qualification_reasons } : {}),
1433
+ ...(offer ? { rail_payable_fen: offer.rail_payable_minor, currency: offer.currency } : { rail_payable_fen: null }),
1434
+ estimated_trip_total: metrics?.estimated_total_cost ?? null,
1435
+ cost_basis: metrics?.cost_basis ?? "unknown",
1436
+ duration_basis: metrics?.duration_basis ?? "unknown",
1437
+ timezone: "Asia/Shanghai",
1438
+ ...(card.risk_notes?.length ? { risk_notes: card.risk_notes } : {}),
1439
+ rail_duration_minutes: Number.isFinite(start) && Number.isFinite(end) && end >= start ? Math.round((end - start) / 60000) : null,
1440
+ ...(card.observed_at ? { observed_at: card.observed_at } : {}),
1441
+ detail: { command: `itpay services read-result ${serviceExecutionID} --snapshot ${snapshotID} --journey ${card.journey_id} --json`,
1442
+ reason: card.qualification_status === "eligible" ? "读取此完整线路的席别、报价和购买条件" : "读取此线路的铁路事实和不可购买原因" },
1443
+ };
1444
+ }
1445
+ function compactRailTicketPlan(raw, journeyEligible) {
1446
+ const legs = Array.isArray(raw.legs) ? raw.legs : [];
1447
+ const seats = Array.isArray(raw.seat_offers) ? raw.seat_offers : [];
1448
+ const tickets = Array.isArray(raw.ticket_offers) ? raw.ticket_offers : [];
1449
+ return {
1450
+ ticket_plan_ref: raw.ticket_plan_ref,
1451
+ passengers: raw.passenger_count ?? (Array.isArray(raw.ticket_offers) ? raw.ticket_offers[0]?.passengers : null) ?? null,
1452
+ purchase_support: raw.purchase_support ?? "none",
1453
+ qualification_status: raw.qualification_status ?? "unknown",
1454
+ qualification_reasons: raw.qualification_reasons ?? [],
1455
+ rail_amount_fen: raw.rail_amount_fen ?? null,
1456
+ service_fee_fen: raw.service_fee_fen ?? null,
1457
+ price_verified: raw.rail_amount_verified === true,
1458
+ ...(journeyEligible && raw.qualification_status === "eligible" && raw.booking_input && typeof raw.booking_input === "object" ? {
1459
+ booking_template: { command: "itpay services run itpay-rail-booking --input-json <journey.json> --json", executable: false, ticket_plan_ref: raw.ticket_plan_ref, input_example: raw.booking_input, required_input: ["file", "passengers"] },
1460
+ } : {}),
1461
+ legs: legs.map((leg, index) => ({
1462
+ leg_index: index,
1463
+ travel_date: leg.travel_date ?? tickets.find(ticket => ticket.leg_index === index)?.travel_date ?? null,
1464
+ ...(() => {
1465
+ const ticket = tickets.find(ticket => ticket.leg_index === index) ?? (tickets[index]?.leg_index === undefined ? tickets[index] : undefined);
1466
+ const offer = ticket?.booking_offer;
1467
+ return legs.length === 1 && offer?.service_id && offer.selection_token && journeyEligible && raw.qualification_status === "eligible" ? {
1468
+ booking_template: { command: `itpay services run ${offer.service_id} --input-json <购票.json> --json`, executable: false,
1469
+ ticket_plan_ref: raw.ticket_plan_ref, leg_index: index,
1470
+ seat_choices: [{ seat_type: ticket?.seat_type, seat_name: ticket?.seat_name }],
1471
+ input_example: { selection: { token: offer.selection_token, seat_type: ticket?.seat_type ?? "<该方案席别代码>" },
1472
+ passengers: ticket?.passengers ?? raw.passenger_count ?? "<真实人数>" },
1473
+ required_input: ["file", "真实人数", "该方案席别代码"],
1474
+ },
1475
+ } : legs.length > 1 ? {} : { booking_unavailable: "本段没有当前可用的服务端凭据;不能猜代码或例行重查。保留快照,按当前执行的恢复动作处理;过期凭据需真实新验证。" };
1476
+ })(),
1477
+ train_code: leg.train_code ?? null,
1478
+ from_station: leg.from_station ?? null, from_station_code: leg.from_station_code ?? null,
1479
+ departure: leg.departure ?? null,
1480
+ to_station: leg.to_station ?? null, to_station_code: leg.to_station_code ?? null,
1481
+ arrival: leg.arrival ?? null,
1482
+ seats: (seats[index] ?? []).map((seat) => ({ seat_type: seat.seat_type, seat_name: seat.seat_name,
1483
+ availability: seat.availability_text, remaining: seat.inventory_exact === true ? seat.remaining : null,
1484
+ unit_fare_fen: seat.fare_minor, eligible: seat.eligible === true })),
1485
+ })),
1486
+ };
1487
+ }
1488
+ function railJourneySummary(card, serviceExecutionID, snapshotID) {
1261
1489
  const rides = Array.isArray(card.rides) ? card.rides : [];
1262
1490
  const trains = rides.map((ride) => ride.train_code).filter(Boolean);
1263
1491
  const first = rides[0];
@@ -1266,6 +1494,7 @@ function railJourneySummary(card, serviceExecutionID) {
1266
1494
  const metrics = card.representative_metrics;
1267
1495
  return {
1268
1496
  journey_id: card.journey_id,
1497
+ ...(snapshotID ? (() => { const detail = compactRailJourney(card, serviceExecutionID, snapshotID); return { rides: detail.rides, seats: detail.seats, default_layer: detail.default_layer, detail: detail.detail, ...(detail.booking_template ? { booking_template: detail.booking_template } : {}) }; })() : {}),
1269
1498
  route: (card.route_names?.length ? card.route_names : card.route ?? []).join("→"),
1270
1499
  ...(trains.length ? { trains } : {}),
1271
1500
  ...(first?.departure || last?.arrival ? { time: `${first?.departure ?? ""}–${last?.arrival ?? ""}` } : {}),
@@ -1294,29 +1523,6 @@ function railJourneySummary(card, serviceExecutionID) {
1294
1523
  select_delegated: `itpay services action ${serviceExecutionID} --action select_journey --actor-type agent --status approved --input journey_id=${card.journey_id} --input selection_mode=delegated --json`,
1295
1524
  }
1296
1525
  : {}),
1297
- ...(card.booking_offer
1298
- ? {
1299
- booking_offer: card.booking_offer,
1300
- booking_template: {
1301
- command: `itpay services run ${card.booking_offer.service_id} --input-json <file> --json`,
1302
- required_input: ["file"],
1303
- executable: false,
1304
- seat_choices: (card.representative_offer?.ticket_offers ?? []).map((offer) => ({
1305
- seat_type: offer.seat_type,
1306
- seat_name: offer.seat_name,
1307
- })).filter((seat) => typeof seat.seat_type === "string"),
1308
- input_example: {
1309
- selection: {
1310
- token: card.booking_offer.selection_token,
1311
- ...(typeof card.representative_offer?.ticket_offers?.[0]?.seat_type === "string"
1312
- ? { seat_type: card.representative_offer.ticket_offers[0].seat_type }
1313
- : {}),
1314
- },
1315
- passengers: card.passengers ?? 1,
1316
- },
1317
- },
1318
- }
1319
- : {}),
1320
1526
  };
1321
1527
  }
1322
1528
  // rail.progressive.v2 read projection: committed snapshots only; polling never
@@ -1328,11 +1534,19 @@ function railPlanningEnvelope(model) {
1328
1534
  const expansion = plan.search?.expansion_status ?? "running";
1329
1535
  const counts = plan.search?.counts;
1330
1536
  const journeys = [plan.recommendation, ...(plan.alternatives ?? [])].filter(Boolean);
1331
- const cards = journeys.map((card) => railJourneySummary(card, se));
1537
+ const hasEligible = typeof counts?.journeys_eligible === "number"
1538
+ ? counts.journeys_eligible > 0 : journeys.length > 0;
1539
+ const changedScopeHint = plan.query_input && !/supplier|provider|dispatch|map|amap|permission/.test(plan.search?.reason ?? "")
1540
+ ? "若原条件锁定车站,可提议仅放宽一端到原站周边;有已核实地点证据才建议具体站,不能说肯定有票。先询问用户是否接受该端范围变化;同意后按new_query_template开新查询,未同意则保持原站。"
1541
+ : /supplier|provider|dispatch|map|amap|permission/.test(plan.search?.reason ?? "")
1542
+ ? "依赖故障按原恢复动作处理,不通过换站掩盖故障。"
1543
+ : "如需改变端点,先取得真实范围变更同意,再按正式铁路指南保留原条件发起新查询。";
1544
+ const cards = journeys.filter(card => card.journey_id !== plan.recommendation?.journey_id)
1545
+ .map((card) => railJourneySummary(card, se, plan.catalog_snapshot_id ?? plan.snapshot_id));
1332
1546
  // Journey counts come from the committed catalog — real combination counts,
1333
1547
  // never seat-row counts. Transfer buckets follow the verified transfer_count.
1334
1548
  const journeyMix = counts && (counts.journeys_total ?? 0) > 0
1335
- ? `${counts.journeys_total}个铁路组合(直达${counts.journeys_direct ?? 0}/1中转${counts.journeys_one_transfer ?? 0}/2中转${counts.journeys_multi_transfer ?? 0})`
1549
+ ? `已保存${counts.journeys_total}个具体车次时刻组合,其中${counts.journeys_eligible ?? "未知"}条当前满足铁路资格(直达${counts.journeys_direct ?? 0}/1中转${counts.journeys_one_transfer ?? 0}/2中转${counts.journeys_multi_transfer ?? 0};这些不是不同站序的数量)`
1336
1550
  : "";
1337
1551
  const pairProgress = counts && (counts.pairs_total ?? 0) > 0
1338
1552
  ? `已检查${counts.pairs_checked ?? 0}/${counts.pairs_total}个附近站对`
@@ -1352,6 +1566,7 @@ function railPlanningEnvelope(model) {
1352
1566
  rail_planning: {
1353
1567
  readiness: plan.readiness,
1354
1568
  query_revision: plan.query_revision,
1569
+ ...(plan.query_input ? { endpoints: plan.query_input.endpoints } : {}),
1355
1570
  snapshot_id: plan.snapshot_id,
1356
1571
  expansion_status: expansion,
1357
1572
  ...(plan.search?.phase ? { phase: plan.search.phase } : {}),
@@ -1365,67 +1580,24 @@ function railPlanningEnvelope(model) {
1365
1580
  ...(plan.search?.poll_after_ms ? { poll_after_ms: plan.search.poll_after_ms } : {}),
1366
1581
  ...(plan.result_not_updated ? { result_not_updated: true } : {}),
1367
1582
  ...(counts ? { counts } : {}),
1368
- ...(plan.budgets ? { budgets: plan.budgets } : {}),
1369
1583
  ...(plan.coverage ? { coverage: plan.coverage } : {}),
1370
1584
  },
1371
1585
  ...(plan.notices?.length ? { notices: plan.notices } : {}),
1372
- ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se) } : {}),
1586
+ ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se, plan.catalog_snapshot_id ?? plan.snapshot_id) } : {}),
1373
1587
  ...(cards.length ? { journeys: cards } : {}),
1374
1588
  ...(plan.snapshot_id ? { full_result: { command: `itpay services read-result ${se} --snapshot ${plan.snapshot_id} --json`, meaning: "读取已保存的完整目录;推荐和journeys仅是摘要" } } : {}),
1589
+ ...(!hasEligible && ["paused", "complete", "expired"].includes(expansion) && plan.query_input && plan.catalog_snapshot_id ? { new_query_template: {
1590
+ command: "itpay services run itpay-rail-smart --input-json <file> --json",
1591
+ executable: false,
1592
+ input_example: { ...plan.query_input, reuse_from: { execution_id: se, snapshot_id: plan.catalog_snapshot_id } },
1593
+ meaning: "先取得真实范围变更同意,再复制完整input_example,仅改获准端。换具体站用station及匹配名称/站码;考虑原站周边用place及原站名,删除station_code,经正常地点解析或使用已核实POI/坐标;只有明确同意整个市才用area及完整行政名,删除POI/坐标/站码。日期、人数、时限及偏好保持;新执行不改变旧查询或订单。引用仅限同owner未过期事实,跨owner拒绝;日期变更不复用旧库存。",
1594
+ } } : {}),
1375
1595
  ...(plan.available_actions?.length ? { available_actions: plan.available_actions } : {}),
1376
1596
  };
1377
1597
  const nextPoll = {
1378
1598
  command: `itpay services next ${se} --timeout 120${plan.snapshot_id ? ` --since-snapshot ${plan.snapshot_id}` : ""} --json`,
1379
1599
  reason: "仍需更多结果时有界等待同一规划(不触发供应商调用)",
1380
1600
  };
1381
- // Two-goal interaction contract (spec 03 §2): the local Agent picks compare
1382
- // or prepare_checkout from full context — the envelope teaches both branches
1383
- // so a delegated purchase keeps moving and a comparison never buys.
1384
- const bookable = journeys.find((card) => card.booking_support === "single_leg" && card.booking_offer);
1385
- const queryResultsInteraction = journeys.length > 0
1386
- ? {
1387
- schema_version: "itpay.interaction.v1",
1388
- stage: "query_results_ready",
1389
- by_goal: {
1390
- compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
1391
- prepare_checkout: {
1392
- action: "select_under_user_rules_then_prepare",
1393
- recipe: "rail.selected-to-checkout.v1",
1394
- requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
1395
- ...(bookable
1396
- ? {
1397
- steps: [
1398
- "按用户明确规则在 journeys 中选择合格者(卡片 select_delegated 提交 agent 委托选择;人类亲自选择用 select_user_chosen)",
1399
- `用所选卡片的 booking_template 写 owner-only 临时输入(selection token + 席别 + 人数,不含身份信息),运行 itpay services run itpay-rail-booking --input-json <file> --json`,
1400
- "owner 要求聊天 review 时一次合并补齐必要字段,真实确认后提交原 workflow:confirm_booking;不得伪造 accept",
1401
- `确认后恢复同一购买执行:itpay services run itpay-rail-booking --execution <实际ID> --json,沿用有界等待与受保护 Checkout`,
1402
- ],
1403
- }
1404
- : { unavailable: "当前结果无可整段购买映射;分别说明各段风险,不得合成原子联程。" }),
1405
- },
1406
- },
1407
- }
1408
- : undefined;
1409
- const queryResultsCommunication = journeys.length > 0
1410
- ? {
1411
- schema_version: "itpay.communication.v1",
1412
- status_line: expansion === "paused"
1413
- ? "首批结果已就绪,扩展搜索已暂停等待用户决定"
1414
- : "查询结果就绪",
1415
- recommended_reason: plan.recommendation?.explanation?.[0],
1416
- human_steps: [
1417
- "compare:比较首选与最多两个备选后告知取舍",
1418
- "prepare_checkout:委托仍在时确认所选,继续官方确认页",
1419
- "姓名、证件、手机号只在受保护 Checkout 页填写",
1420
- ],
1421
- next_expectation: "compare:用户选定其一;prepare_checkout:委托仍在时继续到官方确认入口,人完成review与付款",
1422
- must_convey: [
1423
- "推荐与备选的时间/费用/便利性取舍",
1424
- "金额为查询时参考价,下单前须核验",
1425
- "姓名、证件、手机号只在受保护网页填写",
1426
- ],
1427
- }
1428
- : undefined;
1429
1601
  switch (expansion) {
1430
1602
  case "complete":
1431
1603
  if (plan.search?.reason === "ground_evidence_required") {
@@ -1435,18 +1607,30 @@ function railPlanningEnvelope(model) {
1435
1607
  next: null, recovery: [],
1436
1608
  };
1437
1609
  }
1610
+ if (!hasEligible && !plan.result_not_updated) {
1611
+ return {
1612
+ status: "no_result", result,
1613
+ instruction: `本次已查询范围内没有当前合格线路。原因以 notices、coverage 和 search.reason 为准;缺票、接续失败、依赖故障或未覆盖范围不能改说成没有车。已有铁路事实可从 full_result 读取。${changedScopeHint}后续使用 available_actions 中与用户意图一致的动作。`,
1614
+ next: null, recovery: [],
1615
+ };
1616
+ }
1438
1617
  return {
1439
1618
  status: "ready",
1440
1619
  result,
1441
1620
  // §7.3: the lead line carries real combination counts bucketed by
1442
1621
  // verified transfer count — seat rows never inflate the journey count.
1443
- instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}推荐和journeys是摘要;追问其他车次先读取 full_result 的已保存完整目录。compare:解释首选及最多两个有意义备选的时间/费用/便利性取舍后等待用户选择。prepare_checkout:用户已明确委托按规则选择时,按 interaction.recipe 选合格者并继续到官方确认页,不再次问是否下单;身份、review和最终付款边界仍然生效。乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1622
+ instruction: `${plan.result_not_updated ? "快照没有更新,空增量不表示目录为空。" : ""}${hasEligible && journeys.length === 0 ? `已保存${counts?.journeys_eligible ?? "若干"}条合格线路,执行 full_result.command 读取。` : ""}${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}推荐和journeys是摘要;追问其他车次先读取 full_result 的已保存完整目录。比较需求说明首选与备选取舍后结束;已有明确代选委托则沿合格线路详情继续,只补缺失的真实同意,身份信息仍在官方页面填写。`,
1444
1623
  next: null,
1445
- ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1446
- ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1447
1624
  recovery: [],
1448
1625
  };
1449
1626
  case "paused": {
1627
+ if (plan.search?.reason === "legacy_plan_requires_new_search") {
1628
+ return {
1629
+ status: "awaiting_input", result,
1630
+ instruction: "这次规划使用旧算法快照,当前版本已暂停后续查询。已保存的结果仍可读取;若需继续覆盖未查范围,请按原出行条件发起一项新的 Smart 查询。不要把旧快照解释为新版完整结果。",
1631
+ next: null, recovery: [],
1632
+ };
1633
+ }
1450
1634
  // §7.1 case 1: usable directs committed while ranked scope remains. The
1451
1635
  // message must carry real counts, and expansion is a user choice — the
1452
1636
  // Agent must not treat "expandable" as consent already given.
@@ -1469,7 +1653,7 @@ function railPlanningEnvelope(model) {
1469
1653
  // §7.1 case 3 on a recoverable pause: unverified scope is named with real
1470
1654
  // counts, and the phrasing never implies the whole nearby range ran.
1471
1655
  const partialPause = !directHint && counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1472
- ? `目前找到${counts.journeys_total ?? 0}个可选组合;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1656
+ ? `已保存${counts.journeys_total ?? 0}个具体车次时刻组合,其中${counts.journeys_eligible ?? "未知"}条当前满足铁路资格;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1473
1657
  : "";
1474
1658
  // §S5: a dispatch whose outcome is unknown pauses for reconciliation —
1475
1659
  // the message names the state, never rephrases it as a normal pause or
@@ -1487,21 +1671,29 @@ function railPlanningEnvelope(model) {
1487
1671
  return {
1488
1672
  status: "awaiting_input",
1489
1673
  result,
1490
- instruction: `${modelDegradedHint}${failedPairsHint}${directHint}${transferHint}${partialPause}规划已发布首批结果并暂停扩展:展示现有卡片与 available_actions,等待用户意图(${expandHint})。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。compare:解释首选及最多两个备选后等待;prepare_checkout:委托仍在且选项合格时按 interaction.recipe 继续到官方确认入口,不再次问是否下单。`,
1674
+ instruction: `${modelDegradedHint}${failedPairsHint}${directHint}${transferHint}${partialPause}${hasEligible ? "规划已发布可用结果并暂停扩展:展示现有卡片与 available_actions" : `当前已查范围尚未找到合格线路,搜索已暂停;未查范围与故障以coverage/notices为准,不是完整无解。${changedScopeHint}展示 available_actions`},等待用户意图(${expandHint})。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。比较需求说明首选与备选取舍后结束;已有代选委托且选项合格时沿详情继续到必要确认与官方入口。`,
1491
1675
  next: null,
1492
- ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1493
- ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1494
1676
  recovery: [],
1495
1677
  };
1496
1678
  }
1497
1679
  case "failed":
1498
1680
  case "cancelled":
1499
1681
  case "expired": {
1682
+ if (expansion === "failed" && !plan.snapshot_id &&
1683
+ ["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 ?? "")) {
1684
+ return {
1685
+ status: "awaiting_input", result,
1686
+ instruction: plan.search?.reason === "station_location_evidence_required"
1687
+ ? "尚未查票。车站身份明确,但 Smart 缺少必要地理证据;这是系统证据缺口,不要求用户换站。若双锁站仅需直达,可使用 Exact。"
1688
+ : "尚未查票。受影响端点的名称、归属或坐标尚不能一致确认;只补正该端点的真实信息,保留其余条件,不把此次失败说成无车。",
1689
+ next: null, recovery: [],
1690
+ };
1691
+ }
1500
1692
  // §7.1 case 3: when scope was only partially verified the honest wording
1501
1693
  // is "N confirmed + X pairs unverified" — never the case-2 phrasing that
1502
1694
  // implies the whole nearby range was checked.
1503
1695
  const partial = counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1504
- ? `目前找到${counts.journeys_total ?? 0}个可选组合;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1696
+ ? `已保存${counts.journeys_total ?? 0}个具体车次时刻组合,其中${counts.journeys_eligible ?? "未知"}条当前满足铁路资格;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1505
1697
  : "";
1506
1698
  // §S5: a processing failure with a committed catalog is "saved N
1507
1699
  // verified combinations, later expansion/recommend incomplete" — the
@@ -1539,17 +1731,6 @@ function railPlanningEnvelope(model) {
1539
1731
  result,
1540
1732
  instruction: `${modelDegradedHint}${manualExpansion}${transferHint}${failedPairsHint}规划进行中:已提交的卡片可先向用户展示比较;稍后按 next 增量轮询同一执行(since-snapshot 命中时负载会被压缩,但阶段与计数仍返回最新值)。不要重新发起查询。`,
1541
1733
  next: nextPoll,
1542
- communication: {
1543
- schema_version: "itpay.communication.v1",
1544
- status_line: transferSearching && !manualExpansion
1545
- ? "直达范围暂未找到可用方案,中转组合仍在搜索"
1546
- : "规划进行中,已确认的方案可先向用户展示",
1547
- next_expectation: "按 next 增量轮询同一执行;不重新发起查询",
1548
- must_convey: [
1549
- "当前是进行中的部分结果,不代表最终答复",
1550
- ...(failedPairsHint ? ["部分站对尚未查明,不能说'没有车'"] : []),
1551
- ],
1552
- },
1553
1734
  recovery: [],
1554
1735
  };
1555
1736
  }
@@ -1594,34 +1775,25 @@ function terminalExecutionEnvelope(model) {
1594
1775
  ],
1595
1776
  };
1596
1777
  }
1778
+ const RAIL_LOCATION_EVIDENCE_REASONS = new Set(["endpoint_kind_conflict", "place_identity_uncertain", "endpoint_adcode_name_conflict", "endpoint_poi_name_conflict", "endpoint_station_name_code_conflict", "location_evidence_required", "coordinate_name_mismatch", "area_scope_too_broad", "address_required", "administrative_area_unresolved", "station_location_evidence_required", "coordinate_identity_unverified"]);
1779
+ const RAIL_QUOTE_FAILURE_GUIDANCE = {
1780
+ quote_source_unavailable: "实时车票数据暂不可用;保留原选择,稍后按用户意愿重新核验,不创建替代订单。",
1781
+ quote_train_changed: "所选车次的时刻或运行信息已变化;向用户说明并征询是否重新查票。",
1782
+ quote_seat_changed: "所选席别已变化;向用户说明并征询新的席别选择。",
1783
+ quote_seat_unavailable: "所选席别当前无足够余票;告知用户并由其决定是否换方案。",
1784
+ quote_refresh_expired: "实时报价过程超时;保留原选择,不把旧价当可付款价格。",
1785
+ };
1597
1786
  function servicesNextEnvelope(model) {
1598
1787
  const execution = model.execution;
1599
1788
  const currentDelivery = model.current_delivery ?? model.delivery_bindings.at(-1);
1600
1789
  const lockedRefund = model.refunds.find((refund) => refund.access_locked);
1601
1790
  if (lockedRefund) {
1602
- const terminal = lockedRefund.status === "succeeded";
1603
- return {
1604
- status: "delivery_locked",
1605
- result: {
1606
- service_execution_id: execution.service_execution_id,
1607
- access_locked: true,
1608
- refund: {
1609
- refund_request_id: lockedRefund.refund_request_id,
1610
- status: lockedRefund.status,
1611
- },
1612
- },
1613
- instruction: terminal
1614
- ? "告诉用户退款已由 ItPay 确认成功,原交付永久关闭。Agent 停止读取和跟踪,不再创建授权。"
1615
- : "告诉用户退款仍在处理,原交付已按政策冻结。然后读取同一退款的权威状态;Agent 不读取交付、不创建授权或重复申请。",
1616
- next: terminal ? null : {
1617
- command: `itpay refund get ${lockedRefund.refund_request_id} --json`,
1618
- reason: "读取退款权威状态",
1619
- },
1620
- recovery: [],
1621
- };
1791
+ const refundState = refundStateEnvelope(lockedRefund, "delivery_locked");
1792
+ 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 } : {}) } },
1793
+ next: refundState.next ? { command: `itpay refund get ${lockedRefund.refund_request_id} --json`, reason: "读取同一退款权威状态" } : null };
1622
1794
  }
1623
1795
  const latestRun = model.execution_requests?.filter((request) => request.execution_kind === "service.execution.run").at(-1);
1624
- if (latestRun && ["pending", "started"].includes(latestRun.status)) {
1796
+ if (latestRun && ["pending", "started"].includes(latestRun.status) && model.workflow?.status !== "failed") {
1625
1797
  return {
1626
1798
  status: "running",
1627
1799
  result: { service_execution_id: execution.service_execution_id, execution_request_id: latestRun.execution_request_id },
@@ -1633,7 +1805,7 @@ function servicesNextEnvelope(model) {
1633
1805
  // A terminal execution wins over the rail read model: a failed, cancelled,
1634
1806
  // or refunded run must not render as still issuing. Completed executions
1635
1807
  // keep the rail view so issued seats stay visible.
1636
- if (model.rail_booking && isTerminalServiceExecutionStatus(execution.status) && !["completed", "delivery_completed"].includes(execution.status)) {
1808
+ if (model.rail_booking && model.workflow?.human_action?.context?.quote_recovery !== true && isTerminalServiceExecutionStatus(execution.status) && !["completed", "delivery_completed"].includes(execution.status)) {
1637
1809
  const terminal = terminalExecutionEnvelope(model);
1638
1810
  if (terminal)
1639
1811
  return terminal;
@@ -1653,10 +1825,57 @@ function servicesNextEnvelope(model) {
1653
1825
  }
1654
1826
  if (model.workflow_entry && !["completed", "delivery"].includes(model.workflow?.status ?? "")) {
1655
1827
  const id = execution.service_execution_id;
1828
+ const workflowReason = model.workflow?.failure?.reason_code ?? model.workflow?.error_code;
1656
1829
  const paymentVerified = model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
1657
1830
  const state = model.workflow?.status === "payment" && paymentVerified ? "running" : model.workflow?.status ?? "input_required";
1658
- if (state === "failed" && ["login_required", "rate_limited"].includes(model.workflow?.error_code ?? "")) {
1659
- const login = model.workflow?.error_code === "login_required";
1831
+ if (["quota_paused", "failed"].includes(state) && !RAIL_LOCATION_EVIDENCE_REASONS.has(workflowReason ?? "") && (model.workflow?.dependency ||
1832
+ /^amap_/.test(workflowReason ?? ""))) {
1833
+ const dependency = model.workflow?.dependency;
1834
+ const resumable = state === "quota_paused" && dependency?.retryable === true;
1835
+ return {
1836
+ status: "dependency_unavailable",
1837
+ 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 } : {}) },
1838
+ instruction: resumable
1839
+ ? "地图服务暂时限流。保留原条件及已解析端点;到 retry_at 后执行下一步,继续同一任务,不重新提交地点。"
1840
+ : "地图服务本轮未完成解析,这是外部依赖故障,不要求用户换地址或自行找坐标。有界尝试已结束;保留原条件及已有结果,交由服务维护方处理该错误。",
1841
+ next: resumable ? { command: `itpay services run ${execution.service_id} --execution ${id} --json`, reason: "等待所示恢复时间后继续同一执行" } : null,
1842
+ recovery: [],
1843
+ };
1844
+ }
1845
+ if (state === "failed" && RAIL_LOCATION_EVIDENCE_REASONS.has(workflowReason ?? "")) {
1846
+ const code = workflowReason;
1847
+ const affected = model.workflow?.failure?.endpoint ?? model.workflow?.dependency?.endpoint;
1848
+ const query = model.workflow?.query_input ?? {};
1849
+ const endpoints = query.endpoints && typeof query.endpoints === "object" ? query.endpoints : {};
1850
+ const stationPair = [endpoints.origin, endpoints.destination].every(endpoint => endpoint?.kind === "station" && typeof endpoint.text === "string" && endpoint.text.trim() && typeof endpoint.station_code === "string" && /^[A-Z]{3}$/.test(endpoint.station_code));
1851
+ if (code === "station_location_evidence_required") {
1852
+ const exactInput = stationPair && typeof query.travel_date === "string" ? { origin: endpoints.origin.text, destination: endpoints.destination.text, travel_date: query.travel_date } : undefined;
1853
+ return { status: "system_evidence_required", result: { service_execution_id: id, service_id: execution.service_id, reason: code, affected_endpoint: affected, failure: model.workflow?.failure, query,
1854
+ ...(exactInput ? { new_query_template: { command: "itpay services run itpay-rail-exact --input-json <双锁站直达.json> --json", input: exactInput, executable: false, required_input: ["file"], instruction: "当前明确双锁站可核验直达;Exact只覆盖这一站对,仍按原人数、时限和偏好核对结果。" } } : {}) },
1855
+ instruction: "尚未查票。车站身份已知,但Smart缺车站坐标证据;这是系统证据缺口,不要求用户填技术坐标或换站。" + (exactInput ? "当前双锁站可按完整模板另用Exact核验直达,原需求见query。" : "本路线Smart暂受系统证据限制,当前无可执行恢复;停止并交由维护方补证据。"), next: null, recovery: [] };
1856
+ }
1857
+ if (affected !== "origin" && affected !== "destination")
1858
+ return { status: "input_evidence_required", result: { service_execution_id: id, reason: code, failed_step: model.workflow?.failure?.step_id, failure: model.workflow?.failure, query }, instruction: "地点问题未明确受影响侧,当前无可提交修正模板;保留原需求,停止并核对服务端事实,不猜另一端或坐标。", next: null, recovery: [] };
1859
+ const properties = model.workflow_entry.input_schema.properties;
1860
+ if (!properties?.endpoints && properties?.[affected] === undefined)
1861
+ return { status: "contract_unavailable", result: { service_execution_id: id, reason: code, failed_step: model.workflow?.failure?.step_id, failure: model.workflow?.failure, query }, instruction: "旧查询已终止,当前锁定合同缺少可用地点字段;保留原需求并停止,不猜新旧模板或隐藏字段。", next: null, recovery: [] };
1862
+ const input = { ...Object.fromEntries(Object.entries(query).filter(([key]) => properties?.[key] !== undefined)), ...serviceInputTemplate(model.workflow_entry.input_schema, query) };
1863
+ const identityConflict = ["endpoint_kind_conflict", "endpoint_adcode_name_conflict", "endpoint_poi_name_conflict", "endpoint_station_name_code_conflict", "coordinate_name_mismatch"].includes(code);
1864
+ const replacement = code === "area_scope_too_broad" ? { kind: "area", text: "<受影响端的市县镇完整名称>" } : { ...(endpoints[affected]?.kind ? { kind: endpoints[affected].kind } : {}), text: code === "coordinate_identity_unverified" ? "<已核实与地点身份一致的完整地址或地点名>" : "<已补充行政归属的完整地址或地点名>" };
1865
+ const endpointContract = properties?.endpoints !== undefined && (query.endpoints !== undefined || !properties.origin || !properties.destination);
1866
+ if (endpointContract)
1867
+ input.endpoints = { ...endpoints, [affected]: replacement };
1868
+ else {
1869
+ input[affected] = replacement.text;
1870
+ delete input[`${affected}_location`];
1871
+ }
1872
+ const originalPlace = endpoints[affected]?.text ?? query[affected];
1873
+ return { status: "awaiting_input", result: { service_execution_id: id, service_id: execution.service_id, reason: code, failed_step: model.workflow?.failure?.step_id, classification: "location_evidence_required", affected_endpoint: affected, original_place: originalPlace, ...(model.workflow?.failure?.resolved_scope ? { resolved_scope: model.workflow.failure.resolved_scope } : {}), failure: model.workflow?.failure, query,
1874
+ new_query_template: { command: `itpay services run ${execution.service_id} --input-json <补全原始需求.json> --json`, input, executable: false, required_input: ["file", endpointContract ? `endpoints.${affected}.text` : affected], ...(endpointContract ? { correction_fields: ["text", "kind", "poi_id", "location"].filter(field => { const side = properties?.endpoints?.properties?.[affected]; return !!side?.properties?.[field]; }) } : {}), instruction: "旧执行输入已锁定;补真实地点后将完整input写入JSON文件,按此命令提交新查询,保留另一端、日期、人数和所有原约束。" } },
1875
+ instruction: `旧执行已终止,尚未查票。${identityConflict ? "名称、类别或位置证据存在冲突;先核实返回原因,不能修改kind绕过。" : "完整地点名称合法,但尚缺唯一身份证据;可先用地图或浏览短查,不要求必填门牌或坐标。"}新证据确定类别且原意未变可纠正kind;改变目的地或扩大范围须先取得真实同意。保留原地点及范围;${affected === "origin" ? "出发端" : "到达端"}原文见original_place。有新证据后只补充${affected === "origin" ? "出发端" : "到达端"}${code === "area_scope_too_broad" ? "的市县镇范围" : code === "coordinate_identity_unverified" ? "的已核实地点身份;已移除未核实坐标,不猜坐标" : "的完整地点和行政归属"}。使用当前完整new_query_template;不能在旧执行伪造修改,也不能直接提交占位符。`, next: null, recovery: [] };
1876
+ }
1877
+ if (state === "failed" && ["login_required", "rate_limited"].includes(workflowReason ?? "")) {
1878
+ const login = workflowReason === "login_required";
1660
1879
  return {
1661
1880
  status: login ? "login_required" : "rate_limited",
1662
1881
  result: { service_execution_id: id, service_id: execution.service_id },
@@ -1677,7 +1896,7 @@ function servicesNextEnvelope(model) {
1677
1896
  recovery: [resume],
1678
1897
  };
1679
1898
  }
1680
- if (state === "human_action" && model.workflow?.human_action) {
1899
+ if ((state === "human_action" || (state === "failed" && model.workflow?.human_action?.context?.quote_recovery === true)) && model.workflow?.human_action) {
1681
1900
  const action = model.workflow.human_action;
1682
1901
  const requiredFields = requiredInputFields(action.input_schema);
1683
1902
  const rawPlaces = action.context?.places;
@@ -1694,29 +1913,43 @@ function servicesNextEnvelope(model) {
1694
1913
  return {
1695
1914
  side,
1696
1915
  status: place?.resolution_status,
1916
+ ...(place?.resolution_reason ? { reason: place.resolution_reason } : {}),
1697
1917
  ...(place?.resolution_status === "resolved"
1698
- ? { resolved_place: { name: name(place), location: place.location } }
1918
+ ? { resolved_place: { name: name(place), address: place.formatted_address, location: place.location } }
1699
1919
  : {}),
1700
1920
  ...(candidates.length
1701
- ? { candidates: candidates.slice(0, 5).map((item) => ({ name: name(item), location: item.location })) }
1921
+ ? { candidates: candidates.slice(0, 3).map((item, index) => ({ id: item.id ?? `${side}_${index + 1}`, name: name(item), address: item.formatted_address, type: item.poi_type ?? item.type, location: item.location })) }
1702
1922
  : {}),
1703
1923
  };
1704
1924
  });
1705
1925
  return {
1706
1926
  status: "confirmation_required",
1707
- result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1708
- required_fields: requiredFields, sides },
1709
- instruction: "向用户展示 sides 中 status=needs_confirmation 一端的候选(名称+坐标),请用户选定后按 required_fields 逐项各传一个 --input:名称取候选 name,坐标取候选 location 的 [lng,lat];已 resolved 的一端把其 resolved_place 原样填入。继续同一执行,不重新查询;这一步不是购买确认。",
1927
+ result: { service_execution_id: id, service_id: execution.service_id,
1928
+ human_action: { action_type: action.action_type, input_schema: action.input_schema },
1929
+ required_fields: requiredFields, sides, query: model.workflow?.query_input,
1930
+ ...(sides.some(side => side.status !== "resolved" && !side.candidates?.length && ["ADDRESS_REQUIRED", "ADMINISTRATIVE_AREA_UNRESOLVED"].includes(side.reason ?? "")) ? {
1931
+ new_query_template: { command: `itpay services run ${execution.service_id} --input-json <补全原始需求.json> --json`, executable: false,
1932
+ input: serviceInputTemplate(model.workflow_entry.input_schema, model.workflow?.query_input ?? {}),
1933
+ required_input: ["补缺失端点", "保留原始日期人数时限偏好及已确认端点"], instruction: "原执行锁定输入;补全原需求后用新查询,不提交不存在的候选。" },
1934
+ } : {}) },
1935
+ instruction: sides.some(side => side.status !== "resolved" && !side.candidates?.length)
1936
+ ? locationConfirmationEnvelope(id, "", {}, { endpoints: sides.filter(side => side.status !== "resolved") }).instruction
1937
+ : requiredFields.includes("choices")
1938
+ ? "尚未查票。仅向用户展示有真实歧义端点的候选编号、名称、行政地址与类型(内部 id 仅用于回填,不展示给人);用户选定后,把各未解析端点的候选 id 放入 choices 对象,继续同一执行。已解析端点无需再选;不得替用户决定,也不是购买确认。"
1939
+ : "向用户展示有歧义一端的候选;按 required_fields 填写真实选择,已解析一端保留原值。继续同一执行;这不是购买确认。",
1710
1940
  next: null,
1711
- interaction: {
1712
- schema_version: "itpay.interaction.v1",
1713
- stage: "location_confirmation_required",
1714
- input_template: {
1715
- command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`,
1716
- required_input: requiredFields,
1717
- executable: false,
1718
- },
1719
- },
1941
+ ...(sides.some(side => side.status !== "resolved" && !side.candidates?.length) ? {} : { interaction: {
1942
+ schema_version: "itpay.interaction.v1",
1943
+ stage: "location_confirmation_required",
1944
+ input_template: {
1945
+ command: requiredFields.includes("choices")
1946
+ ? `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <地点确认.json> --json`
1947
+ : `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`,
1948
+ input: { choices: Object.fromEntries(sides.filter(side => side.candidates?.length).map(side => [side.side, "<所选候选id>"])) },
1949
+ required_input: requiredFields,
1950
+ executable: false,
1951
+ },
1952
+ } }),
1720
1953
  recovery: [],
1721
1954
  };
1722
1955
  }
@@ -1731,25 +1964,25 @@ function servicesNextEnvelope(model) {
1731
1964
  ? review.requirements_remaining
1732
1965
  : undefined;
1733
1966
  return {
1734
- status: "booking_review_required",
1735
- result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1736
- required_fields: requiredFields,
1967
+ status: state === "failed" ? "quote_recovery_required" : "booking_review_required",
1968
+ result: { service_execution_id: id, service_id: execution.service_id, review, ...(state === "failed" ? { failure: model.workflow?.failure } : {}),
1969
+ action_type: action.action_type, required_fields: requiredFields,
1737
1970
  ...(requirementsRemaining ? { requirements_remaining: requirementsRemaining } : {}) },
1738
1971
  ...(reviewURL ? { handoff: { url: reviewURL, kind: "booking_review" } } : {}),
1739
- instruction: reviewURL
1740
- ? "已在 handoff.url 提供官方行程确认页,请用户在该页核对行程、席别与座位偏好并确认;页面打开请求不代表用户已确认。该页失效或用户更愿在对话中确认时,仍可按下述 input_template 一次合并提交。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。"
1741
- : "向用户展示 context.review 中的行程(legs)与可选席别/座位偏好词表(seat_options),并明确告知:座位偏好仅为请求、不保证分配(对应 notice_version 文案须经用户同意)。这是购买前的合并确认:把乘客人数、席别与每位乘客的座位偏好一次问清,然后按 required_fields 组装完整 JSON 对象,经 --input-json <file> 提交——draft_revision 必须等于服务端当前值,被拒绝(booking_review_changed)时重新读取后重试。未得真实确认不得提交,不能暗设 accept_non_guaranteed。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。",
1972
+ instruction: `${state === "failed" ? `${typeof model.workflow?.failure?.affected_leg === "number" ? `第${model.workflow.failure.affected_leg + 1}段` : ""}报价失败:${RAIL_QUOTE_FAILURE_GUIDANCE[model.workflow?.failure?.reason_code ?? ""] ?? "报价核验未完成。"} 当前无完整报价、无付款入口。按用户意愿有界地重新核验一次同一购买;复制下方当前确认模板,未变条款沿用真实同意,价格/车次/席别/人数/条款变化须确认差异。无票或车次变化时先让用户决定,不能自动换路线。支付尝试后不得修订。 ` : ""}${reviewURL ? "官方确认页见 handoff.url,打开不等于确认;沿用已知人数和席别。" : "当前草稿见 review。"} 对照对话只补缺失选择或真实条款同意;无字母偏好自动分配,不默认问字母。仅代码修正且车次、实际席别、人数、价格及条款不变,沿用已有明确确认;实质变化只核对变化。复制 interaction.input_template.input;自动分配使用该对象数组,不传 null 或字符串。只有用户本人已接受当前条款,才把 accept_non_guaranteed 改为 true;选车或委托选票不等于条款同意,false是未同意保护而非模板错误。缺同意时仅用 review.consent_question 问一次;没有该文案时说明座位/卧铺偏好可能自动分配,并按当前notice补整程条款。已有充分同意且条件不变则不再问。身份信息只在官方 Checkout 页填写。`,
1742
1973
  next: null,
1743
1974
  interaction: {
1744
1975
  schema_version: "itpay.interaction.v1",
1745
1976
  stage: "booking_review_required",
1746
1977
  input_template: {
1747
1978
  command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <file> --json`,
1748
- required_input: requiredFields.length > 0 ? requiredFields : ["file"],
1979
+ required_input: ["file", ...requiredFields],
1980
+ input: review.input_template && typeof review.input_template === "object"
1981
+ ? review.input_template : serviceInputTemplate(action.input_schema ?? {}, review),
1749
1982
  executable: false,
1750
1983
  },
1751
1984
  },
1752
- recovery: [{ command: `itpay services next ${id} --json`, reason: "重新读取当前 draft_revision 后重试" }],
1985
+ recovery: [],
1753
1986
  };
1754
1987
  }
1755
1988
  return {
@@ -1775,35 +2008,33 @@ function servicesNextEnvelope(model) {
1775
2008
  command = `itpay services checkout ${id} --json`;
1776
2009
  // input_required: the resume command needs a real file path — expose it as
1777
2010
  // a non-executable input_template, not a fake `next.command`.
2011
+ const guidance = railServiceGuidance(execution.service_id, model.workflow_entry?.input_schema);
1778
2012
  const inputTemplate = state === "input_required"
1779
2013
  ? {
1780
2014
  command: `itpay services run ${execution.service_id} --execution ${id} --input-json <file> --json`,
1781
- required_input: ["file"],
2015
+ input: serviceInputTemplate(model.workflow_entry?.input_schema ?? {}, Object.keys(model.workflow?.query_input ?? {}).length ? (model.workflow?.query_input ?? {}) : guidance?.input_example ?? {}),
2016
+ required_input: ["file", ...requiredInputFields(model.workflow_entry?.input_schema)],
1782
2017
  executable: false,
1783
2018
  }
1784
2019
  : undefined;
1785
- const guidance = railServiceGuidance(execution.service_id);
1786
- const failedStep = recovery ? failedWorkflowStep(model.workflow?.steps, model.workflow?.error_code) : undefined;
2020
+ if (state === "input_required" && execution.service_id === "itpay-rail-smart" && !guidance) {
2021
+ return { status: "contract_unavailable", result: { service_execution_id: id, service_id: execution.service_id }, instruction: "当前锁定合同缺失;读取一次同执行合同后仍缺失就停止,不猜新旧输入结构。", next: { command: `itpay services get ${id} --json`, reason: "获取锁定合同一次" }, recovery: [] };
2022
+ }
2023
+ const failedStep = recovery ? model.workflow?.failure?.step_id : undefined;
1787
2024
  const failedStepGuidance = failedStep ? WORKFLOW_STEP_GUIDANCE[failedStep] : undefined;
1788
2025
  const failedInvocation = recovery
1789
2026
  ? [...model.provider_invocations].reverse().find((item) => typeof item.status === "string" && item.status.startsWith("failed"))
1790
2027
  : undefined;
1791
2028
  const providerErrorCode = typeof failedInvocation?.error_code === "string" && failedInvocation.error_code ? failedInvocation.error_code : undefined;
1792
- const quoteFailure = execution.service_id === "itpay-rail-booking" ? {
1793
- quote_source_unavailable: "实时车票数据暂不可用;保留原选择,稍后按用户意愿重新核验,不创建替代订单。",
1794
- quote_train_changed: "所选车次的时刻或运行信息已变化;向用户说明并征询是否重新查票。",
1795
- quote_seat_changed: "所选席别已变化;向用户说明并征询新的席别选择。",
1796
- quote_seat_unavailable: "所选席别当前无足够余票;告知用户并由其决定是否换方案。",
1797
- quote_refresh_expired: "实时报价过程超时;保留原选择,不把旧价当可付款价格。",
1798
- }[providerErrorCode ?? ""] : undefined;
1799
- const failureCode = providerErrorCode ?? model.workflow?.error_code;
2029
+ const quoteFailure = execution.service_id === "itpay-rail-booking" ? RAIL_QUOTE_FAILURE_GUIDANCE[(model.workflow?.failure?.reason_code === "workflow_failed" ? providerErrorCode : model.workflow?.failure?.reason_code) ?? providerErrorCode ?? ""] : undefined;
2030
+ const failureCode = model.workflow?.failure?.reason_code === "workflow_failed" && providerErrorCode && RAIL_QUOTE_FAILURE_GUIDANCE[providerErrorCode] ? providerErrorCode : model.workflow?.failure?.reason_code ?? providerErrorCode ?? model.workflow?.error_code;
1800
2031
  const failedInstruction = recovery
1801
2032
  ? quoteFailure
1802
- ? quoteFailure
2033
+ ? `${typeof model.workflow?.failure?.affected_leg === "number" ? `第${model.workflow.failure.affected_leg + 1}段报价失败。` : ""}${quoteFailure} 当前整程未取得完整有效报价,没有付款入口;保留同一购买和未变化的选择,当前没有可提交恢复动作,停止;需要用户决定或系统处理,不能靠反复 get/next 重新报价,不能收取其余段款项或重复创建购买。`
1803
2034
  : failedStepGuidance
1804
2035
  ? `执行在「${failedStepGuidance.meaning}」步失败${failureCode ? `(错误码:${failureCode})` : ""}:${failedStepGuidance.hint}。先核对原因和当前执行状态,不要盲目重放。`
1805
2036
  : state === "failed"
1806
- ? `执行已失败${failureCode ? `(错误码:${failureCode})` : ""}且不可续用;核对服务端原因、订单与付款事实后再决定下一步。`
2037
+ ? `执行已失败${failureCode ? `(错误码:${failureCode})` : ""}且不可续用;${execution.service_id === "itpay-rail-booking" || paymentVerified ? "核对服务端原因、订单与付款事实" : "保留原地点和约束,核对服务端原因;当前没有可执行恢复则停止,不重复读取终态"}后再决定下一步。`
1807
2038
  : "执行未完成,请按步骤错误处理;不要重建执行或重复调用。"
1808
2039
  : undefined;
1809
2040
  return {
@@ -1811,7 +2042,9 @@ function servicesNextEnvelope(model) {
1811
2042
  result: {
1812
2043
  service_execution_id: id,
1813
2044
  service_id: execution.service_id,
1814
- workflow: model.workflow,
2045
+ workflow_state: state,
2046
+ ...(model.workflow ? { current_step: model.workflow.current_step, workflow_revision: model.workflow.revision } : {}),
2047
+ ...(model.workflow?.failure ? { failure: model.workflow.failure } : {}),
1815
2048
  ...(state === "input_required" ? { input_schema: model.workflow_entry.input_schema } : {}),
1816
2049
  ...(guidance && state === "input_required" ? { guidance } : {}),
1817
2050
  ...(failedStep ? { failed_step: failedStep, ...(failedStepGuidance ? { failed_step_meaning: failedStepGuidance.meaning } : {}) } : {}),
@@ -1824,10 +2057,10 @@ function servicesNextEnvelope(model) {
1824
2057
  ? (model.workflow?.human_action?.context?.review
1825
2058
  ? "服务已到付款步骤,可直接 Checkout 付款;如需调整席别/座位偏好,先用 confirm_booking 动作修订(会重新报价并锁定新价),完成后再付款。"
1826
2059
  : "服务已到付款步骤,使用现有 Checkout 完成扫码付款。")
1827
- : guidance
1828
- ? "按 result.guidance 的字段契约填写输入后继续同一服务执行;不要臆造字段名。"
2060
+ : state === "input_required" && guidance
2061
+ ? RAIL_ENDPOINT_BRIEF + "按 result.guidance 的字段契约填写输入后继续同一服务执行;不要臆造字段名。"
1829
2062
  : "继续读取同一执行;缺少输入时按服务声明补齐。"),
1830
- next: recovery ? null : { command, reason: "继续当前流程" },
2063
+ next: recovery || inputTemplate ? null : { command, reason: "继续当前流程" },
1831
2064
  ...(inputTemplate || (state === "payment" && model.workflow?.human_action?.context?.review) ? {
1832
2065
  interaction: {
1833
2066
  schema_version: "itpay.interaction.v1",
@@ -1842,7 +2075,7 @@ function servicesNextEnvelope(model) {
1842
2075
  } : {}),
1843
2076
  },
1844
2077
  } : {}),
1845
- recovery: recovery && state === "failed"
2078
+ recovery: recovery && state === "failed" && !["itpay-rail-exact", "itpay-rail-smart"].includes(execution.service_id)
1846
2079
  ? [{ command: `itpay services get ${id} --json`, reason: "核对本执行的错误、结果和付款事实" }]
1847
2080
  : [],
1848
2081
  };
@@ -1869,11 +2102,14 @@ function servicesNextEnvelope(model) {
1869
2102
  status: total > 0 ? "result_ready" : "no_result",
1870
2103
  result: { service_execution_id: execution.service_execution_id, scope: "station_pair",
1871
2104
  resolved_station_pair: container?.resolved_station_pair, total,
1872
- result_item_id: item.service_capability_result_item_id },
2105
+ result_item_id: item.service_capability_result_item_id,
2106
+ 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)) : [],
2107
+ ...(exactSmartTemplate(container?.resolved_station_pair, execution.service_execution_id, item.service_capability_result_item_id)
2108
+ ? { smart_template: exactSmartTemplate(container?.resolved_station_pair, execution.service_execution_id, item.service_capability_result_item_id) } : {}) },
1873
2109
  instruction: total > 0
1874
2110
  ? "直达站对结果已保存。执行 next.command 一次读取紧凑车次行,按用户条件筛选;若只需比较,答复后结束;已有购票委托则读取所选行详情。"
1875
2111
  : "这一已解析站对返回 0 条;不代表全城无车。若原需求需要更广覆盖,可核验另一可信站对或 Smart。",
1876
- next: { command: `itpay services read-result ${execution.service_execution_id} --json`, reason: "读取这一已保存站对的完整紧凑结果" },
2112
+ next: { command: `itpay services read-result ${execution.service_execution_id} --json`, reason: "读取这一保存站对的紧凑分页;按返回的续页覆盖所需范围" },
1877
2113
  recovery: [],
1878
2114
  };
1879
2115
  }
@@ -2089,21 +2325,7 @@ function grantedResultEnvelope(response, orderID) {
2089
2325
  };
2090
2326
  }
2091
2327
  function servicesNextPlainResult(result) {
2092
- const lines = [];
2093
- for (const [key, value] of Object.entries(result)) {
2094
- if (key === "items" && Array.isArray(value)) {
2095
- lines.push("items:");
2096
- for (const item of value) {
2097
- lines.push(` ${item.rank}. ${item.title}`);
2098
- for (const [field, fieldValue] of Object.entries(item.safe_payload)) {
2099
- lines.push(` ${field}: ${typeof fieldValue === "string" ? fieldValue : JSON.stringify(fieldValue)}`);
2100
- }
2101
- }
2102
- continue;
2103
- }
2104
- lines.push(`${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
2105
- }
2106
- return lines;
2328
+ return plainValueLines(result);
2107
2329
  }
2108
2330
  export async function runServicesEvents(backend, serviceExecutionID, options = {}) {
2109
2331
  const afterSequence = options.afterSequence ?? 0;
@@ -2134,11 +2356,8 @@ export async function runServicesEvents(backend, serviceExecutionID, options = {
2134
2356
  returned_count: events.length,
2135
2357
  events,
2136
2358
  },
2137
- instruction: "事件仅用于诊断;不要从事件重放业务步骤,回到 services next 获取当前动作。",
2138
- next: {
2139
- command: `itpay services next ${serviceExecutionID} --json`,
2140
- reason: "恢复正常服务流程",
2141
- },
2359
+ instruction: "这是已保存的诊断事件;事件不代表车票结果,也不授权重放业务步骤。已读完所需诊断时停止;实际任务沿先前返回的当前动作继续。",
2360
+ next: null,
2142
2361
  recovery: events.length === limit && events.length > 0
2143
2362
  ? [{
2144
2363
  command: `itpay services events ${serviceExecutionID} --after-sequence ${events.at(-1).sequence} --limit ${limit} --json`,
@@ -2214,7 +2433,7 @@ function buildServicesCheckoutEnvelope(response, checkoutURL, plan, agentType, t
2214
2433
  service_execution_id: response.binding.service_execution_id,
2215
2434
  checkout_id: checkout.checkout.checkout_id,
2216
2435
  capability_id: checkoutCapabilityID(response),
2217
- locked_input: response.locked_input,
2436
+ items: plan.orderItems ?? [],
2218
2437
  amount,
2219
2438
  ...(checkout.checkout.expires_at ? {
2220
2439
  // The shared server-owned payable deadline D: refreshing the page or
@@ -2331,16 +2550,35 @@ export async function runServicesRun(backend, config, serviceID, input, options
2331
2550
  await runServicesNext(backend, id, options);
2332
2551
  }
2333
2552
  catch (cause) {
2553
+ if (cause instanceof HttpError && cause.code === "capability_input_invalid") {
2554
+ const schema = cause.payload?.input_schema;
2555
+ const executionID = cause.payload?.service_execution_id ?? id;
2556
+ throw new CommandContractError(cause.code, cause.message, "Agent按当前模板自行修正错误字段和JSON层级,保留已有摘要的端点含义及原地点/日期/人数,不重复研究或事后补写冒充查询前摘要;不要向用户询问技术字段。只有真实业务歧义才问人;使用返回的执行身份恢复,未创建时用原服务。", schema ? [] : [{ command: executionID ? `itpay services get ${executionID} --json` : "itpay catalog list --json", reason: "获取当前锁定合同;仍缺合同则停止,不猜旧写法" }], schema ? { schema_version: "itpay.interaction.v1", stage: "input_required", input_template: {
2557
+ command: `itpay services run ${serviceID}${executionID ? ` --execution ${executionID}` : ""} --input-json <file> --json`,
2558
+ input: serviceInputTemplate(schema, input), required_input: ["file", ...requiredInputFields(schema)], executable: false,
2559
+ } } : undefined, { service_id: serviceID, ...(executionID ? { service_execution_id: executionID } : {}), execution_created: cause.payload?.execution_created ?? Boolean(executionID), ...(typeof cause.payload?.provider_called === "boolean" ? { provider_called: cause.payload.provider_called } : {}), input_errors: cause.payload?.input_errors ?? [cause.message], ...(schema ? { input_schema: schema } : {}) });
2560
+ }
2561
+ if (cause instanceof HttpError && ["invalid_selection", "selection_wrong_buyer"].includes(cause.code ?? "")) {
2562
+ const reason = cause.payload?.reason;
2563
+ const inputInvalid = reason === "input_constraint_invalid";
2564
+ throw new CommandContractError(cause.code, cause.message, inputInvalid ? "输入违反当前完整计划约束。读取原查询的完整购买模板,修正人数、完整段数和顺序;不要追加未声明字段或拆单。"
2565
+ : cause.code === "selection_wrong_buyer" ? "当前身份无权使用这份购买凭据。停止;不要切换身份绕过,也不展示其他买家资料。"
2566
+ : reason === "stored_selection_inconsistent" ? "服务端凭据与已存目录不一致,补参数无法修复。停止购买并提供诊断标识 stored_selection_inconsistent;不猜字段、不重新创建购买。"
2567
+ : "购买凭据未通过校验,原因尚未确定且没有安全自动恢复。停止,不把推测报成原因或追加隐藏字段。", inputInvalid ? [{ command: "itpay services list --json", reason: "定位原查询并读取当前完整购买模板" }, { command: "itpay docs show rail-booking --json", reason: "读取正式输入约束" }] : [], undefined, { service_id: serviceID, ...(id ? { service_execution_id: id } : {}), ...(reason ? { reason } : {}), execution_created: Boolean(id) });
2568
+ }
2569
+ if (cause instanceof HttpError && ["rail_journey_selection_required", "rail_selection_expired"].includes(cause.code ?? "")) {
2570
+ throw new CommandContractError(cause.code, cause.message, "整程必须使用原查询当前完整 ticket plan 的 selections 模板;手填 legs 只兼容单腿。选择过期时说明缺少当前有效凭据,按用户决定取得完整方案,不拆单、不拼腿、不擅自换站或日期。", [{ command: "itpay services list --json", reason: "找回原查询并读取其完整方案模板" }, { command: "itpay docs show rail-booking --json", reason: "读取正式整程输入合同" }], undefined, { service_id: serviceID, ...(id ? { service_execution_id: id } : {}) });
2571
+ }
2334
2572
  if (cause instanceof HttpError && cause.code === "rail_seat_code_invalid" && id) {
2335
2573
  throw new CommandContractError(cause.code, cause.message, "席别必须使用原查询 booking_template.seat_choices 中与名称对应的代码,不能填“二等座”等显示名。用原 selection token 与真实人数修正输入,继续同一booking execution;若只是代码写法修正且车次、席别名称、人数等购买条件未变,沿用用户已给的确认,不再重复询问。", [{ command: `itpay services next ${id} --json`, reason: "读取同一booking当前状态后修正输入" }]);
2336
2574
  }
2337
2575
  if (cause instanceof CommandContractError || cause instanceof HttpError || cause instanceof DeviceLockBusyError || cause instanceof DeviceStateError)
2338
2576
  throw cause;
2339
- if (cause instanceof HttpTransportError && !id) {
2340
- throw new CommandContractError("workflow_start_outcome_unknown", cause.message, "创建服务执行时没有收到完整响应。先查询当前身份可见的执行;不要直接重跑并创建替代执行。", [{ command: "itpay services list --json", reason: "查找可能已经创建的服务执行" }]);
2577
+ if (!id) {
2578
+ throw new CommandContractError("workflow_start_outcome_unknown", cause instanceof Error ? cause.message : "service creation outcome unknown", "创建服务执行时没有收到完整响应。先查询当前身份可见的执行;不要直接重跑并创建替代执行。", [{ command: "itpay services list --json", reason: "查找可能已经创建的服务执行" }]);
2341
2579
  }
2342
2580
  if (cause instanceof HttpTransportError)
2343
- throw cause;
2344
- throw new CommandContractError("workflow_run_failed", cause instanceof Error ? cause.message : "workflow run failed", "保留当前执行并按错误处理,不要重复创建服务执行。", [{ command: `itpay services run ${serviceID} --execution ${id} --json`, reason: "恢复同一执行" }]);
2581
+ throw new CommandContractError("workflow_outcome_unknown", cause.message, "未收到完整响应;只读同一执行的权威事实,不重放写操作或创建替代执行。", [{ command: `itpay services next ${id} --json`, reason: "核对同一执行" }]);
2582
+ throw new CommandContractError("workflow_run_failed", cause instanceof Error ? cause.message : "workflow run failed", "保留当前执行并按错误处理,不要重复创建服务执行。", [{ command: `itpay services next ${id} --json`, reason: "读取同一执行的当前事实与恢复动作" }]);
2345
2583
  }
2346
2584
  }