@itpay/cli 2.1.12 → 2.1.18

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,12 +1,14 @@
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 { operationID, taskJournalPath } from "../state/config.js";
4
+ import { TaskJournal } from "../state/task_journal.js";
4
5
  import { validateContext } from "../state/client_context.js";
5
6
  import { dispatchRender } from "../render/index.js";
6
7
  import { ensureIdeImageAttach } from "../render/ide.js";
7
8
  import { buildCheckoutHandoff, shouldPrepareLocalCheckoutImage } from "./checkout_handoff.js";
8
9
  import { localizeCardURL, normalizeCardLocale } from "../render/locale.js";
9
10
  import { buildAgentChatHandoff } from "../render/markdown.js";
11
+ import { decodeRailCatalogJourneys } from "./rail_catalog.js";
10
12
  import { platformKeyForHost } from "../render/plan.js";
11
13
  import { renderTerminalQR } from "../render/qr.js";
12
14
  // Declared at execution creation: the client understands the rail.progressive.v2
@@ -15,6 +17,20 @@ import { renderTerminalQR } from "../render/qr.js";
15
17
  const RAIL_PROGRESSIVE_FEATURES = ["rail.progressive.v2"];
16
18
  import { buildCheckoutQRPlan } from "./buy.js";
17
19
  import { appendFeedbackPostmortemInstruction, CommandContractError, isTerminalServiceExecutionStatus, writeCommandEnvelope, } from "./guidance.js";
20
+ // AUTH04: record the pause point of each Service Execution so a later
21
+ // `itpay auth login` can resume the SAME task. Ids/stage/resume command only —
22
+ // no chat content, PII or credentials. Best-effort: journaling never breaks
23
+ // a command.
24
+ function journalTaskState(serviceExecutionID, envelope, env) {
25
+ try {
26
+ const journal = new TaskJournal(taskJournalPath(env));
27
+ const serviceID = typeof envelope.result?.service_id === "string" ? envelope.result.service_id : undefined;
28
+ journal.observe(serviceExecutionID, envelope.status, serviceID ? `itpay services run ${serviceID} --execution ${serviceExecutionID} --json` : undefined);
29
+ }
30
+ catch {
31
+ /* local journal is advisory; never fail the envelope */
32
+ }
33
+ }
18
34
  const serviceActionStatuses = new Set(["pending", "approved", "rejected", "expired", "cancelled"]);
19
35
  const RAIL_SERVICE_GUIDANCE = {
20
36
  "itpay-rail-exact": {
@@ -98,13 +114,29 @@ export async function runServicesStart(backend, serviceID, options = {}) {
98
114
  });
99
115
  if (response.workflow_entry) {
100
116
  const guidance = railServiceGuidance(serviceID);
101
- 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 });
117
+ writeCommandEnvelope({
118
+ status: "input_required",
119
+ result: { service_execution_id: response.execution.service_execution_id, service_id: serviceID, input_schema: response.workflow_entry.input_schema, ...(guidance ? { guidance } : {}) },
120
+ instruction: guidance ? "按 result.guidance 逐项填写输入(when_to_use 说明本服务适用场景、input_fields 是必填契约、input_example 可直接照抄),然后继续同一服务执行。不要臆造字段名。" : "根据服务声明填写输入,然后继续同一服务执行。",
121
+ next: null,
122
+ interaction: {
123
+ schema_version: "itpay.interaction.v1",
124
+ stage: "input_required",
125
+ input_template: {
126
+ command: `itpay services run ${serviceID} --execution ${response.execution.service_execution_id} --input-json <file> --json`,
127
+ required_input: ["file"],
128
+ executable: false,
129
+ },
130
+ },
131
+ recovery: []
132
+ }, { ...options });
102
133
  return;
103
134
  }
104
135
  const capability = response.capabilities.find((item) => item.phase === response.execution.phase && !item.requires_payment);
105
136
  const requiredInput = requiredInputFields(capability?.input_schema);
106
- const command = capability
107
- ? `itpay services invoke ${response.execution.service_execution_id} --capability ${capability.capability_id}${requiredInput.map((field) => ` --input ${field}=<value>`).join("")} --json`
137
+ const needsInput = requiredInput.length > 0;
138
+ const command = capability && !needsInput
139
+ ? `itpay services invoke ${response.execution.service_execution_id} --capability ${capability.capability_id} --json`
108
140
  : `itpay services next ${response.execution.service_execution_id} --json`;
109
141
  const capabilitySummary = capability ? {
110
142
  capability_id: capability.capability_id,
@@ -125,8 +157,19 @@ export async function runServicesStart(backend, serviceID, options = {}) {
125
157
  : "当前没有可直接调用的 capability;读取服务端下一步,不要猜测 capability。",
126
158
  next: {
127
159
  command,
128
- reason: capability ? "执行当前允许的能力" : "读取服务端计算的下一步",
160
+ reason: capability && !needsInput ? "执行当前允许的能力" : "读取服务端计算的下一步",
129
161
  },
162
+ ...(capability && needsInput ? {
163
+ interaction: {
164
+ schema_version: "itpay.interaction.v1",
165
+ stage: "input_required",
166
+ input_template: {
167
+ command: `itpay services invoke ${response.execution.service_execution_id} --capability ${capability.capability_id}${requiredInput.map((field) => ` --input ${field}=<value>`).join("")} --json`,
168
+ required_input: requiredInput,
169
+ executable: false,
170
+ },
171
+ },
172
+ } : {}),
130
173
  recovery: [],
131
174
  }, {
132
175
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -152,11 +195,19 @@ function locationConfirmationEnvelope(executionID, capabilityID, input, confirma
152
195
  result: { service_execution_id: executionID, query: input, location_confirmation: confirmation },
153
196
  instruction: "尚未查票。仅向用户确认 endpoints 中有歧义的地点,展示真实候选名称、地址和高德链接。不要自行选择候选、把区域改成车站或重复调用原查询。用户选择后保留原输入,带 location_confirmation 回到同一服务。没有候选时请用户补充已知城市或准确地名,再使用修正输入查询。",
154
197
  next: null,
155
- recovery: confirmation.can_resume ? [{
156
- command: `itpay services invoke ${executionID} --capability ${capabilityID}${formatInputOptions({ ...input,
157
- location_confirmation: { plan_id: confirmation.plan_id, token: confirmation.token, choices } })} --json`,
158
- reason: "仅在用户明确选择后替换 choices 并执行;30 分钟有效",
159
- }] : [],
198
+ ...(confirmation.can_resume ? {
199
+ interaction: {
200
+ schema_version: "itpay.interaction.v1",
201
+ stage: "location_confirmation_required",
202
+ input_template: {
203
+ command: `itpay services invoke ${executionID} --capability ${capabilityID}${formatInputOptions({ ...input,
204
+ location_confirmation: { plan_id: confirmation.plan_id, token: confirmation.token, choices } })} --json`,
205
+ required_input: ["choices"],
206
+ executable: false,
207
+ },
208
+ },
209
+ } : {}),
210
+ recovery: [],
160
211
  };
161
212
  }
162
213
  function requiredInputFields(schema) {
@@ -313,18 +364,33 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
313
364
  }
314
365
  }
315
366
  else if (items.length > 0 && requestedCapability.requires_human_action) {
316
- next = {
317
- command: `itpay services action ${response.execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
318
- reason: "在当前 Execution 记录用户选择",
319
- };
367
+ next = null;
320
368
  }
321
369
  else if (items.length === 0) {
322
370
  next = null;
323
371
  }
372
+ const interaction = items.length > 0 && requestedCapability.requires_human_action
373
+ ? {
374
+ schema_version: "itpay.interaction.v1",
375
+ stage: "query_results_ready",
376
+ by_goal: {
377
+ compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
378
+ prepare_checkout: {
379
+ action: "select_under_user_rules_then_prepare",
380
+ requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
381
+ },
382
+ },
383
+ input_template: {
384
+ command: `itpay services action ${response.execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
385
+ required_input: ["rank"],
386
+ executable: false,
387
+ },
388
+ }
389
+ : undefined;
324
390
  if (preview?.resolved_locations)
325
391
  instruction += " 按 resolved_locations 说明实际解析地点;区域级位置仅是范围代表点,接驳时间不是从用户精确位置计算的。";
326
392
  return {
327
- value: { status, result: baseResult, instruction, next, recovery: [] },
393
+ value: { status, result: baseResult, instruction, next, recovery: [], ...(interaction ? { interaction } : {}) },
328
394
  plainResult: serviceResultPlainLines(baseResult),
329
395
  };
330
396
  }
@@ -766,6 +832,7 @@ export async function runServicesGet(backend, serviceExecutionID, options = {})
766
832
  export async function runServicesNext(backend, serviceExecutionID, options = {}) {
767
833
  const response = await backend.getServiceExecution(serviceExecutionID, options.sinceSnapshot ? { sinceSnapshot: options.sinceSnapshot } : {});
768
834
  const envelope = servicesNextEnvelope(response);
835
+ journalTaskState(serviceExecutionID, envelope, options.env ?? process.env);
769
836
  if (response.rail_booking)
770
837
  envelope.result = { ...envelope.result, rail_booking: response.rail_booking };
771
838
  writeCommandEnvelope(envelope, {
@@ -867,6 +934,47 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
867
934
  // planning evidence — a free owner-validated read that never touches the
868
935
  // grant/Vault path. Without the selectors the authorized-delivery flow is
869
936
  // unchanged.
937
+ // rail.planning full catalog read: --snapshot alone returns the complete
938
+ // committed rail.catalog.v3 — shared dictionaries and field_legend are
939
+ // preserved verbatim; one catalog, never split into candidates+journeys.
940
+ if (options.snapshot && !options.journey) {
941
+ const committed = await backend.getRailPlanningCatalog(serviceExecutionID, options.snapshot);
942
+ const catalog = committed.catalog;
943
+ // The committed catalog may ship shared_rows.v1 positional rows — decode
944
+ // the summary through the column legend; an unknown encoding is an
945
+ // explicit upgrade error, never an empty catalog.
946
+ const decoded = catalog
947
+ ? decodeRailCatalogJourneys(catalog)
948
+ : { journeys: [], packed: false };
949
+ const journeys = decoded.journeys;
950
+ const counts = (catalog?.counts ?? {});
951
+ const journeyCount = journeys.length || Number(counts?.combinations ?? 0);
952
+ writeCommandEnvelope({
953
+ status: "ready",
954
+ result: {
955
+ service_execution_id: serviceExecutionID,
956
+ plan_id: committed.plan_id,
957
+ snapshot_id: committed.snapshot_id,
958
+ query_revision: committed.query_revision,
959
+ catalog,
960
+ },
961
+ instruction: "catalog 是本次查询的完整无损目录:journeys 为全部可行组合(含超出预览分页的组合),plans 是每个组合下的购票方案,stations/services/offers/ground_options 为共享字典,field_legend 解释紧凑字段。逐组合向用户说明车次、换乘与接驳取舍;席别与价格以 plans 内报价为准、下单前仍需核验;姓名身份证手机号只在 ItPay 网页填写。",
962
+ next: journeys.length > 0 && journeys[0]?.ref && journeys[0].ref !== "?"
963
+ ? { command: `itpay services read-result ${serviceExecutionID} --snapshot ${committed.snapshot_id} --journey ${journeys[0].ref} --json`, reason: "查看单个行程明细" }
964
+ : { command: `itpay services next ${serviceExecutionID} --since-snapshot ${committed.snapshot_id} --json`, reason: "返回规划进展" },
965
+ recovery: [],
966
+ }, {
967
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
968
+ ...(options.output ? { output: options.output } : {}),
969
+ plainResult: journeys.length > 0
970
+ ? [
971
+ `${journeyCount} combinations:`,
972
+ ...journeys.map((j) => `${j.defaultLayer === "backup" ? "[备选] " : "[主选择] "}${j.ref} ${j.route}`),
973
+ ]
974
+ : [`catalog: ${committed.snapshot_id} (no combinations)`],
975
+ });
976
+ return;
977
+ }
870
978
  if (options.journey) {
871
979
  const detail = await backend.getRailJourneyDetail(serviceExecutionID, options.journey, options.snapshot);
872
980
  writeCommandEnvelope({
@@ -993,17 +1101,33 @@ function railJourneySummary(card, serviceExecutionID) {
993
1101
  ...(trains.length ? { trains } : {}),
994
1102
  ...(first?.departure || last?.arrival ? { time: `${first?.departure ?? ""}–${last?.arrival ?? ""}` } : {}),
995
1103
  ...(offer ? { price: formatMoney(offer.rail_payable_minor, offer.currency) } : { price: "unknown" }),
1104
+ ...(card.decision_role ? { decision_role: card.decision_role } : {}),
1105
+ ...(card.explanation?.length ? { explanation: card.explanation } : {}),
1106
+ ...(card.reason_codes?.length ? { reason_codes: card.reason_codes } : {}),
1107
+ ...(card.tradeoff_codes?.length ? { tradeoff_codes: card.tradeoff_codes } : {}),
1108
+ ...(card.recommended_profile ? { recommended_profile: card.recommended_profile,
1109
+ price_basis: "price为默认购票方案参考价;推荐对应的席别、接驳及总价以recommended_profile为准,按其中ticket_plan_ref/offer_refs查看并确认后下单。" } : {}),
996
1110
  availability: card.availability,
997
1111
  booking_support: card.booking_support,
998
1112
  ...(card.observed_at ? { observed_at: card.observed_at } : {}),
999
1113
  ...(card.risk_notes?.length ? { risk_notes: card.risk_notes } : {}),
1000
1114
  ...(card.booking_support === "single_leg"
1001
- ? { select: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${card.journey_id} --json` }
1115
+ ? {
1116
+ select: `itpay services action ${serviceExecutionID} --action select_journey --actor-type human --status approved --input journey_id=${card.journey_id} --json`,
1117
+ // Delegated selection under explicit user rules is an agent action —
1118
+ // never record it as a human pick.
1119
+ 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`,
1120
+ }
1002
1121
  : {}),
1003
1122
  ...(card.booking_offer
1004
1123
  ? {
1005
1124
  booking_offer: card.booking_offer,
1006
- 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>}`,
1125
+ booking_template: {
1126
+ command: `itpay services run ${card.booking_offer.service_id} --input-json <file> --json`,
1127
+ required_input: ["file"],
1128
+ executable: false,
1129
+ input_example: { selection: { token: card.booking_offer.selection_token, seat_type: "<席别>" }, passengers: "<n>" },
1130
+ },
1007
1131
  }
1008
1132
  : {}),
1009
1133
  };
@@ -1015,8 +1139,27 @@ function railPlanningEnvelope(model) {
1015
1139
  const plan = model.rail_planning;
1016
1140
  const se = model.execution.service_execution_id;
1017
1141
  const expansion = plan.search?.expansion_status ?? "running";
1142
+ const counts = plan.search?.counts;
1018
1143
  const journeys = [plan.recommendation, ...(plan.alternatives ?? [])].filter(Boolean);
1019
1144
  const cards = journeys.map((card) => railJourneySummary(card, se));
1145
+ // Journey counts come from the committed catalog — real combination counts,
1146
+ // never seat-row counts. Transfer buckets follow the verified transfer_count.
1147
+ const journeyMix = counts && (counts.journeys_total ?? 0) > 0
1148
+ ? `${counts.journeys_total}个铁路组合(直达${counts.journeys_direct ?? 0}/1中转${counts.journeys_one_transfer ?? 0}/2中转${counts.journeys_multi_transfer ?? 0})`
1149
+ : "";
1150
+ const pairProgress = counts && (counts.pairs_total ?? 0) > 0
1151
+ ? `已检查${counts.pairs_checked ?? 0}/${counts.pairs_total}个附近站对`
1152
+ : "";
1153
+ // §S5: failed pairs are named honestly — never implied by "checked" and
1154
+ // never rephrased as "no direct exists".
1155
+ // §S5: a rules-only recommendation after a failed model attempt is honest —
1156
+ // "rules finished, AI didn't", never silently dressed as a model pick.
1157
+ const modelDegradedHint = plan.search?.model_outcome === "fallback"
1158
+ ? "规则推荐已完成,AI模型未完成;推荐有效,但置信度说明以规则结果为准。"
1159
+ : "";
1160
+ const failedPairsHint = counts && (counts.pairs_failed ?? 0) > 0
1161
+ ? `有${counts.pairs_failed}个站对暂未查明,不能确认没有直达;已有结果仍可查看。`
1162
+ : "";
1020
1163
  const result = {
1021
1164
  service_execution_id: se,
1022
1165
  rail_planning: {
@@ -1024,12 +1167,21 @@ function railPlanningEnvelope(model) {
1024
1167
  query_revision: plan.query_revision,
1025
1168
  snapshot_id: plan.snapshot_id,
1026
1169
  expansion_status: expansion,
1170
+ ...(plan.search?.phase ? { phase: plan.search.phase } : {}),
1171
+ ...(plan.search?.transfer_status ? { transfer_status: plan.search.transfer_status } : {}),
1172
+ ...(plan.search?.transition_reason ? { transition_reason: plan.search.transition_reason } : {}),
1173
+ ...(plan.search?.authorization ? { authorization: plan.search.authorization } : {}),
1174
+ ...(plan.search?.expansion_target ? { expansion_target: plan.search.expansion_target } : {}),
1175
+ ...(plan.search?.decision_source ? { decision_source: plan.search.decision_source } : {}),
1176
+ ...(plan.search?.model_outcome ? { model_outcome: plan.search.model_outcome } : {}),
1027
1177
  ...(plan.search?.reason ? { reason: plan.search.reason } : {}),
1028
1178
  ...(plan.search?.poll_after_ms ? { poll_after_ms: plan.search.poll_after_ms } : {}),
1029
1179
  ...(plan.result_not_updated ? { result_not_updated: true } : {}),
1180
+ ...(counts ? { counts } : {}),
1030
1181
  ...(plan.budgets ? { budgets: plan.budgets } : {}),
1031
1182
  ...(plan.coverage ? { coverage: plan.coverage } : {}),
1032
1183
  },
1184
+ ...(plan.notices?.length ? { notices: plan.notices } : {}),
1033
1185
  ...(plan.recommendation ? { recommendation: railJourneySummary(plan.recommendation, se) } : {}),
1034
1186
  ...(cards.length ? { journeys: cards } : {}),
1035
1187
  ...(plan.available_actions?.length ? { available_actions: plan.available_actions } : {}),
@@ -1038,43 +1190,174 @@ function railPlanningEnvelope(model) {
1038
1190
  command: `itpay services next ${se}${plan.snapshot_id ? ` --since-snapshot ${plan.snapshot_id}` : ""} --json`,
1039
1191
  reason: "读取同一规划的增量进展(不触发供应商调用)",
1040
1192
  };
1193
+ // Two-goal interaction contract (spec 03 §2): the local Agent picks compare
1194
+ // or prepare_checkout from full context — the envelope teaches both branches
1195
+ // so a delegated purchase keeps moving and a comparison never buys.
1196
+ const bookable = journeys.find((card) => card.booking_support === "single_leg" && card.booking_offer);
1197
+ const queryResultsInteraction = journeys.length > 0
1198
+ ? {
1199
+ schema_version: "itpay.interaction.v1",
1200
+ stage: "query_results_ready",
1201
+ by_goal: {
1202
+ compare: { action: "present_options", max_options: 3, stop_after_presentation: true },
1203
+ prepare_checkout: {
1204
+ action: "select_under_user_rules_then_prepare",
1205
+ recipe: "rail.selected-to-checkout.v1",
1206
+ requires: ["current_delegation", "eligible_current_selection", "owner_permits_next_step"],
1207
+ ...(bookable
1208
+ ? {
1209
+ steps: [
1210
+ "按用户明确规则在 journeys 中选择合格者(卡片 select_delegated 提交 agent 委托选择;人类亲自选择用 select)",
1211
+ `用所选卡片的 booking_template 写 owner-only 临时输入(selection token + 席别 + 人数,不含身份信息),运行 itpay services run itpay-rail-booking --input-json <file> --json`,
1212
+ "owner 要求聊天 review 时一次合并补齐必要字段,真实确认后提交原 workflow:confirm_booking;不得伪造 accept",
1213
+ `确认后恢复同一购买执行:itpay services run itpay-rail-booking --execution <实际ID> --json,沿用有界等待与受保护 Checkout`,
1214
+ ],
1215
+ }
1216
+ : { unavailable: "当前结果无可整段购买映射;分别说明各段风险,不得合成原子联程。" }),
1217
+ },
1218
+ },
1219
+ }
1220
+ : undefined;
1221
+ const queryResultsCommunication = journeys.length > 0
1222
+ ? {
1223
+ schema_version: "itpay.communication.v1",
1224
+ status_line: expansion === "paused"
1225
+ ? "首批结果已就绪,扩展搜索已暂停等待用户决定"
1226
+ : "查询结果就绪",
1227
+ recommended_reason: plan.recommendation?.explanation?.[0],
1228
+ human_steps: [
1229
+ "compare:比较首选与最多两个备选后告知取舍",
1230
+ "prepare_checkout:委托仍在时确认所选,继续官方确认页",
1231
+ "姓名、证件、手机号只在受保护 Checkout 页填写",
1232
+ ],
1233
+ next_expectation: "compare:用户选定其一;prepare_checkout:委托仍在时继续到官方确认入口,人完成review与付款",
1234
+ must_convey: [
1235
+ "推荐与备选的时间/费用/便利性取舍",
1236
+ "金额为查询时参考价,下单前须核验",
1237
+ "姓名、证件、手机号只在受保护网页填写",
1238
+ ],
1239
+ }
1240
+ : undefined;
1041
1241
  switch (expansion) {
1042
1242
  case "complete":
1043
1243
  return {
1044
1244
  status: "ready",
1045
1245
  result,
1046
- instruction: "规划已收敛:向用户展示 recommendation 与 journeys 卡片(车次/时间/价格/余票状态),请用户选定 journey 后用其 select 命令提交;乘车人身份信息不在此收集,后续购买走受保护 Checkout。",
1246
+ // §7.3: the lead line carries real combination counts bucketed by
1247
+ // verified transfer count — seat rows never inflate the journey count.
1248
+ instruction: `${journeyMix ? `本次共${journeyMix}(席别不重复计数)。` : ""}${plan.search?.reason === "budget_exhausted" ? "本次查询已达到上限,以下是已查到的方案,搜索范围尚未全部核验。" : "本轮规划已完成。"}compare:解释首选及最多两个有意义备选的时间/费用/便利性取舍后等待用户选择。prepare_checkout:用户已明确委托按规则选择时,按 interaction.recipe 选合格者并继续到官方确认页,不再次问是否下单;身份、review和最终付款边界仍然生效。乘车人身份信息不在此收集,后续购买走受保护 Checkout。`,
1047
1249
  next: journeys[0]?.booking_support === "single_leg"
1048
- ? { command: railJourneySummary(journeys[0], se).select, reason: "选定推荐行程" }
1250
+ ? { command: railJourneySummary(journeys[0], se).select, reason: "用户在 compare 后选定推荐行程时执行;prepare_checkout 且规则命中时用 select_delegated" }
1049
1251
  : null,
1252
+ ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1253
+ ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1050
1254
  recovery: [],
1051
1255
  };
1052
- case "paused":
1256
+ case "paused": {
1257
+ // §7.1 case 1: usable directs committed while ranked scope remains. The
1258
+ // message must carry real counts, and expansion is a user choice — the
1259
+ // Agent must not treat "expandable" as consent already given.
1260
+ const directHint = (plan.search?.reason === "direct_options_ready" || plan.search?.phase === "direct_ready") && journeyMix
1261
+ ? `本次找到${counts?.journeys_direct ?? 0}趟可选直达(${journeyMix}),${pairProgress || "部分站对已核验"},中转尚未展开。可以直接选一趟;时间或价格不合适时可继续搜索其它直达及中转,等待会更久。`
1262
+ : "";
1263
+ // §2.1: a delivered-pause at the authorized transfer depth names what a
1264
+ // consented expand buys next — verified-empty is "nothing at this
1265
+ // depth", never "no routes exist".
1266
+ const transferHint = (plan.search?.reason === "transfer_options_ready" || plan.search?.phase === "transfer_ready") && journeyMix
1267
+ ? `一次中转范围已核验完毕并交付(${journeyMix})。可以直接选用;也可以继续比较两次中转方案,查询会明显更久。`
1268
+ : plan.search?.reason === "no_options_verified"
1269
+ ? `当前授权深度内的范围已核验完、未找到可用方案——这不等于没有其它路线。可授权继续更深的两次中转搜索(耗时显著增加),或停止。`
1270
+ : "";
1271
+ const expandHint = plan.search?.expansion_target === "two_transfer"
1272
+ ? "expand_search 将授权一次更深的两次中转搜索(等待更久)"
1273
+ : plan.search?.expansion_target === "more_direct_and_one_transfer"
1274
+ ? "expand_search 将补齐剩余直达并展开一次中转"
1275
+ : "expand_search 是唯一会再消耗供应商配额的命令,其余均为本地读取";
1276
+ // §7.1 case 3 on a recoverable pause: unverified scope is named with real
1277
+ // counts, and the phrasing never implies the whole nearby range ran.
1278
+ const partialPause = !directHint && counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1279
+ ? `目前找到${counts.journeys_total ?? 0}个可选组合;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1280
+ : "";
1281
+ // §S5: a dispatch whose outcome is unknown pauses for reconciliation —
1282
+ // the message names the state, never rephrases it as a normal pause or
1283
+ // a no-directs result, and never offers a blind retry (expand_search is
1284
+ // withheld by the projection too).
1285
+ if (plan.search?.reason === "dispatch_outcome_unknown") {
1286
+ return {
1287
+ status: "awaiting_input",
1288
+ result,
1289
+ instruction: `${counts?.journeys_total ? `已保存${counts.journeys_total}个已核验组合仍可查看。` : ""}上一次查询发送结果未确认(可能已部分查询),系统已暂停并等待对账,不会自动重复发送,也不能据此断定没有车次。展示已有卡片,稍后可重新读取最新状态。`,
1290
+ next: null,
1291
+ recovery: [{ command: `itpay services next ${se} --json`, reason: "稍后读取对账后的最新状态" }],
1292
+ };
1293
+ }
1053
1294
  return {
1054
1295
  status: "awaiting_input",
1055
1296
  result,
1056
- instruction: "规划已发布首批结果并暂停扩展:展示现有卡片与 available_actions,等待用户意图(expand_search 是唯一会再消耗供应商配额的命令,其余均为本地读取)。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。",
1297
+ instruction: `${modelDegradedHint}${failedPairsHint}${directHint}${transferHint}${partialPause}规划已发布首批结果并暂停扩展:展示现有卡片与 available_actions,等待用户意图(${expandHint})。ready 后 next 为空表示向用户汇报并等待,不是永远没有更多。compare:解释首选及最多两个备选后等待;prepare_checkout:委托仍在且选项合格时按 interaction.recipe 继续到官方确认入口,不再次问是否下单。`,
1057
1298
  next: null,
1299
+ ...(queryResultsInteraction ? { interaction: queryResultsInteraction } : {}),
1300
+ ...(queryResultsCommunication ? { communication: queryResultsCommunication } : {}),
1058
1301
  recovery: [],
1059
1302
  };
1303
+ }
1060
1304
  case "failed":
1061
1305
  case "cancelled":
1062
- case "expired":
1306
+ case "expired": {
1307
+ // §7.1 case 3: when scope was only partially verified the honest wording
1308
+ // is "N confirmed + X pairs unverified" — never the case-2 phrasing that
1309
+ // implies the whole nearby range was checked.
1310
+ const partial = counts && (counts.pairs_total ?? 0) > (counts.pairs_checked ?? 0)
1311
+ ? `目前找到${counts.journeys_total ?? 0}个可选组合;还有${(counts.pairs_total ?? 0) - (counts.pairs_checked ?? 0)}个站对/部分中转路径未核验。以下是已确认结果,不能据此断定没有其它走法。`
1312
+ : "";
1313
+ // §S5: a processing failure with a committed catalog is "saved N
1314
+ // verified combinations, later expansion/recommend incomplete" — the
1315
+ // existing catalog stays readable and nothing implies no other routes.
1316
+ const savedCatalog = expansion === "failed" && (counts?.journeys_total ?? 0) > 0
1317
+ ? `已保存${counts?.journeys_total}个已核验组合;后续扩展/推荐未完成,原因是本次处理异常。已有方案仍可查看,不代表没有其它路线,也不需要重复提交订单或付款。`
1318
+ : "";
1063
1319
  return {
1064
1320
  status: expansion,
1065
1321
  result,
1066
- instruction: "本次规划已结束且不可续用;展示已有卡片后如需重查请用户确认后用同一服务新建执行。不要自动重新发起。",
1322
+ instruction: `${modelDegradedHint}${failedPairsHint}${savedCatalog}${partial}本次规划已结束且不可续用;展示已有卡片后如需重查请用户确认后用同一服务新建执行。不要自动重新发起。`,
1067
1323
  next: null,
1068
1324
  recovery: [{ command: `itpay services events ${se} --json`, reason: "读取同一任务的处理记录" }],
1069
1325
  };
1070
- default:
1326
+ }
1327
+ default: {
1328
+ // §7.1 case 2: the automatic phase is still running. When the verified
1329
+ // stage is transfer the user must hear "direct scope found nothing
1330
+ // usable, transfer search continues" — never re-submit.
1331
+ const transferSearching = plan.search?.phase === "transfer"
1332
+ || plan.search?.transition_reason === "no_usable_direct"
1333
+ || plan.search?.transfer_status === "in_progress" || plan.search?.transfer_status === "pending";
1334
+ // §S5: an expansion the user authorized keeps earlier directs and says
1335
+ // so — it must not reuse the automatic "no usable direct" wording.
1336
+ const manualExpansion = plan.search?.authorization === "manual"
1337
+ ? `已保留之前的直达结果,正在按你的要求比较更多直达/中转及分段购票方案,需要多一点时间。`
1338
+ : "";
1339
+ const transferHint = !manualExpansion && transferSearching
1340
+ ? `已检查本次附近站范围,暂未找到符合条件且有票的直达,正在继续搜索中转组合,需要多一点时间;不需要重新提交。`
1341
+ : "";
1071
1342
  return {
1072
1343
  status: "planning",
1073
1344
  result,
1074
- instruction: "规划进行中:已提交的卡片可先向用户展示比较;稍后按 next 增量轮询同一执行(since-snapshot 命中时负载会被压缩)。不要重新发起查询。",
1345
+ instruction: `${modelDegradedHint}${manualExpansion}${transferHint}${failedPairsHint}规划进行中:已提交的卡片可先向用户展示比较;稍后按 next 增量轮询同一执行(since-snapshot 命中时负载会被压缩,但阶段与计数仍返回最新值)。不要重新发起查询。`,
1075
1346
  next: nextPoll,
1347
+ communication: {
1348
+ schema_version: "itpay.communication.v1",
1349
+ status_line: transferSearching && !manualExpansion
1350
+ ? "直达范围暂未找到可用方案,中转组合仍在搜索"
1351
+ : "规划进行中,已确认的方案可先向用户展示",
1352
+ next_expectation: "按 next 增量轮询同一执行;不重新发起查询",
1353
+ must_convey: [
1354
+ "当前是进行中的部分结果,不代表最终答复",
1355
+ ...(failedPairsHint ? ["部分站对尚未查明,不能说'没有车'"] : []),
1356
+ ],
1357
+ },
1076
1358
  recovery: [],
1077
1359
  };
1360
+ }
1078
1361
  }
1079
1362
  }
1080
1363
  function terminalExecutionEnvelope(model) {
@@ -1229,18 +1512,48 @@ function servicesNextEnvelope(model) {
1229
1512
  result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1230
1513
  required_fields: requiredFields, sides },
1231
1514
  instruction: "向用户展示 sides 中 status=needs_confirmation 一端的候选(名称+坐标),请用户选定后按 required_fields 逐项各传一个 --input:名称取候选 name,坐标取候选 location 的 [lng,lat];已 resolved 的一端把其 resolved_place 原样填入。继续同一执行,不重新查询;这一步不是购买确认。",
1232
- next: { command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`, reason: "用户确认后继续当前查询" },
1515
+ next: null,
1516
+ interaction: {
1517
+ schema_version: "itpay.interaction.v1",
1518
+ stage: "location_confirmation_required",
1519
+ input_template: {
1520
+ command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`,
1521
+ required_input: requiredFields,
1522
+ executable: false,
1523
+ },
1524
+ },
1233
1525
  recovery: [],
1234
1526
  };
1235
1527
  }
1236
1528
  const review = action.context?.review;
1237
1529
  if (review) {
1530
+ // W4: when the owner projects an official web review entry, present it
1531
+ // and wait for the human there; otherwise keep the one consolidated
1532
+ // chat review — never fabricate an accepted review.
1533
+ const reviewURL = [review.web_review_url, review.entry_url, review.review_url]
1534
+ .find((value) => typeof value === "string" && /^https:\/\//.test(value));
1535
+ const requirementsRemaining = Array.isArray(review.requirements_remaining)
1536
+ ? review.requirements_remaining
1537
+ : undefined;
1238
1538
  return {
1239
1539
  status: "booking_review_required",
1240
1540
  result: { service_execution_id: id, service_id: execution.service_id, human_action: action,
1241
- required_fields: requiredFields },
1242
- instruction: "向用户展示 context.review 中的行程(legs)与可选席别/座位偏好词表(seat_options),并明确告知:座位偏好仅为请求、不保证分配(对应 notice_version 文案须经用户同意)。收集乘客人数、席别与每位乘客偏好后,按 required_fields 组装完整 JSON 对象,经 --input-json <file> 提交——draft_revision 必须等于服务端当前值,被拒绝(booking_review_changed)时重新读取后重试。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。",
1243
- next: { command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <file> --json`, reason: "提交用户确认后的行程确认/修订" },
1541
+ required_fields: requiredFields,
1542
+ ...(requirementsRemaining ? { requirements_remaining: requirementsRemaining } : {}) },
1543
+ ...(reviewURL ? { handoff: { url: reviewURL, kind: "booking_review" } } : {}),
1544
+ instruction: reviewURL
1545
+ ? "已在 handoff.url 提供官方行程确认页,请用户在该页核对行程、席别与座位偏好并确认;页面打开请求不代表用户已确认。该页失效或用户更愿在对话中确认时,仍可按下述 input_template 一次合并提交。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。"
1546
+ : "向用户展示 context.review 中的行程(legs)与可选席别/座位偏好词表(seat_options),并明确告知:座位偏好仅为请求、不保证分配(对应 notice_version 文案须经用户同意)。这是购买前的合并确认:把乘客人数、席别与每位乘客的座位偏好一次问清,然后按 required_fields 组装完整 JSON 对象,经 --input-json <file> 提交——draft_revision 必须等于服务端当前值,被拒绝(booking_review_changed)时重新读取后重试。未得真实确认不得提交,不能暗设 accept_non_guaranteed。乘客姓名/证件等身份信息不在此提交,仍走受保护 Checkout 页。",
1547
+ next: null,
1548
+ interaction: {
1549
+ schema_version: "itpay.interaction.v1",
1550
+ stage: "booking_review_required",
1551
+ input_template: {
1552
+ command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved --input-json <file> --json`,
1553
+ required_input: requiredFields.length > 0 ? requiredFields : ["file"],
1554
+ executable: false,
1555
+ },
1556
+ },
1244
1557
  recovery: [{ command: `itpay services next ${id} --json`, reason: "重新读取当前 draft_revision 后重试" }],
1245
1558
  };
1246
1559
  }
@@ -1248,7 +1561,16 @@ function servicesNextEnvelope(model) {
1248
1561
  status: "confirmation_required",
1249
1562
  result: { service_execution_id: id, service_id: execution.service_id, human_action: action },
1250
1563
  instruction: "请展示待确认的内容,请用户明确确认后,按 input_schema 填写 --input 字段。继续同一执行,不重新查询;这一步不是购买确认。",
1251
- next: { command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`, reason: "用户确认后继续当前查询" },
1564
+ next: null,
1565
+ interaction: {
1566
+ schema_version: "itpay.interaction.v1",
1567
+ stage: "confirmation_required",
1568
+ input_template: {
1569
+ command: `itpay services action ${id} --action ${action.action_type} --actor-type human --status approved${requiredFields.map((field) => ` --input ${field}=<值>`).join("")} --json`,
1570
+ required_input: requiredFields,
1571
+ executable: false,
1572
+ },
1573
+ },
1252
1574
  recovery: [],
1253
1575
  };
1254
1576
  }
@@ -1256,8 +1578,15 @@ function servicesNextEnvelope(model) {
1256
1578
  let command = `itpay services next ${id} --json`;
1257
1579
  if (state === "payment")
1258
1580
  command = `itpay services checkout ${id} --json`;
1259
- if (state === "input_required")
1260
- command = `itpay services run ${execution.service_id} --execution ${id} --input-json <file> --json`;
1581
+ // input_required: the resume command needs a real file path — expose it as
1582
+ // a non-executable input_template, not a fake `next.command`.
1583
+ const inputTemplate = state === "input_required"
1584
+ ? {
1585
+ command: `itpay services run ${execution.service_id} --execution ${id} --input-json <file> --json`,
1586
+ required_input: ["file"],
1587
+ executable: false,
1588
+ }
1589
+ : undefined;
1261
1590
  const guidance = railServiceGuidance(execution.service_id);
1262
1591
  const failedStep = recovery ? failedWorkflowStep(model.workflow?.steps, model.workflow?.error_code) : undefined;
1263
1592
  const failedStepGuidance = failedStep ? WORKFLOW_STEP_GUIDANCE[failedStep] : undefined;
@@ -1295,14 +1624,26 @@ function servicesNextEnvelope(model) {
1295
1624
  ? "按 result.guidance 的字段契约填写输入后继续同一服务执行;不要臆造字段名。"
1296
1625
  : "继续读取同一执行;缺少输入时按服务声明补齐。"),
1297
1626
  next: recovery ? null : { command, reason: "继续当前流程" },
1627
+ ...(inputTemplate || (state === "payment" && model.workflow?.human_action?.context?.review) ? {
1628
+ interaction: {
1629
+ schema_version: "itpay.interaction.v1",
1630
+ stage: inputTemplate ? "input_required" : "payment",
1631
+ ...(inputTemplate ? { input_template: inputTemplate } : {}),
1632
+ ...(state === "payment" && model.workflow?.human_action?.context?.review ? {
1633
+ revise_template: {
1634
+ command: `itpay services action ${id} --action ${model.workflow.human_action.action_type} --actor-type human --status approved --input-json <file> --json`,
1635
+ required_input: ["file"],
1636
+ executable: false,
1637
+ },
1638
+ } : {}),
1639
+ },
1640
+ } : {}),
1298
1641
  recovery: recovery && state === "failed"
1299
1642
  ? [
1300
1643
  { command: `itpay services start ${execution.service_id} --json`, reason: "修正输入后重新发起(本执行已终止不能续用)" },
1301
1644
  ...(guidance ? [{ command: "itpay docs show rail-booking --json", reason: "查看本服务输入字段契约与示例" }] : []),
1302
1645
  ]
1303
- : state === "payment" && model.workflow?.human_action?.context?.review
1304
- ? [{ command: `itpay services action ${id} --action ${model.workflow.human_action.action_type} --actor-type human --status approved --input-json <file> --json`, reason: "付款前修订席别/座位偏好(draft_revision 以服务端为准)" }]
1305
- : [],
1646
+ : [],
1306
1647
  };
1307
1648
  }
1308
1649
  const terminalEnvelope = terminalExecutionEnvelope(model);
@@ -1657,6 +1998,12 @@ function buildServicesCheckoutEnvelope(response, checkoutURL, plan, agentType, t
1657
1998
  capability_id: checkoutCapabilityID(response),
1658
1999
  locked_input: response.locked_input,
1659
2000
  amount,
2001
+ ...(checkout.checkout.expires_at ? {
2002
+ // The shared server-owned payable deadline D: refreshing the page or
2003
+ // re-creating a checkout can never extend it; past it the checkout
2004
+ // and pending order are terminally cancelled.
2005
+ payment_deadline_at: checkout.checkout.expires_at,
2006
+ } : {}),
1660
2007
  },
1661
2008
  handoff: presentationHandoff.handoff,
1662
2009
  instruction: presentationHandoff.instruction,
@@ -1745,7 +2092,12 @@ export async function runServicesRun(backend, config, serviceID, input, options
1745
2092
  const until = Date.now() + (options.timeoutSeconds ?? 120) * 1000;
1746
2093
  const sleep = options.sleep ?? ((milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)));
1747
2094
  const paid = () => model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
1748
- while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && Date.now() < until) {
2095
+ // §7.2: for progressive services the committed rail_planning projection is
2096
+ // itself a deliverable — return the same railPlanningEnvelope as soon as it
2097
+ // exists instead of hiding every stage behind the 120s workflow poll. The
2098
+ // owner keeps advancing in the background; the CLI returning early never
2099
+ // cancels it.
2100
+ while ((["queued", "running", "delivery"].includes(model.workflow?.status ?? "") || (model.workflow?.status === "payment" && paid())) && !model.current_delivery && !model.rail_planning && Date.now() < until) {
1749
2101
  await sleep(options.pollIntervalMS ?? 1500);
1750
2102
  model = await backend.getServiceExecution(id);
1751
2103
  }