@itpay/cli 2.1.4 → 2.1.5

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.
@@ -123,6 +123,9 @@ export class BackendClient {
123
123
  advanceServiceExecution(id, input, idempotencyKey) {
124
124
  return this.http.post(`/v1/service-executions/${encodeURIComponent(id)}/advance`, { input, idempotency_key: idempotencyKey });
125
125
  }
126
+ createRailPhoneLink() {
127
+ return this.http.post("/v1/rail/phone-links", {});
128
+ }
126
129
  invokeServiceCapability(serviceExecutionID, capabilityID, input) {
127
130
  return this.http.post(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/capabilities/${encodeURIComponent(capabilityID)}/invoke`, input, { replaySafe: Boolean(input.idempotency_key) });
128
131
  }
@@ -78,15 +78,36 @@ function pendingCheckoutEnvelope(presentation, checkoutURL, plan, nextCommand, a
78
78
  : {}),
79
79
  ...(platform === "markdown" ? { markdown: buildAgentChatHandoff(plan).markdown } : {}),
80
80
  });
81
+ const railQuote = presentation.rail_quote;
82
+ const railPassengersPending = presentation.checkout_details === "rail_passengers" && !presentation.rail_passengers_confirmed;
81
83
  return {
82
84
  status: "human_checkout_required",
83
85
  result: {
84
86
  checkout_id: presentation.checkout.checkout_id,
85
87
  payment: "pending",
86
88
  amount,
89
+ ...(railQuote ? { rail_quote: {
90
+ passengers: railQuote.passengers,
91
+ expires_at: railQuote.expires_at,
92
+ legs: railQuote.legs.map((leg) => ({
93
+ train_code: leg.train_code,
94
+ travel_date: leg.travel_date,
95
+ route: `${leg.from} → ${leg.to}`,
96
+ time: `${leg.departure}–${leg.arrival}`,
97
+ seat_name: leg.seat_name,
98
+ ...(leg.seat_preferences?.length ? { seat_preferences: leg.seat_preferences } : {}),
99
+ ...(leg.seat_request_planned ? {
100
+ seat_request_planned: leg.seat_request_planned,
101
+ auto_reason: leg.auto_reason ?? "none",
102
+ } : {}),
103
+ })),
104
+ } } : {}),
105
+ ...(railPassengersPending ? { rail_passengers_confirmed: false } : {}),
87
106
  },
88
107
  handoff: presentationHandoff.handoff,
89
- instruction: presentationHandoff.instruction,
108
+ instruction: railPassengersPending
109
+ ? `${presentationHandoff.instruction} 请用户在受保护网页填写乘车人并确认报价;姓名、证件和手机号只在网页填写,不要贴到对话中。座位偏好仅为购票请求、购票时才提交给供应商且不保证满足;无法逐人提交的偏好将自动分配座位,以实际出票为准。`
110
+ : presentationHandoff.instruction,
90
111
  next: { command: nextCommand, reason: "稍后只查询同一 Checkout" },
91
112
  recovery: [],
92
113
  };
@@ -34,6 +34,7 @@ export async function runServicesStart(backend, serviceID, options = {}) {
34
34
  const capabilitySummary = capability ? {
35
35
  capability_id: capability.capability_id,
36
36
  required_input: requiredInput,
37
+ input_schema: capability.input_schema,
37
38
  ...(capability.free_quota_limit !== undefined ? { free_quota_limit: capability.free_quota_limit } : {}),
38
39
  } : null;
39
40
  writeCommandEnvelope({
@@ -45,7 +46,7 @@ export async function runServicesStart(backend, serviceID, options = {}) {
45
46
  capability: capabilitySummary,
46
47
  },
47
48
  instruction: capability
48
- ? "填写首选 capability 的 required_input;一次只提交当前 execution 所代表的服务意图。"
49
+ ? "填写首选 capability 的 required_input;一次只提交当前 execution 所代表的服务意图。" + locationInputInstruction(capability.input_schema)
49
50
  : "当前没有可直接调用的 capability;读取服务端下一步,不要猜测 capability。",
50
51
  next: {
51
52
  command,
@@ -67,10 +68,34 @@ export async function runServicesStart(backend, serviceID, options = {}) {
67
68
  ],
68
69
  });
69
70
  }
71
+ function locationConfirmationEnvelope(executionID, capabilityID, input, confirmation) {
72
+ input = (confirmation.input ?? input);
73
+ const endpoints = (confirmation.endpoints ?? []);
74
+ const choices = Object.fromEntries(endpoints.map((endpoint) => [endpoint.side, "<用户选择的候选 id>"]));
75
+ return {
76
+ status: "location_confirmation_required",
77
+ result: { service_execution_id: executionID, query: input, location_confirmation: confirmation },
78
+ instruction: "尚未查票。仅向用户确认 endpoints 中有歧义的地点,展示真实候选名称、地址和高德链接。不要自行选择候选、把区域改成车站或重复调用原查询。用户选择后保留原输入,带 location_confirmation 回到同一服务。没有候选时请用户补充已知城市或准确地名,再使用修正输入查询。",
79
+ next: null,
80
+ recovery: confirmation.can_resume ? [{
81
+ command: `itpay services invoke ${executionID} --capability ${capabilityID}${formatInputOptions({ ...input,
82
+ location_confirmation: { plan_id: confirmation.plan_id, token: confirmation.token, choices } })} --json`,
83
+ reason: "仅在用户明确选择后替换 choices 并执行;30 分钟有效",
84
+ }] : [],
85
+ };
86
+ }
70
87
  function requiredInputFields(schema) {
71
88
  const required = schema?.required;
72
89
  return Array.isArray(required) ? required.filter((field) => typeof field === "string") : [];
73
90
  }
91
+ function locationInputInstruction(schema) {
92
+ const properties = schema?.properties;
93
+ if (!properties?.origin_location || !properties?.destination_location)
94
+ return "";
95
+ if (!properties.location_confirmation)
96
+ return " 地点保留用户已知最完整名称或城市范围;不要编造坐标或把城市改成同名车站。坐标及其他可选字段严格按当前 input_schema 提交。";
97
+ 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 才展示候选名称、地址和高德链接,等待用户选择,禁止自行选第一项。";
98
+ }
74
99
  export async function runServicesInvoke(backend, config, serviceExecutionID, capabilityID, input, options = {}) {
75
100
  const readModel = await backend.getServiceExecution(serviceExecutionID);
76
101
  const requestedCapability = readModel.capabilities.find((capability) => capability.capability_id === capabilityID);
@@ -91,10 +116,26 @@ export async function runServicesInvoke(backend, config, serviceExecutionID, cap
91
116
  }]);
92
117
  }
93
118
  const idempotencyKey = await operationID(config, `service.invoke:${serviceExecutionID}:${capabilityID}:${stableInput(input)}`);
94
- const response = await backend.invokeServiceCapability(serviceExecutionID, capabilityID, {
95
- idempotency_key: idempotencyKey,
96
- redacted_summary: input,
97
- });
119
+ let response;
120
+ try {
121
+ response = await backend.invokeServiceCapability(serviceExecutionID, capabilityID, {
122
+ idempotency_key: idempotencyKey,
123
+ redacted_summary: input,
124
+ });
125
+ }
126
+ catch (error) {
127
+ if (!(error instanceof HttpError) || error.code !== "verified_phone_required")
128
+ throw error;
129
+ const link = await backend.createRailPhoneLink();
130
+ writeCommandEnvelope({
131
+ status: "human_action_required",
132
+ result: { service_execution_id: serviceExecutionID, ...link },
133
+ instruction: "请用户打开 verification_url,在 ItPay 网页验证手机号并确认连接当前 Agent。CLI 不接收手机号或验证码;等待用户完成后再重试查询。",
134
+ next: null,
135
+ recovery: [{ command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(input)} --json`, reason: "仅在用户完成网页验证后重试" }],
136
+ }, { ...options, plainResult: [`手机号验证:${link.verification_url}`] });
137
+ return;
138
+ }
98
139
  const envelope = invokedEnvelope(response, requestedCapability, readModel.capabilities, input);
99
140
  writeCommandEnvelope(envelope.value, {
100
141
  ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
@@ -103,6 +144,13 @@ export async function runServicesInvoke(backend, config, serviceExecutionID, cap
103
144
  });
104
145
  }
105
146
  function invokedEnvelope(response, requestedCapability, capabilities, input) {
147
+ if (response.execution_request_id) {
148
+ const id = response.execution.service_execution_id;
149
+ return { value: { status: "running", result: { service_execution_id: id, execution_request_id: response.execution_request_id },
150
+ instruction: "查询已排队或正在运行。告知用户稍候,只读取同一任务的状态;不要重新发起查询,也不要把暂未返回结果说成没有车。",
151
+ next: { command: `itpay services next ${id} --json`, reason: "稍后读取同一查询结果" }, recovery: [] },
152
+ plainResult: ["status: running", `service_execution_id: ${id}`] };
153
+ }
106
154
  const items = response.result_items.map((item) => ({
107
155
  rank: item.rank,
108
156
  title: item.display_title,
@@ -118,11 +166,51 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
118
166
  items,
119
167
  ...(quota ? { quota } : {}),
120
168
  };
169
+ const preview = response.invocation?.safe_result_preview;
170
+ if (!response.effective_quota?.exhausted && preview?.search_status === "LOCATION_CONFIRMATION_REQUIRED" && preview.location_confirmation) {
171
+ return { value: locationConfirmationEnvelope(response.execution.service_execution_id, requestedCapability.capability_id, input, preview.location_confirmation),
172
+ plainResult: [JSON.stringify(preview.location_confirmation)] };
173
+ }
174
+ if (typeof preview?.catalog_total === "number") {
175
+ baseResult.catalog = {
176
+ total: preview.catalog_total,
177
+ recommendation: preview.recommendation,
178
+ decision_source: preview.decision_source,
179
+ coverage: preview.coverage,
180
+ notices: preview.notices,
181
+ search_status: preview.search_status,
182
+ searched_scope: preview.searched_scope,
183
+ effective_policy_hash: preview.effective_policy_hash,
184
+ api_cost: preview.api_cost,
185
+ stage_timestamps: preview.stage_timestamps,
186
+ page: preview.catalog_page,
187
+ journey_summary: preview.journey_summary,
188
+ journeys: preview.journeys,
189
+ train_services: preview.train_services,
190
+ resolved_locations: preview.resolved_locations,
191
+ };
192
+ }
121
193
  let status = items.length > 0 ? "result_ready" : "no_result";
122
194
  let instruction = items.length > 0
123
195
  ? "用编号、名称和可公开字段向用户说明候选;若候选列表已满足目标就停止。只有用户明确选择并希望继续时,才提交对应编号;不要向用户提及 safe_payload、Execution 或内部 ID。"
124
196
  : `没有找到与“${queryText(input)}”匹配的结果。向用户展示本次为 0 个结果并停止。不要修改、缩短或猜测其他输入;只有用户明确提供新输入后,才能启动新的查询。`;
197
+ if (items.length === 0 && Array.isArray(preview?.notices)) {
198
+ const railNotice = preview.notices.find((notice) => {
199
+ if (!notice || typeof notice !== "object")
200
+ return false;
201
+ const value = notice;
202
+ return ["RAIL_TRANSFER_SCOPE_LIMIT", "RAIL_TRANSFER_SEARCH_INCOMPLETE"].includes(String(value.code)) && typeof value.message === "string";
203
+ });
204
+ if (railNotice)
205
+ instruction = `向用户展示官方提示:${railNotice.message} 不要断言该行程必须多次中转或没有车。等待用户选择分段查询的起终点,不自动更换输入或重试。`;
206
+ }
125
207
  let next = null;
208
+ if (items.length > 0 && baseResult.catalog) {
209
+ instruction = "先向用户说明排在首位的推荐方案和其他方案的时间、费用与便利性取舍。items 包含本次返回的合格候选,用户不满意时继续从该列表比较,不必重复查票。搜索是否完成、目录是否截断、覆盖范围和模型降级以 catalog 为准;不能把部分结果说成完整搜索。费用尚需购票前核验。姓名、身份证和手机号仅在 ItPay 网页填写。";
210
+ }
211
+ if (items.length > 0 && preview?.journey_summary) {
212
+ instruction = "先按 journey_summary 报告本次已查询范围内的可用车次、换乘走法和实际乘车组合数量,不能用 catalog.total 或 items 数量冒充车次或组合数。按 journeys 展示组合,推荐置顶,保留全部组合和 train_services 车次列表供用户查看。每组先说明乘坐哪些车、在哪里真正换车以及等待多久;rides.onboard_stops 是同车接续停站,无需下车,可能需车内换座,不计入换乘次数。席别、余票、价格及接驳是组合下的选择,通过 candidate_ids 查对应 items;确认具体选择后才使用该 item 的编号,不猜席别或自动付款。30 分钟只是在已确认便捷换乘站点的筛选下限,不是接续保证。不得把多段票称为已可购买的套票;以 purchase_supported 为准。覆盖不完整、截断和模型仅看短名单时必须说明。姓名、身份证和手机号只在 ItPay 网页填写。";
213
+ }
126
214
  if (response.effective_quota?.exhausted) {
127
215
  status = "quota_exhausted";
128
216
  instruction = "免费额度已用完且本次没有调用 Provider。当前没有可购买的 continuation;只读取同一 Execution 的服务端恢复方向。";
@@ -159,6 +247,8 @@ function invokedEnvelope(response, requestedCapability, capabilities, input) {
159
247
  else if (items.length === 0) {
160
248
  next = null;
161
249
  }
250
+ if (preview?.resolved_locations)
251
+ instruction += " 按 resolved_locations 说明实际解析地点;区域级位置仅是范围代表点,接驳时间不是从用户精确位置计算的。";
162
252
  return {
163
253
  value: { status, result: baseResult, instruction, next, recovery: [] },
164
254
  plainResult: serviceResultPlainLines(baseResult),
@@ -177,6 +267,14 @@ function serviceResultPlainLines(result) {
177
267
  }
178
268
  if (items.length === 0)
179
269
  lines.push("results: 0");
270
+ const catalog = result.catalog;
271
+ if (catalog?.resolved_locations)
272
+ lines.push(`resolved_locations: ${JSON.stringify(catalog.resolved_locations)}`);
273
+ if (catalog?.journey_summary) {
274
+ for (const key of ["journey_summary", "train_services", "journeys"]) {
275
+ lines.push(`${key}: ${JSON.stringify(catalog[key])}`);
276
+ }
277
+ }
180
278
  if (result.quota)
181
279
  lines.push(`quota: ${JSON.stringify(result.quota)}`);
182
280
  if (result.checkout)
@@ -212,6 +310,8 @@ function checkoutCommand(serviceExecutionID, capability, input, fillMissing = tr
212
310
  return `itpay services checkout ${serviceExecutionID} --capability ${capability.capability_id}${formatInputOptions(lockedInput)}${capability.delivery_email_required ? " --email <email>" : ""} --json`;
213
311
  }
214
312
  function capabilityPrice(capability) {
313
+ if (capability.pricing_method === "rail_fare_plus_fee")
314
+ return "实时票款 + 每张票 2 元服务费(最终金额以锁定报价为准)";
215
315
  return capability.price_amount_minor !== undefined && capability.price_currency
216
316
  ? formatMoney(capability.price_amount_minor, capability.price_currency)
217
317
  : "当前发布价格";
@@ -279,7 +379,7 @@ function formatInputOptions(input) {
279
379
  .sort(([left], [right]) => left.localeCompare(right))
280
380
  .map(([key, value]) => String(value) === "<value>"
281
381
  ? ` --input ${key}=<value>`
282
- : ` --input ${shellArgument(`${key}=${String(value)}`)}`)
382
+ : ` --input ${shellArgument(`${key}=${typeof value === "object" && value !== null ? JSON.stringify(value) : String(value)}`)}`)
283
383
  .join("");
284
384
  }
285
385
  function shellArgument(value) {
@@ -652,11 +752,176 @@ export async function runServicesReadResult(backend, serviceExecutionID, options
652
752
  plainResult: servicesNextPlainResult(envelope.result),
653
753
  });
654
754
  }
755
+ function railLegSeatSummary(seat) {
756
+ return {
757
+ passenger_index: seat.passenger_index,
758
+ ...(seat.seat_type_name ? { seat_type_name: seat.seat_type_name } : {}),
759
+ ...(seat.seat_label || seat.coach_no || seat.seat_no
760
+ ? { seat: seat.seat_label ?? [seat.coach_no ? `${seat.coach_no}车` : "", seat.seat_no ?? ""].join("") }
761
+ : {}),
762
+ confirmed: seat.confirmed,
763
+ };
764
+ }
765
+ function railBookingEnvelope(model) {
766
+ const rail = model.rail_booking;
767
+ const execution = model.execution;
768
+ const orderID = (model.current_delivery ?? model.delivery_bindings.at(-1))?.order_id;
769
+ const legs = rail.legs.map((leg) => ({
770
+ leg_index: leg.leg_index,
771
+ state: leg.state,
772
+ issued: leg.issued,
773
+ ...(leg.details_pending ? { details_pending: true } : {}),
774
+ ...(leg.supplier_state ? { supplier_state: leg.supplier_state } : {}),
775
+ ...(leg.train_code ? { train_code: leg.train_code } : {}),
776
+ ...(leg.travel_date ? { travel_date: leg.travel_date } : {}),
777
+ ...(leg.from || leg.to ? { route: `${leg.from ?? ""} → ${leg.to ?? ""}` } : {}),
778
+ ...(leg.departure || leg.arrival ? { time: `${leg.departure ?? ""}–${leg.arrival ?? ""}` } : {}),
779
+ ...(leg.seat_name ? { seat_name: leg.seat_name } : {}),
780
+ ...(leg.seat_request ? { seat_request: leg.seat_request } : {}),
781
+ ...(leg.seat_preferences?.length ? { seat_preferences: leg.seat_preferences } : {}),
782
+ ...(leg.seats?.length ? { seats: leg.seats.map(railLegSeatSummary) } : {}),
783
+ }));
784
+ const result = {
785
+ service_execution_id: execution.service_execution_id,
786
+ ...(orderID ? { order_id: orderID } : {}),
787
+ rail: { state: rail.state, issued_legs: rail.issued_legs, legs },
788
+ ...(model.workflow ? { workflow: model.workflow } : {}),
789
+ };
790
+ if (rail.state === "issued") {
791
+ const detailsPending = rail.legs.some((leg) => leg.details_pending);
792
+ return {
793
+ status: "issued",
794
+ result,
795
+ instruction: `告诉用户:车票已出票,座位以实际出票为准,平台不提供 12306 票号,可在订单页核对行程。不要在对话中索要乘车人身份信息。${detailsPending ? "部分席位信息未能同步,请前往 12306 核对行程;不要重复购买。" : ""}`,
796
+ next: orderID ? { command: `itpay order ${orderID} --json`, reason: "查看订单及退款入口" } : null,
797
+ recovery: [],
798
+ };
799
+ }
800
+ if (rail.state === "manual_review") {
801
+ return {
802
+ status: "manual_review",
803
+ result,
804
+ instruction: "告诉用户:付款已确认,订单需要人工核对;请勿重复付款或重新下单,已出票的车票会保留。不要承诺退款结果或时效。",
805
+ next: orderID ? { command: `itpay order ${orderID} --json`, reason: "查看同一订单当前状态" } : null,
806
+ recovery: [],
807
+ };
808
+ }
809
+ // A rail booking run only exists after a verified payment, so payment is
810
+ // confirmed here — but only `pending` actually means supplier issuance is in
811
+ // flight. Any state added later falls back to a conservative re-read instead
812
+ // of claiming issuance.
813
+ if (rail.state === "pending") {
814
+ return {
815
+ status: "issuing",
816
+ result,
817
+ instruction: "告诉用户:付款已确认,后台正在出票;付款成功不代表已出票,请勿重复购买或再次付款。稍后只读取同一任务。",
818
+ next: { command: `itpay services next ${execution.service_execution_id} --json`, reason: "稍后读取同一出票任务" },
819
+ recovery: [],
820
+ };
821
+ }
822
+ return {
823
+ status: "processing",
824
+ result,
825
+ instruction: `告诉用户:付款已确认,订单处理状态待确认(${rail.state});付款成功不代表已出票,请勿重复购买或再次付款。稍后只读取同一任务。`,
826
+ next: { command: `itpay services next ${execution.service_execution_id} --json`, reason: "稍后读取同一出票任务" },
827
+ recovery: [],
828
+ };
829
+ }
830
+ function terminalExecutionEnvelope(model) {
831
+ const execution = model.execution;
832
+ const currentDelivery = model.current_delivery ?? model.delivery_bindings.at(-1);
833
+ if (!isTerminalServiceExecutionStatus(execution.status) ||
834
+ (model.workflow_entry && (currentDelivery || serviceDeliveryMode(model) === "agent_visible_result") && ["completed", "delivery_completed"].includes(execution.status))) {
835
+ return null;
836
+ }
837
+ const paid = model.checkout_bindings.some((binding) => binding.status === "payment_verified") || Boolean(currentDelivery?.order_id);
838
+ const paidFailure = execution.status === "failed" && paid;
839
+ return {
840
+ status: execution.status,
841
+ result: {
842
+ service_execution_id: execution.service_execution_id,
843
+ service_id: execution.service_id,
844
+ phase: execution.phase,
845
+ ...(currentDelivery?.order_id ? { order_id: currentDelivery.order_id } : {}),
846
+ },
847
+ instruction: execution.status === "refunded"
848
+ ? "告诉用户这笔服务已经退款并永久结束。Agent 不重放服务步骤、不创建付款页面或尝试读取旧交付。"
849
+ : paidFailure
850
+ ? appendFeedbackPostmortemInstruction("告诉用户:付款和订单已经记录,但本次服务没有正常完成,不需要再次付款或重新下单。然后从同一订单检查退款状态;Agent 不重放服务步骤、创建付款页面或再次调用数据来源,也不把技术故障归咎于用户。", "failed")
851
+ : "告诉用户本次服务已经结束且没有可继续的交付。Agent 不重放服务步骤或创建付款页面。",
852
+ next: null,
853
+ recovery: [
854
+ ...(paidFailure
855
+ ? [{
856
+ command: currentDelivery?.order_id
857
+ ? `itpay order ${currentDelivery.order_id} --json`
858
+ : "itpay orders --json",
859
+ reason: "恢复同一笔已付款订单及其退款状态",
860
+ }]
861
+ : []),
862
+ {
863
+ command: `itpay services events ${execution.service_execution_id} --json`,
864
+ reason: "仅在需要诊断终止原因时读取事件",
865
+ },
866
+ ],
867
+ };
868
+ }
655
869
  function servicesNextEnvelope(model) {
656
870
  const execution = model.execution;
657
871
  const currentDelivery = model.current_delivery ?? model.delivery_bindings.at(-1);
658
872
  const lockedRefund = model.refunds.find((refund) => refund.access_locked);
659
- if (model.workflow_entry && !lockedRefund && !["completed", "delivery"].includes(model.workflow?.status ?? "")) {
873
+ if (lockedRefund) {
874
+ const terminal = lockedRefund.status === "succeeded";
875
+ return {
876
+ status: "delivery_locked",
877
+ result: {
878
+ service_execution_id: execution.service_execution_id,
879
+ access_locked: true,
880
+ refund: {
881
+ refund_request_id: lockedRefund.refund_request_id,
882
+ status: lockedRefund.status,
883
+ },
884
+ },
885
+ instruction: terminal
886
+ ? "告诉用户退款已由 ItPay 确认成功,原交付永久关闭。Agent 停止读取和跟踪,不再创建授权。"
887
+ : "告诉用户退款仍在处理,原交付已按政策冻结。然后读取同一退款的权威状态;Agent 不读取交付、不创建授权或重复申请。",
888
+ next: terminal ? null : {
889
+ command: `itpay refund get ${lockedRefund.refund_request_id} --json`,
890
+ reason: "读取退款权威状态",
891
+ },
892
+ recovery: [],
893
+ };
894
+ }
895
+ const latestRun = model.execution_requests?.filter((request) => request.execution_kind === "service.execution.run").at(-1);
896
+ if (latestRun && ["pending", "started"].includes(latestRun.status)) {
897
+ return {
898
+ status: "running",
899
+ result: { service_execution_id: execution.service_execution_id, execution_request_id: latestRun.execution_request_id },
900
+ instruction: "任务仍在排队或执行中,稍后读取同一任务。不要重新查票、重复提交输入或把等待状态当作零结果。",
901
+ next: { command: `itpay services next ${execution.service_execution_id} --json`, reason: "稍后读取同一任务状态" },
902
+ recovery: [],
903
+ };
904
+ }
905
+ // A terminal execution wins over the rail read model: a failed, cancelled,
906
+ // or refunded run must not render as still issuing. Completed executions
907
+ // keep the rail view so issued seats stay visible.
908
+ if (model.rail_booking && isTerminalServiceExecutionStatus(execution.status) && !["completed", "delivery_completed"].includes(execution.status)) {
909
+ const terminal = terminalExecutionEnvelope(model);
910
+ if (terminal)
911
+ return terminal;
912
+ }
913
+ if (model.rail_booking)
914
+ return railBookingEnvelope(model);
915
+ if (latestRun && ["failed", "cancelled"].includes(latestRun.status) && !model.workflow_entry) {
916
+ return {
917
+ status: latestRun.status,
918
+ result: { service_execution_id: execution.service_execution_id },
919
+ instruction: "本次任务未完成,需要检查同一任务的处理记录;这不表示没有结果。不要自动重新发起。",
920
+ next: null,
921
+ recovery: [{ command: `itpay services events ${execution.service_execution_id} --json`, reason: "读取同一任务的处理记录" }],
922
+ };
923
+ }
924
+ if (model.workflow_entry && !["completed", "delivery"].includes(model.workflow?.status ?? "")) {
660
925
  const id = execution.service_execution_id;
661
926
  const paymentVerified = model.payment_bindings.some((binding) => binding.status === "payment_verified") || model.checkout_bindings.some((binding) => binding.status === "payment_verified");
662
927
  const state = model.workflow?.status === "payment" && paymentVerified ? "running" : model.workflow?.status ?? "input_required";
@@ -702,62 +967,17 @@ function servicesNextEnvelope(model) {
702
967
  recovery: [],
703
968
  };
704
969
  }
705
- if (lockedRefund) {
706
- const terminal = lockedRefund.status === "succeeded";
707
- return {
708
- status: "delivery_locked",
709
- result: {
710
- service_execution_id: execution.service_execution_id,
711
- access_locked: true,
712
- refund: {
713
- refund_request_id: lockedRefund.refund_request_id,
714
- status: lockedRefund.status,
715
- },
716
- },
717
- instruction: terminal
718
- ? "告诉用户退款已由 ItPay 确认成功,原交付永久关闭。Agent 停止读取和跟踪,不再创建授权。"
719
- : "告诉用户退款仍在处理,原交付已按政策冻结。然后读取同一退款的权威状态;Agent 不读取交付、不创建授权或重复申请。",
720
- next: terminal ? null : {
721
- command: `itpay refund get ${lockedRefund.refund_request_id} --json`,
722
- reason: "读取退款权威状态",
723
- },
724
- recovery: [],
725
- };
726
- }
727
- if (isTerminalServiceExecutionStatus(execution.status) && !(model.workflow_entry && (currentDelivery || serviceDeliveryMode(model) === "agent_visible_result") && ["completed", "delivery_completed"].includes(execution.status))) {
728
- const paid = model.checkout_bindings.some((binding) => binding.status === "payment_verified") || Boolean(currentDelivery?.order_id);
729
- const paidFailure = execution.status === "failed" && paid;
730
- return {
731
- status: execution.status,
732
- result: {
733
- service_execution_id: execution.service_execution_id,
734
- service_id: execution.service_id,
735
- phase: execution.phase,
736
- ...(currentDelivery?.order_id ? { order_id: currentDelivery.order_id } : {}),
737
- },
738
- instruction: execution.status === "refunded"
739
- ? "告诉用户这笔服务已经退款并永久结束。Agent 不重放服务步骤、不创建付款页面或尝试读取旧交付。"
740
- : paidFailure
741
- ? appendFeedbackPostmortemInstruction("告诉用户:付款和订单已经记录,但本次服务没有正常完成,不需要再次付款或重新下单。然后从同一订单检查退款状态;Agent 不重放服务步骤、创建付款页面或再次调用数据来源,也不把技术故障归咎于用户。", "failed")
742
- : "告诉用户本次服务已经结束且没有可继续的交付。Agent 不重放服务步骤或创建付款页面。",
743
- next: null,
744
- recovery: [
745
- ...(paidFailure
746
- ? [{
747
- command: currentDelivery?.order_id
748
- ? `itpay order ${currentDelivery.order_id} --json`
749
- : "itpay orders --json",
750
- reason: "恢复同一笔已付款订单及其退款状态",
751
- }]
752
- : []),
753
- {
754
- command: `itpay services events ${execution.service_execution_id} --json`,
755
- reason: "仅在需要诊断终止原因时读取事件",
756
- },
757
- ],
758
- };
759
- }
970
+ const terminalEnvelope = terminalExecutionEnvelope(model);
971
+ if (terminalEnvelope)
972
+ return terminalEnvelope;
760
973
  const currentItems = model.current_result_items ?? [];
974
+ const latestInvocation = model.provider_invocations.at(-1);
975
+ const latestPreview = latestInvocation?.safe_result_preview;
976
+ if (currentItems.length === 0 && latestPreview?.search_status === "LOCATION_CONFIRMATION_REQUIRED" && latestPreview.location_confirmation) {
977
+ const capability = model.capabilities.find((item) => item.capability_id === latestInvocation?.capability_id && item.phase === execution.phase && !item.requires_payment);
978
+ if (capability)
979
+ return locationConfirmationEnvelope(execution.service_execution_id, capability.capability_id, (latestInvocation?.request_summary ?? {}), latestPreview.location_confirmation);
980
+ }
761
981
  const delivery = currentDelivery;
762
982
  const deliveryMode = serviceDeliveryMode(model);
763
983
  const candidateSelection = model.allowed_actions?.find((action) => action.type === "select_candidate");
@@ -888,7 +1108,7 @@ function servicesNextEnvelope(model) {
888
1108
  ? "告诉用户付款和订单已经确认,结果仍在同一笔服务中处理,不需要再次付款;如果最终无法交付,将从原订单检查退款路径。稍后只执行 next.command;Agent 不创建新服务、付款页面或数据请求,也不承诺退款结果。"
889
1109
  : preferred?.requires_human
890
1110
  ? "当前下一步需要用户明确选择;先展示必要信息并等待确认。"
891
- : preferred ? "执行服务端返回的唯一首选动作;不要猜测其他 capability。" : "当前没有后续动作。",
1111
+ : preferred ? "执行服务端返回的唯一首选动作;不要猜测其他 capability。" + locationInputInstruction(model.capabilities.find((item) => item.capability_id === preferred.capability_id)?.input_schema) : "当前没有后续动作。",
892
1112
  next,
893
1113
  recovery: [{ command: `itpay services get ${execution.service_execution_id} --json`, reason: "仅在当前动作异常时检查时间线" }],
894
1114
  };
@@ -1039,6 +1259,17 @@ export function collectOption(value, previous = []) {
1039
1259
  return previous;
1040
1260
  }
1041
1261
  function parseValue(value) {
1262
+ // Structured inputs (objects/arrays) are passed as JSON; a string that merely
1263
+ // starts with a JSON delimiter but is not valid JSON stays a string — the
1264
+ // backend schema validates the field type either way.
1265
+ if (value.startsWith("{") || value.startsWith("[")) {
1266
+ try {
1267
+ return JSON.parse(value);
1268
+ }
1269
+ catch {
1270
+ return value;
1271
+ }
1272
+ }
1042
1273
  if (value === "true")
1043
1274
  return true;
1044
1275
  if (value === "false")
@@ -15,8 +15,8 @@ import { OperationJournal } from "./operation_journal.js";
15
15
  export const DEFAULT_BASE_URL = "https://app.itpay.ai";
16
16
  export const DEV_BASE_URL = "https://dev.itpay.ai";
17
17
  export const SANDBOX_BASE_URL = "https://sandbox.itpay.ai";
18
- export const CLI_VERSION = "2.1.4";
19
- export const API_CONTRACT_REVISION = "sha256:7f8e09ecee0ab32e3eed3690341fa5bbf6d955dbb464fd79ce0e340667b1085e";
18
+ export const CLI_VERSION = "2.1.5";
19
+ export const API_CONTRACT_REVISION = "sha256:42659989d533d5a43c4ab92eb2455bcbbc6456e799fa4c2b6587de77f06b95f7";
20
20
  const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
21
21
  const CART_SESSION_FILENAME = "cart.json";
22
22
  const OPERATION_JOURNAL_FILENAME = "operations.json";
@@ -2,7 +2,7 @@ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, ran
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, resolve } from "node:path";
5
- const PROTECTED_PATHS = ["/v1/agent-device-account-bindings", "/v1/carts", "/v1/service-executions", "/v1/agent-instances", "/v1/orders", "/v1/refunds", "/v1/me", "/v1/vault"];
5
+ const PROTECTED_PATHS = ["/v1/agent-device-account-bindings", "/v1/carts", "/v1/service-executions", "/v1/agent-instances", "/v1/orders", "/v1/refunds", "/v1/me", "/v1/vault", "/v1/rail/phone-links"];
6
6
  export class DeviceAuthority {
7
7
  baseURL;
8
8
  backendKey;
@@ -20,6 +20,14 @@ itpay services invoke <service_execution_id> --capability <capability_id>
20
20
 
21
21
  ## 有结果输出
22
22
 
23
+ 火车票目录:`items` 保留服务端返回的全部合格方案,不按推荐数量截断。
24
+ `result.catalog` 带搜索覆盖范围、模型来源和推荐引用;`recommended=true` 的方案排在首位。
25
+ 本地 Agent 应先解释首推及不同取舍,用户不满意时可继续查看同一次返回的完整列表。
26
+ 智能规划返回 `verified_phone_required` 时,先完成 ItPay 网页手机号验证,不在 CLI 收集手机号、身份证或姓名。
27
+ 该错误发生在供应商调用和额度预占之前。
28
+ 本地验证环境中 CLI 会返回 `verification_url`,用户在该网页验证手机号并确认连接 Agent。
29
+ 完成后重试同一条 invoke;不在终端输入或回传手机验证码。
30
+
23
31
  ```json
24
32
  {
25
33
  "status": "result_ready",
@@ -153,3 +161,5 @@ Provider 已收到请求时,Backend 返回同一 Execution 的权威调用和
153
161
  ## Agent Type / Host
154
162
 
155
163
  所有正式支持的 Local Agent Type 的 safe result 一致。instruction 可以适配对话表述,但不得隐藏 quota、价格或 schema 错误。
164
+
165
+ 本地模拟购票阶段:用户选定候选后才提交选择,并使用 `services quote --capability book_ticket` 获取实时票款加每张 2 元服务费的锁定报价。精确查询需在报价输入中指定候选里可售的 `seat_type`;智能方案沿用所选席别。`pricing_method=rail_fare_plus_fee` 不代表总价 2 元。乘客资料仅在 Checkout 网页填写。本地出票结果明确标记 `simulation=true`,不表示真实出票。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itpay/cli",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "The ItPay CLI for services, orders, and human-authorized purchased content.",
5
5
  "type": "module",
6
6
  "bin": {