@itpay/cli 2.1.17 → 2.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import { HttpError } from "../client/http.js";
2
2
  import { HttpTransportError } from "../client/transport.js";
3
- import { operationID } from "../state/config.js";
3
+ import { DeviceLockBusyError, DeviceStateError } from "../state/device_authority.js";
4
+ import { operationID, taskJournalPath } from "../state/config.js";
5
+ import { TaskJournal } from "../state/task_journal.js";
4
6
  import { validateContext } from "../state/client_context.js";
5
7
  import { dispatchRender } from "../render/index.js";
6
8
  import { ensureIdeImageAttach } from "../render/ide.js";
@@ -16,6 +18,20 @@ import { renderTerminalQR } from "../render/qr.js";
16
18
  const RAIL_PROGRESSIVE_FEATURES = ["rail.progressive.v2"];
17
19
  import { buildCheckoutQRPlan } from "./buy.js";
18
20
  import { appendFeedbackPostmortemInstruction, CommandContractError, isTerminalServiceExecutionStatus, writeCommandEnvelope, } from "./guidance.js";
21
+ // AUTH04: record the pause point of each Service Execution so a later
22
+ // `itpay auth login` can resume the SAME task. Ids/stage/resume command only —
23
+ // no chat content, PII or credentials. Best-effort: journaling never breaks
24
+ // a command.
25
+ function journalTaskState(serviceExecutionID, envelope, env) {
26
+ try {
27
+ const journal = new TaskJournal(taskJournalPath(env));
28
+ const serviceID = typeof envelope.result?.service_id === "string" ? envelope.result.service_id : undefined;
29
+ journal.observe(serviceExecutionID, envelope.status, serviceID ? `itpay services run ${serviceID} --execution ${serviceExecutionID} --json` : undefined);
30
+ }
31
+ catch {
32
+ /* local journal is advisory; never fail the envelope */
33
+ }
34
+ }
19
35
  const serviceActionStatuses = new Set(["pending", "approved", "rejected", "expired", "cancelled"]);
20
36
  const RAIL_SERVICE_GUIDANCE = {
21
37
  "itpay-rail-exact": {
@@ -99,13 +115,29 @@ export async function runServicesStart(backend, serviceID, options = {}) {
99
115
  });
100
116
  if (response.workflow_entry) {
101
117
  const guidance = railServiceGuidance(serviceID);
102
- writeCommandEnvelope({ status: "input_required", result: { service_execution_id: response.execution.service_execution_id, service_id: serviceID, input_schema: response.workflow_entry.input_schema, ...(guidance ? { guidance } : {}) }, instruction: guidance ? "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 可直接照抄),然后继续同一服务执行。不要臆造字段名。" : "根据服务声明填写输入,然后继续同一服务执行。", next: { command: `itpay services run ${serviceID} --execution ${response.execution.service_execution_id} --input-json <file> --json`, reason: "提交买家输入" }, recovery: [] }, { ...options });
118
+ writeCommandEnvelope({
119
+ status: "input_required",
120
+ result: { service_execution_id: response.execution.service_execution_id, service_id: serviceID, input_schema: response.workflow_entry.input_schema, ...(guidance ? { guidance } : {}) },
121
+ instruction: guidance ? "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 可直接照抄),然后继续同一服务执行。不要臆造字段名。" : "根据服务声明填写输入,然后继续同一服务执行。",
122
+ next: null,
123
+ interaction: {
124
+ schema_version: "itpay.interaction.v1",
125
+ stage: "input_required",
126
+ input_template: {
127
+ command: `itpay services run ${serviceID} --execution ${response.execution.service_execution_id} --input-json <file> --json`,
128
+ required_input: ["file"],
129
+ executable: false,
130
+ },
131
+ },
132
+ recovery: []
133
+ }, { ...options });
103
134
  return;
104
135
  }
105
136
  const capability = response.capabilities.find((item) => item.phase === response.execution.phase && !item.requires_payment);
106
137
  const requiredInput = requiredInputFields(capability?.input_schema);
107
- const command = capability
108
- ? `itpay services invoke ${response.execution.service_execution_id} --capability ${capability.capability_id}${requiredInput.map((field) => ` --input ${field}=<value>`).join("")} --json`
138
+ const needsInput = requiredInput.length > 0;
139
+ const command = capability && !needsInput
140
+ ? `itpay services invoke ${response.execution.service_execution_id} --capability ${capability.capability_id} --json`
109
141
  : `itpay services next ${response.execution.service_execution_id} --json`;
110
142
  const capabilitySummary = capability ? {
111
143
  capability_id: capability.capability_id,
@@ -126,8 +158,19 @@ export async function runServicesStart(backend, serviceID, options = {}) {
126
158
  : "当前没有可直接调用的 capability;读取服务端下一步,不要猜测 capability。",
127
159
  next: {
128
160
  command,
129
- reason: capability ? "执行当前允许的能力" : "读取服务端计算的下一步",
161
+ reason: capability && !needsInput ? "执行当前允许的能力" : "读取服务端计算的下一步",
130
162
  },
163
+ ...(capability && needsInput ? {
164
+ interaction: {
165
+ schema_version: "itpay.interaction.v1",
166
+ stage: "input_required",
167
+ input_template: {
168
+ command: `itpay services invoke ${response.execution.service_execution_id} --capability ${capability.capability_id}${requiredInput.map((field) => ` --input ${field}=<value>`).join("")} --json`,
169
+ required_input: requiredInput,
170
+ executable: false,
171
+ },
172
+ },
173
+ } : {}),
131
174
  recovery: [],
132
175
  }, {
133
176
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -153,11 +196,19 @@ function locationConfirmationEnvelope(executionID, capabilityID, input, confirma
153
196
  result: { service_execution_id: executionID, query: input, location_confirmation: confirmation },
154
197
  instruction: "尚未查票。仅向用户确认 endpoints 中有歧义的地点,展示真实候选名称、地址和高德链接。不要自行选择候选、把区域改成车站或重复调用原查询。用户选择后保留原输入,带 location_confirmation 回到同一服务。没有候选时请用户补充已知城市或准确地名,再使用修正输入查询。",
155
198
  next: null,
156
- recovery: confirmation.can_resume ? [{
157
- command: `itpay services invoke ${executionID} --capability ${capabilityID}${formatInputOptions({ ...input,
158
- location_confirmation: { plan_id: confirmation.plan_id, token: confirmation.token, choices } })} --json`,
159
- reason: "仅在用户明确选择后替换 choices 并执行;30 分钟有效",
160
- }] : [],
199
+ ...(confirmation.can_resume ? {
200
+ interaction: {
201
+ schema_version: "itpay.interaction.v1",
202
+ stage: "location_confirmation_required",
203
+ input_template: {
204
+ command: `itpay services invoke ${executionID} --capability ${capabilityID}${formatInputOptions({ ...input,
205
+ location_confirmation: { plan_id: confirmation.plan_id, token: confirmation.token, choices } })} --json`,
206
+ required_input: ["choices"],
207
+ executable: false,
208
+ },
209
+ },
210
+ } : {}),
211
+ recovery: [],
161
212
  };
162
213
  }
163
214
  function requiredInputFields(schema) {
@@ -314,18 +365,33 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
314
365
  }
315
366
  }
316
367
  else if (items.length > 0 && requestedCapability.requires_human_action) {
317
- next = {
318
- command: `itpay services action ${response.execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
319
- reason: "在当前 Execution 记录用户选择",
320
- };
368
+ next = null;
321
369
  }
322
370
  else if (items.length === 0) {
323
371
  next = null;
324
372
  }
373
+ const interaction = items.length > 0 && requestedCapability.requires_human_action
374
+ ? {
375
+ schema_version: "itpay.interaction.v1",
376
+ stage: "query_results_ready",
377
+ by_goal: {
378
+ compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
379
+ prepare_checkout: {
380
+ action: "select_under_user_rules_then_prepare",
381
+ requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
382
+ },
383
+ },
384
+ input_template: {
385
+ command: `itpay services action ${response.execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
386
+ required_input: ["rank"],
387
+ executable: false,
388
+ },
389
+ }
390
+ : undefined;
325
391
  if (preview?.resolved_locations)
326
392
  instruction += " 按 resolved_locations 说明实际解析地点;区域级位置仅是范围代表点,接驳时间不是从用户精确位置计算的。";
327
393
  return {
328
- value: { status, result: baseResult, instruction, next, recovery: [] },
394
+ value: { status, result: baseResult, instruction, next, recovery: [], ...(interaction ? { interaction } : {}) },
329
395
  plainResult: serviceResultPlainLines(baseResult),
330
396
  };
331
397
  }
@@ -479,7 +545,33 @@ export async function runServicesAction(backend, serviceExecutionID, actionType,
479
545
  request.result_item_id = resultItemID;
480
546
  if (options.requiredBefore)
481
547
  request.required_before = options.requiredBefore;
482
- const response = await backend.recordServiceExecutionAction(serviceExecutionID, request);
548
+ let response;
549
+ try {
550
+ response = await backend.recordServiceExecutionAction(serviceExecutionID, request);
551
+ }
552
+ catch (error) {
553
+ if (error instanceof HttpError && ["booking_review_invalid", "booking_review_changed", "booking_review_locked"].includes(error.code)) {
554
+ throw new CommandContractError(error.code, error.message, error.code === "booking_review_invalid"
555
+ ? "确认输入与当前草稿不符。重新读取同一booking的review模板,席别使用返回的代码,不填显示名或猜新值。若仅修正代码写法,且车次、实际席别、人数、价格及条款等已确认内容未变,可沿用用户已有明确确认后提交;草稿实质变化或缺少真实同意时,先请用户确认变化内容。"
556
+ : error.code === "booking_review_changed"
557
+ ? "草稿版本已变化;重新读取同一booking的当前review与revision,经用户核对后再提交。"
558
+ : "当前购买已有付款尝试;读取同一booking及订单状态,不创建新订单或重做付款。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取同一booking的当前状态与review" }]);
559
+ }
560
+ throw error;
561
+ }
562
+ if ("state" in response) {
563
+ const updated = await backend.getServiceExecution(serviceExecutionID);
564
+ const envelope = servicesNextEnvelope(updated);
565
+ envelope.result = {
566
+ ...envelope.result,
567
+ planning_action: { action_id: response.action_id, state: response.state, replayed: response.replayed },
568
+ };
569
+ writeCommandEnvelope(envelope, {
570
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
571
+ ...(options.output ? { output: options.output } : {}),
572
+ });
573
+ return;
574
+ }
483
575
  if (selection && actionType === "select_candidate" && response.status === "approved") {
484
576
  const updated = await backend.getServiceExecution(serviceExecutionID);
485
577
  const preferred = updated.allowed_actions?.[0];
@@ -727,6 +819,7 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
727
819
  const lockedRefund = response.refunds.find((refund) => refund.access_locked);
728
820
  const nextState = servicesNextEnvelope(response);
729
821
  const result = {
822
+ ...nextState.result,
730
823
  service_execution_id: execution.service_execution_id,
731
824
  service_id: execution.service_id,
732
825
  status: execution.status,
@@ -744,13 +837,11 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
744
837
  } : {}),
745
838
  };
746
839
  const envelope = {
747
- status: "shown",
840
+ status: nextState.status,
748
841
  result,
749
- instruction: lockedRefund || isTerminalServiceExecutionStatus(execution.status)
750
- ? nextState.instruction
751
- : "时间线仅用于解释和恢复;按当前首选动作继续,不要重放已完成步骤。",
842
+ instruction: nextState.instruction,
752
843
  next: nextState.next ? { command: nextState.next.command, reason: "继续当前首选动作" } : null,
753
- recovery: [{ command: `itpay services events ${serviceExecutionID} --json`, reason: "仅在需要完整诊断事件时使用" }],
844
+ recovery: nextState.recovery,
754
845
  };
755
846
  writeCommandEnvelope(envelope, {
756
847
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -767,6 +858,7 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
767
858
  export async function runServicesNext(backend, serviceExecutionID, options = {}) {
768
859
  const response = await backend.getServiceExecution(serviceExecutionID, options.sinceSnapshot ? { sinceSnapshot: options.sinceSnapshot } : {});
769
860
  const envelope = servicesNextEnvelope(response);
861
+ journalTaskState(serviceExecutionID, envelope, options.env ?? process.env);
770
862
  if (response.rail_booking)
771
863
  envelope.result = { ...envelope.result, rail_booking: response.rail_booking };
772
864
  writeCommandEnvelope(envelope, {
@@ -777,7 +869,7 @@ export async function runServicesNext(backend, serviceExecutionID, options = {})
777
869
  }
778
870
  export async function runServicesPage(backend, serviceExecutionID, resultItemID, options = {}) {
779
871
  const offset = options.offset ?? 0;
780
- const limit = options.limit ?? 5;
872
+ const limit = options.limit ?? 20;
781
873
  if (!Number.isInteger(offset) || offset < 0) {
782
874
  throw new CommandContractError("offset_invalid", "--offset must be a non-negative integer", "offset 必须是非负整数;本次未读取服务端分页。", [{ command: `itpay services page ${serviceExecutionID} ${resultItemID} --json`, reason: "从第一页重新读取" }]);
783
875
  }
@@ -807,11 +899,11 @@ export async function runServicesPage(backend, serviceExecutionID, resultItemID,
807
899
  next_offset: nextOffset,
808
900
  page,
809
901
  },
810
- instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。用普通语言向用户说明本页候选(车次、席别、时刻、费用口径),铁路应付与地面估算费用分开表述;不要提及 safe_payload、Execution 或内部 ID。",
902
+ instruction: "读取的是已保存结果的同版本分页,不重新查询、不消耗额度。先读完所需页,再合并回答用户;不要逐页播报。",
811
903
  next: nextOffset !== null
812
904
  ? { command: response.snapshot_id
813
- ? `itpay services page ${serviceExecutionID} ${resultItemID} --cursor rcur_${nextOffset} --json`
814
- : `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --json`,
905
+ ? `itpay services page ${serviceExecutionID} ${resultItemID} --cursor rcur_${nextOffset} --limit ${limit} --json`
906
+ : `itpay services page ${serviceExecutionID} ${resultItemID} --offset ${nextOffset} --limit ${limit} --json`,
815
907
  reason: "读取同版本结果的下一页" }
816
908
  : null,
817
909
  recovery: offset > 0
@@ -892,10 +984,8 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
892
984
  query_revision: committed.query_revision,
893
985
  catalog,
894
986
  },
895
- instruction: "catalog 是本次查询的完整无损目录:journeys 为全部可行组合(含超出预览分页的组合),plans 是每个组合下的购票方案,stations/services/offers/ground_options 为共享字典,field_legend 解释紧凑字段。逐组合向用户说明车次、换乘与接驳取舍;席别与价格以 plans 内报价为准、下单前仍需核验;姓名身份证手机号只在 ItPay 网页填写。",
896
- next: journeys.length > 0
897
- ? { command: `itpay services read-result ${serviceExecutionID} --snapshot ${committed.snapshot_id} --journey ${journeys[0]?.ref ?? '?'} --json`, reason: "查看单个行程明细" }
898
- : { command: `itpay services next ${serviceExecutionID} --since-snapshot ${committed.snapshot_id} --json`, reason: "返回规划进展" },
987
+ instruction: "catalog 是本次查询的完整已保存目录。按用户问题筛选全部journeys并一次回答;只有需要单程细节时才读取journey。价格和余票供比较,购买前须实时报价。",
988
+ next: null,
899
989
  recovery: [],
900
990
  }, {
901
991
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -1046,12 +1136,34 @@ function railJourneySummary(card, serviceExecutionID) {
1046
1136
  ...(card.observed_at ? { observed_at: card.observed_at } : {}),
1047
1137
  ...(card.risk_notes?.length ? { risk_notes: card.risk_notes } : {}),
1048
1138
  ...(card.booking_support === "single_leg"
1049
- ? { select: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${card.journey_id} --json` }
1139
+ ? {
1140
+ select: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${card.journey_id} --json`,
1141
+ // Delegated selection under explicit user rules is an agent action —
1142
+ // never record it as a human pick.
1143
+ select_delegated: `itpay services action ${serviceExecutionID} --action select_journey --actor-type agent --status approved --input journey_id=${card.journey_id} --input selection_mode=delegated --json`,
1144
+ }
1050
1145
  : {}),
1051
1146
  ...(card.booking_offer
1052
1147
  ? {
1053
1148
  booking_offer: card.booking_offer,
1054
- book: `itpay services run ${card.booking_offer.service_id} --input-json <file> --json # file = {"selection":{"token":"${card.booking_offer.selection_token}","seat_type":"<席别>"},"passengers":<n>}`,
1149
+ booking_template: {
1150
+ command: `itpay services run ${card.booking_offer.service_id} --input-json <file> --json`,
1151
+ required_input: ["file"],
1152
+ executable: false,
1153
+ seat_choices: (card.representative_offer?.ticket_offers ?? []).map((offer) => ({
1154
+ seat_type: offer.seat_type,
1155
+ seat_name: offer.seat_name,
1156
+ })).filter((seat) => typeof seat.seat_type === "string"),
1157
+ input_example: {
1158
+ selection: {
1159
+ token: card.booking_offer.selection_token,
1160
+ ...(typeof card.representative_offer?.ticket_offers?.[0]?.seat_type === "string"
1161
+ ? { seat_type: card.representative_offer.ticket_offers[0].seat_type }
1162
+ : {}),
1163
+ },
1164
+ passengers: card.passengers ?? 1,
1165
+ },
1166
+ },
1055
1167
  }
1056
1168
  : {}),
1057
1169
  };
@@ -1108,12 +1220,61 @@ function railPlanningEnvelope(model) {
1108
1220
  ...(plan.notices?.length ? { notices: plan.notices } : {}),
1109
1221
  ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se) } : {}),
1110
1222
  ...(cards.length ? { journeys: cards } : {}),
1223
+ ...(plan.snapshot_id ? { full_result: { command: `itpay services read-result ${se} --snapshot ${plan.snapshot_id} --json`, meaning: "读取已保存的完整目录;推荐和journeys仅是摘要" } } : {}),
1111
1224
  ...(plan.available_actions?.length ? { available_actions: plan.available_actions } : {}),
1112
1225
  };
1113
1226
  const nextPoll = {
1114
1227
  command: `itpay services next ${se}${plan.snapshot_id ? ` --since-snapshot ${plan.snapshot_id}` : ""} --json`,
1115
1228
  reason: "读取同一规划的增量进展(不触发供应商调用)",
1116
1229
  };
1230
+ // Two-goal interaction contract (spec 03 §2): the local Agent picks compare
1231
+ // or prepare_checkout from full context — the envelope teaches both branches
1232
+ // so a delegated purchase keeps moving and a comparison never buys.
1233
+ const bookable = journeys.find((card) => card.booking_support === "single_leg" && card.booking_offer);
1234
+ const queryResultsInteraction = journeys.length > 0
1235
+ ? {
1236
+ schema_version: "itpay.interaction.v1",
1237
+ stage: "query_results_ready",
1238
+ by_goal: {
1239
+ compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
1240
+ prepare_checkout: {
1241
+ action: "select_under_user_rules_then_prepare",
1242
+ recipe: "rail.selected-to-checkout.v1",
1243
+ requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
1244
+ ...(bookable
1245
+ ? {
1246
+ steps: [
1247
+ "按用户明确规则在 journeys 中选择合格者(卡片 select_delegated 提交 agent 委托选择;人类亲自选择用 select)",
1248
+ `用所选卡片的 booking_template 写 owner-only 临时输入(selection token + 席别 + 人数,不含身份信息),运行 itpay services run itpay-rail-booking --input-json <file> --json`,
1249
+ "owner 要求聊天 review 时一次合并补齐必要字段,真实确认后提交原 workflow:confirm_booking;不得伪造 accept",
1250
+ `确认后恢复同一购买执行:itpay services run itpay-rail-booking --execution <实际ID> --json,沿用有界等待与受保护 Checkout`,
1251
+ ],
1252
+ }
1253
+ : { unavailable: "当前结果无可整段购买映射;分别说明各段风险,不得合成原子联程。" }),
1254
+ },
1255
+ },
1256
+ }
1257
+ : undefined;
1258
+ const queryResultsCommunication = journeys.length > 0
1259
+ ? {
1260
+ schema_version: "itpay.communication.v1",
1261
+ status_line: expansion === "paused"
1262
+ ? "首批结果已就绪,扩展搜索已暂停等待用户决定"
1263
+ : "查询结果就绪",
1264
+ recommended_reason: plan.recommendation?.explanation?.[0],
1265
+ human_steps: [
1266
+ "compare:比较首选与最多两个备选后告知取舍",
1267
+ "prepare_checkout:委托仍在时确认所选,继续官方确认页",
1268
+ "姓名、证件、手机号只在受保护 Checkout 页填写",
1269
+ ],
1270
+ next_expectation: "compare:用户选定其一;prepare_checkout:委托仍在时继续到官方确认入口,人完成review与付款",
1271
+ must_convey: [
1272
+ "推荐与备选的时间/费用/便利性取舍",
1273
+ "金额为查询时参考价,下单前须核验",
1274
+ "姓名、证件、手机号只在受保护网页填写",
1275
+ ],
1276
+ }
1277
+ : undefined;
1117
1278
  switch (expansion) {
1118
1279
  case "complete":
1119
1280
  return {
@@ -1121,10 +1282,12 @@ function railPlanningEnvelope(model) {
1121
1282
  result,
1122
1283
  // §7.3: the lead line carries real combination counts bucketed by
1123
1284
  // verified transfer count — seat rows never inflate the journey count.
1124
- instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}向用户展示 recommendation 与 journeys 卡片(车次/时间/价格/余票状态),请用户选定 journey 后用其 select 命令提交;乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1285
+ instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}推荐和journeys是摘要;追问其他车次先读取 full_result 的已保存完整目录。compare:解释首选及最多两个有意义备选的时间/费用/便利性取舍后等待用户选择。prepare_checkout:用户已明确委托按规则选择时,按 interaction.recipe 选合格者并继续到官方确认页,不再次问是否下单;身份、review和最终付款边界仍然生效。乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1125
1286
  next: journeys[0]?.booking_support === "single_leg"
1126
- ? { command: railJourneySummary(journeys[0], se).select, reason: "选定推荐行程" }
1287
+ ? { command: railJourneySummary(journeys[0], se).select, reason: "用户在 compare 后选定推荐行程时执行;prepare_checkout 且规则命中时用 select_delegated" }
1127
1288
  : null,
1289
+ ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1290
+ ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1128
1291
  recovery: [],
1129
1292
  };
1130
1293
  case "paused": {
@@ -1168,8 +1331,10 @@ function railPlanningEnvelope(model) {
1168
1331
  return {
1169
1332
  status: "awaiting_input",
1170
1333
  result,
1171
- instruction: `${modelDegradedHint}${failedPairsHint}${directHint}${transferHint}${partialPause}规划已发布首批结果并暂停扩展:展示现有卡片与 available_actions,等待用户意图(${expandHint})。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。`,
1334
+ instruction: `${modelDegradedHint}${failedPairsHint}${directHint}${transferHint}${partialPause}规划已发布首批结果并暂停扩展:展示现有卡片与 available_actions,等待用户意图(${expandHint})。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。compare:解释首选及最多两个备选后等待;prepare_checkout:委托仍在且选项合格时按 interaction.recipe 继续到官方确认入口,不再次问是否下单。`,
1172
1335
  next: null,
1336
+ ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1337
+ ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1173
1338
  recovery: [],
1174
1339
  };
1175
1340
  }
@@ -1191,9 +1356,11 @@ function railPlanningEnvelope(model) {
1191
1356
  return {
1192
1357
  status: expansion,
1193
1358
  result,
1194
- instruction: `${modelDegradedHint}${failedPairsHint}${savedCatalog}${partial}本次规划已结束且不可续用;展示已有卡片后如需重查请用户确认后用同一服务新建执行。不要自动重新发起。`,
1359
+ instruction: `${modelDegradedHint}${failedPairsHint}${savedCatalog}${partial}${expansion === "expired" ? "规划已过期,已保存目录仍可读取并回答历史结果追问;购买须重新核验实时余票与报价。" : "本次规划已结束,展示已有结果;需要新的供应商观测时再明确发起查询。"}`,
1195
1360
  next: null,
1196
- recovery: [{ command: `itpay services events ${se} --json`, reason: "读取同一任务的处理记录" }],
1361
+ recovery: plan.snapshot_id
1362
+ ? [{ command: `itpay services read-result ${se} --snapshot ${plan.snapshot_id} --json`, reason: "读取同一任务已保存的完整目录" }]
1363
+ : [{ command: `itpay services next ${se} --json`, reason: "读取同一任务当前状态" }],
1197
1364
  };
1198
1365
  }
1199
1366
  default: {
@@ -1216,6 +1383,17 @@ function railPlanningEnvelope(model) {
1216
1383
  result,
1217
1384
  instruction: `${modelDegradedHint}${manualExpansion}${transferHint}${failedPairsHint}规划进行中:已提交的卡片可先向用户展示比较;稍后按 next 增量轮询同一执行(since-snapshot 命中时负载会被压缩,但阶段与计数仍返回最新值)。不要重新发起查询。`,
1218
1385
  next: nextPoll,
1386
+ communication: {
1387
+ schema_version: "itpay.communication.v1",
1388
+ status_line: transferSearching && !manualExpansion
1389
+ ? "直达范围暂未找到可用方案,中转组合仍在搜索"
1390
+ : "规划进行中,已确认的方案可先向用户展示",
1391
+ next_expectation: "按 next 增量轮询同一执行;不重新发起查询",
1392
+ must_convey: [
1393
+ "当前是进行中的部分结果,不代表最终答复",
1394
+ ...(failedPairsHint ? ["部分站对尚未查明,不能说'没有车'"] : []),
1395
+ ],
1396
+ },
1219
1397
  recovery: [],
1220
1398
  };
1221
1399
  }
@@ -1373,18 +1551,48 @@ function servicesNextEnvelope(model) {
1373
1551
  result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1374
1552
  required_fields: requiredFields, sides },
1375
1553
  instruction: "向用户展示 sides 中 status=needs_confirmation 一端的候选(名称+坐标),请用户选定后按 required_fields 逐项各传一个 --input:名称取候选 name,坐标取候选 location 的 [lng,lat];已 resolved 的一端把其 resolved_place 原样填入。继续同一执行,不重新查询;这一步不是购买确认。",
1376
- next: { command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`, reason: "用户确认后继续当前查询" },
1554
+ next: null,
1555
+ interaction: {
1556
+ schema_version: "itpay.interaction.v1",
1557
+ stage: "location_confirmation_required",
1558
+ input_template: {
1559
+ command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`,
1560
+ required_input: requiredFields,
1561
+ executable: false,
1562
+ },
1563
+ },
1377
1564
  recovery: [],
1378
1565
  };
1379
1566
  }
1380
1567
  const review = action.context?.review;
1381
1568
  if (review) {
1569
+ // W4: when the owner projects an official web review entry, present it
1570
+ // and wait for the human there; otherwise keep the one consolidated
1571
+ // chat review — never fabricate an accepted review.
1572
+ const reviewURL = [review.web_review_url, review.entry_url, review.review_url]
1573
+ .find((value) => typeof value === "string" && /^https:\/\//.test(value));
1574
+ const requirementsRemaining = Array.isArray(review.requirements_remaining)
1575
+ ? review.requirements_remaining
1576
+ : undefined;
1382
1577
  return {
1383
1578
  status: "booking_review_required",
1384
1579
  result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1385
- required_fields: requiredFields },
1386
- instruction: "向用户展示 context.review 中的行程(legs)与可选席别/座位偏好词表(seat_options),并明确告知:座位偏好仅为请求、不保证分配(对应 notice_version 文案须经用户同意)。收集乘客人数、席别与每位乘客偏好后,按 required_fields 组装完整 JSON 对象,经 --input-json <file> 提交——draft_revision 必须等于服务端当前值,被拒绝(booking_review_changed)时重新读取后重试。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。",
1387
- next: { command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <file> --json`, reason: "提交用户确认后的行程确认/修订" },
1580
+ required_fields: requiredFields,
1581
+ ...(requirementsRemaining ? { requirements_remaining: requirementsRemaining } : {}) },
1582
+ ...(reviewURL ? { handoff: { url: reviewURL, kind: "booking_review" } } : {}),
1583
+ instruction: reviewURL
1584
+ ? "已在 handoff.url 提供官方行程确认页,请用户在该页核对行程、席别与座位偏好并确认;页面打开请求不代表用户已确认。该页失效或用户更愿在对话中确认时,仍可按下述 input_template 一次合并提交。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。"
1585
+ : "向用户展示 context.review 中的行程(legs)与可选席别/座位偏好词表(seat_options),并明确告知:座位偏好仅为请求、不保证分配(对应 notice_version 文案须经用户同意)。这是购买前的合并确认:把乘客人数、席别与每位乘客的座位偏好一次问清,然后按 required_fields 组装完整 JSON 对象,经 --input-json <file> 提交——draft_revision 必须等于服务端当前值,被拒绝(booking_review_changed)时重新读取后重试。未得真实确认不得提交,不能暗设 accept_non_guaranteed。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。",
1586
+ next: null,
1587
+ interaction: {
1588
+ schema_version: "itpay.interaction.v1",
1589
+ stage: "booking_review_required",
1590
+ input_template: {
1591
+ command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <file> --json`,
1592
+ required_input: requiredFields.length > 0 ? requiredFields : ["file"],
1593
+ executable: false,
1594
+ },
1595
+ },
1388
1596
  recovery: [{ command: `itpay services next ${id} --json`, reason: "重新读取当前 draft_revision 后重试" }],
1389
1597
  };
1390
1598
  }
@@ -1392,7 +1600,16 @@ function servicesNextEnvelope(model) {
1392
1600
  status: "confirmation_required",
1393
1601
  result: { service_execution_id: id, service_id: execution.service_id, human_action: action },
1394
1602
  instruction: "请展示待确认的内容,请用户明确确认后,按 input_schema 填写 --input 字段。继续同一执行,不重新查询;这一步不是购买确认。",
1395
- next: { command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`, reason: "用户确认后继续当前查询" },
1603
+ next: null,
1604
+ interaction: {
1605
+ schema_version: "itpay.interaction.v1",
1606
+ stage: "confirmation_required",
1607
+ input_template: {
1608
+ command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`,
1609
+ required_input: requiredFields,
1610
+ executable: false,
1611
+ },
1612
+ },
1396
1613
  recovery: [],
1397
1614
  };
1398
1615
  }
@@ -1400,22 +1617,37 @@ function servicesNextEnvelope(model) {
1400
1617
  let command = `itpay services next ${id} --json`;
1401
1618
  if (state === "payment")
1402
1619
  command = `itpay services checkout ${id} --json`;
1403
- if (state === "input_required")
1404
- command = `itpay services run ${execution.service_id} --execution ${id} --input-json <file> --json`;
1620
+ // input_required: the resume command needs a real file path — expose it as
1621
+ // a non-executable input_template, not a fake `next.command`.
1622
+ const inputTemplate = state === "input_required"
1623
+ ? {
1624
+ command: `itpay services run ${execution.service_id} --execution ${id} --input-json <file> --json`,
1625
+ required_input: ["file"],
1626
+ executable: false,
1627
+ }
1628
+ : undefined;
1405
1629
  const guidance = railServiceGuidance(execution.service_id);
1406
1630
  const failedStep = recovery ? failedWorkflowStep(model.workflow?.steps, model.workflow?.error_code) : undefined;
1407
1631
  const failedStepGuidance = failedStep ? WORKFLOW_STEP_GUIDANCE[failedStep] : undefined;
1408
1632
  const failedInvocation = recovery
1409
1633
  ? [...model.provider_invocations].reverse().find((item) => typeof item.status === "string" && item.status.startsWith("failed"))
1410
1634
  : undefined;
1411
- const errorDetail = typeof failedInvocation?.error_message === "string" && failedInvocation.error_message ? failedInvocation.error_message : undefined;
1412
1635
  const providerErrorCode = typeof failedInvocation?.error_code === "string" && failedInvocation.error_code ? failedInvocation.error_code : undefined;
1636
+ const quoteFailure = execution.service_id === "itpay-rail-booking" ? {
1637
+ quote_source_unavailable: "实时车票数据暂不可用;保留原选择,稍后按用户意愿重新核验,不创建替代订单。",
1638
+ quote_train_changed: "所选车次的时刻或运行信息已变化;向用户说明并征询是否重新查票。",
1639
+ quote_seat_changed: "所选席别已变化;向用户说明并征询新的席别选择。",
1640
+ quote_seat_unavailable: "所选席别当前无足够余票;告知用户并由其决定是否换方案。",
1641
+ quote_refresh_expired: "实时报价过程超时;保留原选择,不把旧价当可付款价格。",
1642
+ }[providerErrorCode ?? ""] : undefined;
1413
1643
  const failedInstruction = recovery
1414
- ? failedStepGuidance
1415
- ? `执行在「${failedStepGuidance.meaning}」步失败:${failedStepGuidance.hint}${errorDetail ? `。供应商返回:${errorDetail}` : ""}。本执行已终止不能续用;修正后用同一服务新建执行重试,不要盲目重放。`
1416
- : state === "failed"
1417
- ? `执行已失败${failedStep ? `(失败步骤:${failedStep})` : ""}且不可续用;修正输入后用同一服务新建执行重试。`
1418
- : "执行未完成,请按步骤错误处理;不要重建执行或重复调用。"
1644
+ ? quoteFailure
1645
+ ? quoteFailure
1646
+ : failedStepGuidance
1647
+ ? `执行在「${failedStepGuidance.meaning}」步失败:${failedStepGuidance.hint}。本执行已终止不能续用;先核对原因,不要盲目重放。`
1648
+ : state === "failed"
1649
+ ? `执行已失败${failedStep ? `(失败步骤:${failedStep})` : ""}且不可续用;修正输入后用同一服务新建执行重试。`
1650
+ : "执行未完成,请按步骤错误处理;不要重建执行或重复调用。"
1419
1651
  : undefined;
1420
1652
  return {
1421
1653
  status: state,
@@ -1427,7 +1659,7 @@ function servicesNextEnvelope(model) {
1427
1659
  ...(guidance && state === "input_required" ? { guidance } : {}),
1428
1660
  ...(failedStep ? { failed_step: failedStep, ...(failedStepGuidance ? { failed_step_meaning: failedStepGuidance.meaning } : {}) } : {}),
1429
1661
  ...(providerErrorCode ? { provider_error_code: providerErrorCode } : {}),
1430
- ...(errorDetail ? { error_detail: errorDetail } : {}),
1662
+ ...(providerErrorCode && !quoteFailure ? { diagnostic_code: providerErrorCode } : {}),
1431
1663
  ...(recovery ? { retryable: state === "failed" } : {}),
1432
1664
  },
1433
1665
  instruction: failedInstruction
@@ -1439,14 +1671,26 @@ function servicesNextEnvelope(model) {
1439
1671
  ? "按 result.guidance 的字段契约填写输入后继续同一服务执行;不要臆造字段名。"
1440
1672
  : "继续读取同一执行;缺少输入时按服务声明补齐。"),
1441
1673
  next: recovery ? null : { command, reason: "继续当前流程" },
1442
- recovery: recovery && state === "failed"
1674
+ ...(inputTemplate || (state === "payment" && model.workflow?.human_action?.context?.review) ? {
1675
+ interaction: {
1676
+ schema_version: "itpay.interaction.v1",
1677
+ stage: inputTemplate ? "input_required" : "payment",
1678
+ ...(inputTemplate ? { input_template: inputTemplate } : {}),
1679
+ ...(state === "payment" && model.workflow?.human_action?.context?.review ? {
1680
+ revise_template: {
1681
+ command: `itpay services action ${id} --action ${model.workflow.human_action.action_type} --actor-type human --status approved --input-json <file> --json`,
1682
+ required_input: ["file"],
1683
+ executable: false,
1684
+ },
1685
+ } : {}),
1686
+ },
1687
+ } : {}),
1688
+ recovery: recovery && state === "failed" && !quoteFailure
1443
1689
  ? [
1444
1690
  { command: `itpay services start ${execution.service_id} --json`, reason: "修正输入后重新发起(本执行已终止不能续用)" },
1445
1691
  ...(guidance ? [{ command: "itpay docs show rail-booking --json", reason: "查看本服务输入字段契约与示例" }] : []),
1446
1692
  ]
1447
- : state === "payment" && model.workflow?.human_action?.context?.review
1448
- ? [{ command: `itpay services action ${id} --action ${model.workflow.human_action.action_type} --actor-type human --status approved --input-json <file> --json`, reason: "付款前修订席别/座位偏好(draft_revision 以服务端为准)" }]
1449
- : [],
1693
+ : [],
1450
1694
  };
1451
1695
  }
1452
1696
  const terminalEnvelope = terminalExecutionEnvelope(model);
@@ -1801,6 +2045,12 @@ function buildServicesCheckoutEnvelope(response, checkoutURL, plan, agentType, t
1801
2045
  capability_id: checkoutCapabilityID(response),
1802
2046
  locked_input: response.locked_input,
1803
2047
  amount,
2048
+ ...(checkout.checkout.expires_at ? {
2049
+ // The shared server-owned payable deadline D: refreshing the page or
2050
+ // re-creating a checkout can never extend it; past it the checkout
2051
+ // and pending order are terminally cancelled.
2052
+ payment_deadline_at: checkout.checkout.expires_at,
2053
+ } : {}),
1804
2054
  },
1805
2055
  handoff: presentationHandoff.handoff,
1806
2056
  instruction: presentationHandoff.instruction,
@@ -1889,12 +2139,12 @@ export async function runServicesRun(backend, config, serviceID, input, options
1889
2139
  const until = Date.now() + (options.timeoutSeconds ?? 120) * 1000;
1890
2140
  const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
1891
2141
  const paid = () => model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
1892
- // §7.2: for progressive services the committed rail_planning projection is
1893
- // itself a deliverable — return the same railPlanningEnvelope as soon as it
1894
- // exists instead of hiding every stage behind the 120s workflow poll. The
1895
- // owner keeps advancing in the background; the CLI returning early never
1896
- // cancels it.
1897
- while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && !model.rail_planning && Date.now() < until) {
2142
+ // A planning projection can exist before any usable result or user action.
2143
+ // Keep the existing bounded wait until there is something to hand over.
2144
+ const planningDeliverable = () => Boolean(model.rail_planning && (model.rail_planning.recommendation || model.rail_planning.alternatives?.length ||
2145
+ ["complete", "paused", "failed", "cancelled", "expired"].includes(model.rail_planning.search?.expansion_status ?? "") ||
2146
+ model.rail_planning.available_actions?.some((action) => ["select_journey", "expand_search"].includes(action.type))));
2147
+ while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && !planningDeliverable() && Date.now() < until) {
1898
2148
  await sleep(options.pollIntervalMS ?? 1500);
1899
2149
  model = await backend.getServiceExecution(id);
1900
2150
  }
@@ -1913,7 +2163,10 @@ export async function runServicesRun(backend, config, serviceID, input, options
1913
2163
  await runServicesNext(backend, id, options);
1914
2164
  }
1915
2165
  catch (cause) {
1916
- if (cause instanceof CommandContractError || cause instanceof HttpError)
2166
+ if (cause instanceof HttpError && cause.code === "rail_seat_code_invalid" && id) {
2167
+ throw new CommandContractError(cause.code, cause.message, "席别必须使用原查询 booking_template.seat_choices 中与名称对应的代码,不能填“二等座”等显示名。用原 selection token 与真实人数修正输入,继续同一booking execution;若只是代码写法修正且车次、席别名称、人数等购买条件未变,沿用用户已给的确认,不再重复询问。", [{ command: `itpay services next ${id} --json`, reason: "读取同一booking当前状态后修正输入" }]);
2168
+ }
2169
+ if (cause instanceof CommandContractError || cause instanceof HttpError || cause instanceof DeviceLockBusyError || cause instanceof DeviceStateError)
1917
2170
  throw cause;
1918
2171
  if (cause instanceof HttpTransportError && !id) {
1919
2172
  throw new CommandContractError("workflow_start_outcome_unknown", cause.message, "创建服务执行时没有收到完整响应。先查询当前身份可见的执行;不要直接重跑并创建替代执行。", [{ command: "itpay services list --json", reason: "查找可能已经创建的服务执行" }]);