@fcg-labs/cx-agent-hook 0.5.0 → 0.6.0

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.
package/MIGRATION.md CHANGED
@@ -1,3 +1,28 @@
1
+ # 0.5.x → 0.6.0 마이그레이션
2
+
3
+ ## 요약 — 대상 확정(더블체크, 계약 §B.10)이 추가된다. **깨지는 것 없음** (additive)
4
+
5
+ 문의 컨텍스트 값(예: subUserId)이 PK 라는 보장이 없다 — 오타깃 실행은 다른 유저
6
+ 데이터 손상이다. 0.6.0 부터 절차 자산이 `verify`(대상 확정)를 들고 오면 SDK 가
7
+ 쓰기 전에 **읽기 전용 API 로 대상을 확인**한다. `verify` 없는 offer 는 0.5.x 와
8
+ 바이트 동일하게 동작한다. 고객사 코드 변경 0 — `adminApi` 그대로.
9
+
10
+ ## 새로 생긴 것
11
+
12
+ - `offer.verify` — `{action_key(읽기), execution(GET 사양), display_fields[{path,label}],
13
+ preconditions[], resolution}`. 서버(asset v6)가 동봉한다.
14
+ - 실행 흐름: 선점 → **verify 읽기(GET 강제·재시도 0)** → precondition 결정적 평가 →
15
+ 쓰기(읽기 직후 즉시) → 보고. 차단은 전부 `failed` 로 허브 원장에 남는다 —
16
+ reasonCode `precondition_failed`(deny_copy 가 메시지) · `verify_unavailable` ·
17
+ `verify_not_readonly` · `verify_manual`.
18
+ - 확인 패널: 열릴 때 같은 읽기로 **대상 확인 블록**(`fcx-act-verify*`)을 렌더하고,
19
+ 확인이 ok 되기 전에는 실행 버튼이 나오지 않는다 (위험도 무관).
20
+ - `session.verifyAction(offer)`(패널 미리보기) · `session.actionVerifies()`,
21
+ 순수 함수 `pluck`·`evaluatePreconditions`·`verifyViaAdminApi` (actions.js).
22
+ - 로케일 키 4종: `ui_action_verify_title/loading/blocked/failed` (ko·en·ja·zh-TW).
23
+
24
+ ---
25
+
1
26
  # 0.4.0 → 0.5.0 마이그레이션
2
27
 
3
28
  ## 요약 — 처리 액션 표면만 바뀐다 (0.3.0 표면은 그대로)
package/actions.js CHANGED
@@ -133,6 +133,82 @@ function normalizeResponse(res) {
133
133
  return { httpStatus: 200, body: res };
134
134
  }
135
135
 
136
+ /** 응답 JSON 경로 추출 — `pluck(body, "data.gender")`. 없으면 undefined (결정적). */
137
+ export function pluck(obj, path) {
138
+ let cur = obj;
139
+ for (const part of String(path || "").split(".")) {
140
+ if (!part) continue;
141
+ if (cur === null || cur === undefined || typeof cur !== "object") return undefined;
142
+ cur = cur[part];
143
+ }
144
+ return cur;
145
+ }
146
+
147
+ /**
148
+ * 대상 확정 precondition 의 결정적 평가 — 계약 §B.10. LLM 판정 없음.
149
+ * not_equals_param: 응답값 === 바인딩값 이면 차단 (예: 현재 성별 === 요청 성별 → 변경 불요)
150
+ * equals_param: 응답값 !== 바인딩값 이면 차단 (대상 동일성 확인)
151
+ * equals_value: 응답값 !== 리터럴 이면 차단
152
+ * @returns {{blocked:boolean, path:string, denyCopy:string}}
153
+ */
154
+ export function evaluatePreconditions(preconditions, body, paramsBound) {
155
+ const bound = paramsBound && typeof paramsBound === "object" ? paramsBound : {};
156
+ for (const p of Array.isArray(preconditions) ? preconditions : []) {
157
+ if (!p || typeof p !== "object" || !p.path) continue;
158
+ const val = pluck(body, p.path);
159
+ let blocked = false;
160
+ if (p.not_equals_param) blocked = String(val) === String(bound[p.not_equals_param]);
161
+ else if (p.equals_param) blocked = String(val) !== String(bound[p.equals_param]);
162
+ else if (p.equals_value !== undefined && p.equals_value !== "") blocked = String(val) !== String(p.equals_value);
163
+ if (blocked) return { blocked: true, path: String(p.path), denyCopy: String(p.deny_copy || "") };
164
+ }
165
+ return { blocked: false, path: "", denyCopy: "" };
166
+ }
167
+
168
+ /**
169
+ * 대상 확정(더블체크) 읽기 — 계약 §B.10 (T2.7). 쓰기 전에 offer.verify 의 **읽기 전용**
170
+ * API 로 대상을 조회해 ① 표시 항목(display_fields)을 뽑고 ② precondition 을 결정적으로
171
+ * 평가한다. 문의 컨텍스트 값이 PK 라는 보장이 없다 — 오타깃 실행은 타인 데이터 손상이라
172
+ * 읽기 실패·비 2xx·GET 아님은 전부 **실행 차단**(fail-closed)이다.
173
+ * @returns {Promise<{ok:boolean, skipped?:boolean, reasonCode?:string, message?:string,
174
+ * denyCopy?:string, display:Array<{label:string,value:string}>, httpStatus?:number}>}
175
+ */
176
+ export async function verifyViaAdminApi(adminApi, offer) {
177
+ const v = offer && offer.verify;
178
+ if (!v || typeof v !== "object") return { ok: true, skipped: true, display: [] };
179
+ if (v.resolution === "manual") {
180
+ // 단건 조회로 대상 확정 불가 선언 — 자동 실행 경로가 아예 아니다 (서버 조립도 막지만 이중 방어)
181
+ return { ok: false, reasonCode: "verify_manual", display: [] };
182
+ }
183
+ const resolved = resolveExecution(v.execution, offer && offer.params_bound, adminApi && adminApi.endpoints);
184
+ if (!resolved.ok) return { ok: false, reasonCode: "verify_unavailable", message: resolved.error || "", display: [] };
185
+ if (resolved.method !== "get") {
186
+ // 검증이 부작용을 내면 안 된다 — 읽기 전용 강제
187
+ return { ok: false, reasonCode: "verify_not_readonly", display: [] };
188
+ }
189
+ let httpStatus = 0; let body;
190
+ try {
191
+ const res = await adminApi.request({ method: resolved.method, url: resolved.url,
192
+ path: resolved.path, query: resolved.query, data: resolved.data });
193
+ ({ httpStatus, body } = normalizeResponse(res));
194
+ } catch (err) {
195
+ const resp = err && err.response;
196
+ if (resp && typeof resp.status === "number") { httpStatus = resp.status; body = resp.data; }
197
+ else return { ok: false, reasonCode: "verify_unavailable", message: "transport", display: [] };
198
+ }
199
+ if (httpStatus < 200 || httpStatus >= 300) {
200
+ return { ok: false, reasonCode: "verify_unavailable", message: `HTTP ${httpStatus}`, httpStatus, display: [] };
201
+ }
202
+ const display = (Array.isArray(v.display_fields) ? v.display_fields : []).map((f) => {
203
+ const val = pluck(body, f && f.path);
204
+ return { label: String((f && (f.label || f.path)) || ""),
205
+ value: val === undefined || val === null ? "—" : String(val) };
206
+ });
207
+ const pre = evaluatePreconditions(v.preconditions, body, offer && offer.params_bound);
208
+ if (pre.blocked) return { ok: false, reasonCode: "precondition_failed", denyCopy: pre.denyCopy, display, httpStatus };
209
+ return { ok: true, display, httpStatus };
210
+ }
211
+
136
212
  /**
137
213
  * CMS API 1회 호출 + 판정. **재시도 0.** adminApi.request({method,url,path,query,data}) 는
138
214
  * CMS 의 기존 헬퍼(예: TempAdminApi.request) 참조 — 세션 인증·화이트리스트·진행바는
package/element.js CHANGED
@@ -204,10 +204,19 @@ function createActionsClass() {
204
204
  this.replaceChildren();
205
205
  if (!this.#agent || !this.#session) return;
206
206
  const tree = actionOffersTree({ offers: this.#offers, pending: this.#pending,
207
- enabled: Boolean(this.#agent.actionsEnabled), confirming: this.#confirming, messages: this.#agent.messages });
207
+ enabled: Boolean(this.#agent.actionsEnabled), confirming: this.#confirming,
208
+ verifies: typeof this.#session.actionVerifies === "function" ? this.#session.actionVerifies() : {},
209
+ messages: this.#agent.messages });
208
210
  if (!tree) return;
209
211
  const actions = {
210
- confirm: (e) => { this.#confirming = e.currentTarget.dataset.offer || null; this.#ackedOffer = null; this.#render(); },
212
+ confirm: (e) => {
213
+ const id = e.currentTarget.dataset.offer || null;
214
+ this.#confirming = id; this.#ackedOffer = null;
215
+ // 대상 확정 (T2.7) — 패널이 열리면 읽기 확인을 시동한다 (결과는 notify 로 재렌더)
216
+ const offer = this.#offers.find((o) => o.offer_id === id);
217
+ if (offer && offer.verify && typeof this.#session.verifyAction === "function") this.#session.verifyAction(offer);
218
+ this.#render();
219
+ },
211
220
  cancel: () => { this.#confirming = null; this.#ackedOffer = null; this.#render(); },
212
221
  ack: (e) => { const id = e.currentTarget.dataset.offer; this.#ackedOffer = this.#ackedOffer === id ? null : id; this.#render(); },
213
222
  execute: (e) => this.#execute(e.currentTarget.dataset.offer),
package/locales.js CHANGED
@@ -48,6 +48,10 @@ const en = {
48
48
  ui_action_risk_low: "low risk",
49
49
  ui_action_risk_medium: "medium risk",
50
50
  ui_action_risk_high: "high risk",
51
+ ui_action_verify_title: "Confirm the target before running",
52
+ ui_action_verify_loading: "Checking the target…",
53
+ ui_action_verify_blocked: "Blocked by a pre-run check.",
54
+ ui_action_verify_failed: "Could not confirm the target — this action will not run.",
51
55
  ui_overwrite_confirm: "Replace your current draft with the AI draft?",
52
56
  // 거절·오류 사유
53
57
  not_configured: "AI reply suggestions are not connected yet.",
@@ -91,6 +95,10 @@ const ko = {
91
95
  ui_action_risk_low: "위험 낮음",
92
96
  ui_action_risk_medium: "위험 보통",
93
97
  ui_action_risk_high: "위험 높음",
98
+ ui_action_verify_title: "실행 전 대상 확인",
99
+ ui_action_verify_loading: "대상 확인 중…",
100
+ ui_action_verify_blocked: "실행 전 확인에서 차단되었습니다.",
101
+ ui_action_verify_failed: "대상을 확인할 수 없습니다 — 실행하지 않습니다.",
94
102
  ui_overwrite_confirm: "작성 중인 답변을 지우고 AI 초안으로 바꿀까요?",
95
103
  not_configured: "AI 답변 제안이 아직 연결되지 않았습니다.",
96
104
  unsupported_api: "AI 답변 제안이 아직 연결되지 않았습니다.",
@@ -129,6 +137,10 @@ const ja = {
129
137
  ui_action_risk_low: "リスク低",
130
138
  ui_action_risk_medium: "リスク中",
131
139
  ui_action_risk_high: "リスク高",
140
+ ui_action_verify_title: "実行前に対象を確認",
141
+ ui_action_verify_loading: "対象を確認しています…",
142
+ ui_action_verify_blocked: "実行前チェックでブロックされました。",
143
+ ui_action_verify_failed: "対象を確認できません — 実行しません。",
132
144
  ui_overwrite_confirm: "作成中の回答を消してAI下書きに置き換えますか?",
133
145
  not_configured: "AI 返信案はまだ接続されていません。",
134
146
  unsupported_api: "AI 返信案はまだ接続されていません。",
@@ -167,6 +179,10 @@ const zhTW = {
167
179
  ui_action_risk_low: "風險低",
168
180
  ui_action_risk_medium: "風險中",
169
181
  ui_action_risk_high: "風險高",
182
+ ui_action_verify_title: "執行前確認對象",
183
+ ui_action_verify_loading: "正在確認對象…",
184
+ ui_action_verify_blocked: "已被執行前檢查擋下。",
185
+ ui_action_verify_failed: "無法確認對象 — 不會執行。",
170
186
  ui_overwrite_confirm: "要清除目前草稿並以 AI 草稿取代嗎?",
171
187
  not_configured: "AI 回覆建議尚未連接。",
172
188
  unsupported_api: "AI 回覆建議尚未連接。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcg-labs/cx-agent-hook",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "FCG CX Agent 후킹 SDK — 서빙 답변 수신 + CS팀 교정(점수·수정·발송) 후킹. 의존성 0, CMS에 install만으로 이식",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/react.js CHANGED
@@ -144,11 +144,18 @@ export function ActionOffersPanel({ session, agent, actorClaimed, onResult, clas
144
144
  }, [offers, ackedOffer, session, onResult]);
145
145
 
146
146
  if (!session || !agent) return null;
147
- const tree = actionOffersTree({ offers, pending, enabled: Boolean(agent.actionsEnabled), confirming, messages: agent.messages });
147
+ const tree = actionOffersTree({ offers, pending, enabled: Boolean(agent.actionsEnabled), confirming,
148
+ verifies: typeof session.actionVerifies === "function" ? session.actionVerifies() : {},
149
+ messages: agent.messages });
148
150
  if (!tree) return null;
149
151
 
150
152
  const actions = {
151
- confirm: (e) => { setConfirming(e.currentTarget.dataset.offer || null); setAckedOffer(null); },
153
+ confirm: (e) => {
154
+ const id = e.currentTarget.dataset.offer || null;
155
+ setConfirming(id); setAckedOffer(null);
156
+ const offer = offers.find((o) => o.offer_id === id);
157
+ if (offer && offer.verify && typeof session.verifyAction === "function") session.verifyAction(offer); // T2.7 — 결과는 notify 재렌더
158
+ },
152
159
  cancel: () => { setConfirming(null); setAckedOffer(null); },
153
160
  ack: (e) => { const id = e.currentTarget.dataset.offer; setAckedOffer((v) => (v === id ? null : id)); },
154
161
  execute: (e) => execute(e.currentTarget.dataset.offer),
package/session.js CHANGED
@@ -24,7 +24,7 @@
24
24
  * **수명이 다르다**: 요청은 전송 **전에** 남고, 종단 뒤 24h 까지 살아 새로고침·
25
25
  * 재진입 시 "이미 눌렀던 것" 을 안다. 정본은 허브 원장이다 — 브라우저 소실은
26
26
  * 기록 손실이 아니라 표시 손실일 뿐. localStorage 부재면 메모리로만 산다. */
27
- import { executeViaAdminApi } from "./actions.js";
27
+ import { executeViaAdminApi, verifyViaAdminApi } from "./actions.js";
28
28
 
29
29
  export class ActionLedger {
30
30
  constructor({ impl, prefix = "cx-agent-actions", terminalTtlMs = 24 * 60 * 60 * 1000 } = {}) {
@@ -156,6 +156,7 @@ export class InquirySession {
156
156
  this._offers = []; // 마지막 답변의 처리 선택지 (actions/1 §4)
157
157
  this._actionUi = null;
158
158
  this._locks = {}; // action_key\x00target_key → 잠금 조회 결과 (세션 캐시)
159
+ this._verify = {}; // offer_id → 대상 확정(더블체크) 결과 (T2.7, 화면 표시용 캐시)
159
160
 
160
161
  // 영속 복원 — TTL 만료분은 store 가 이미 걸렀다
161
162
  if (this._id && deps.store) {
@@ -263,6 +264,27 @@ export class InquirySession {
263
264
  return { requestId, ...r };
264
265
  }
265
266
  if (d.actionLedger) d.actionLedger.put(requestId, { ...rec, state: "received" });
267
+ // ②-a 대상 확정 (T2.7, 계약 §B.10) — verify 동봉 offer 는 **쓰기 직전에 읽기 재검**.
268
+ // 패널의 verifyAction 표시와 별개로 여기서 다시 읽는다 (TOCTOU 최소화 — 읽기
269
+ // 직후 즉시 쓰기). 읽기 실패·precondition 불통과 = 실행 차단 + 원장 보고.
270
+ if (offer.verify) {
271
+ const vr = await verifyViaAdminApi(d.adminApi, offer);
272
+ this._verify[offer.offer_id] = { state: vr.ok ? "ok" : "blocked", reasonCode: vr.reasonCode || "",
273
+ denyCopy: vr.denyCopy || "", message: vr.message || "", display: vr.display || [] };
274
+ if (!vr.ok) {
275
+ const reasonCode = vr.reasonCode || "verify_unavailable";
276
+ const message = vr.denyCopy || vr.message || "";
277
+ const report = { state: "failed", reasonCode, message, httpStatus: vr.httpStatus || 0, latencyMs: 0 };
278
+ const rep = await d.client.reportActionResult(requestId, report);
279
+ const unreported = !rep.ok && rep.httpStatus === 0;
280
+ if (d.actionLedger) {
281
+ d.actionLedger.put(requestId, { ...rec, state: "failed", reasonCode, message,
282
+ unreported, pendingReport: unreported ? report : undefined });
283
+ }
284
+ this._notifyActions();
285
+ return { requestId, ok: true, state: "failed", reasonCode, message, reported: !unreported, error: null };
286
+ }
287
+ }
266
288
  // ② CMS 자기 API — 재시도 0
267
289
  const out = await executeViaAdminApi(d.adminApi, offer);
268
290
  if (out.authExpired && typeof d.adminApi.onAuthExpired === "function") {
@@ -285,6 +307,25 @@ export class InquirySession {
285
307
  return { requestId, ok: true, state: out.state, reasonCode: out.reasonCode, message: out.message,
286
308
  reported: !unreported, error: null };
287
309
  }
310
+ /**
311
+ * 대상 확정 미리보기 (T2.7) — 확인 패널이 열릴 때 UI 가 부른다. 결과는 캐시돼
312
+ * 카드의 "대상 확인" 블록에 표시되고, 실행 버튼은 ok 일 때만 나온다.
313
+ * 실제 실행(executeAction)은 이 캐시를 믿지 않고 쓰기 직전에 다시 읽는다.
314
+ */
315
+ async verifyAction(offer) {
316
+ const d = this._d;
317
+ if (!offer || !offer.offer_id || !offer.verify) return { ok: true, skipped: true, display: [] };
318
+ if (!d.adminApi || typeof d.adminApi.request !== "function") return { ok: false, reasonCode: "admin_api_missing", display: [] };
319
+ this._verify[offer.offer_id] = { state: "loading", reasonCode: "", denyCopy: "", message: "", display: [] };
320
+ this._notifyActions();
321
+ const out = await verifyViaAdminApi(d.adminApi, offer);
322
+ this._verify[offer.offer_id] = { state: out.ok ? "ok" : "blocked", reasonCode: out.reasonCode || "",
323
+ denyCopy: out.denyCopy || "", message: out.message || "", display: out.display || [] };
324
+ this._notifyActions();
325
+ return out;
326
+ }
327
+ /** offer_id → 대상 확정 상태 스냅샷 — view 의 `verifies` 입력. */
328
+ actionVerifies() { return { ...this._verify }; }
288
329
  /** 미보고 결과 재보고 — 재실행이 아니다. ledger 의 pendingReport 만 다시 보낸다. */
289
330
  async resendResult(requestId) {
290
331
  const d = this._d;
package/styles.css CHANGED
@@ -139,6 +139,15 @@
139
139
  background: var(--fcx-surface-2, #e8eef7); border-radius: var(--fcx-radius, 10px);
140
140
  }
141
141
  .fcx-act-confirm-text { font-weight: 600; }
142
+ /* 대상 확인 블록 (T2.7 §B.10) — 실행 전 읽기 검증 표시 */
143
+ .fcx-act-verify {
144
+ display: grid; gap: 2px; padding: 6px 8px;
145
+ border: 1px dashed var(--fcx-accent, #2f6fed); border-radius: 6px;
146
+ background: var(--fcx-surface, #fff);
147
+ }
148
+ .fcx-act-verify-title { font-weight: 600; font-size: 0.92em; }
149
+ .fcx-act-verify-line { font-variant-numeric: tabular-nums; }
150
+ .fcx-act-verify-status { color: var(--fcx-warn, #6b4a0b); font-size: 0.92em; }
142
151
  .fcx-act-ack {
143
152
  justify-self: start; cursor: pointer; font: inherit;
144
153
  padding: 2px 4px; border: 0; background: transparent; border-radius: 4px;
@@ -151,6 +160,8 @@
151
160
  .fcx-act-status[data-state="failed"] { color: var(--fcx-danger, #b42318); }
152
161
  .fcx-act-status[data-state="unknown"] { color: var(--fcx-warn, #9a6a12); }
153
162
  .fcx-act-status[data-state="unavailable"] { color: var(--fcx-muted, #5b6b7d); font-style: italic; }
163
+ .fcx-act-success-copy { margin-top: 4px; padding: 6px 8px; font-size: 13px; border-radius: 6px;
164
+ background: var(--fcx-success-bg, #e8f5ec); color: var(--fcx-success, #1f7a3f); }
154
165
  .fcx-act-locked {
155
166
  color: var(--fcx-success, #1f7a3f);
156
167
  padding: 4px 8px; border-radius: 6px;
@@ -161,6 +172,7 @@
161
172
  [data-fcx-theme="dark"] .fcx-act { color: var(--fcx-text, #dbe6f3); }
162
173
  [data-fcx-theme="dark"] .fcx-act-params, [data-fcx-theme="dark"] .fcx-act-status { color: var(--fcx-muted, #8ba0b8); }
163
174
  [data-fcx-theme="dark"] .fcx-act-risk, [data-fcx-theme="dark"] .fcx-act-confirm { background: var(--fcx-surface-2, #182238); }
175
+ [data-fcx-theme="dark"] .fcx-act-verify { background: var(--fcx-surface, #0f1626); }
164
176
  [data-fcx-theme="dark"] .fcx-act-locked { background: var(--fcx-success-bg, #12301c); }
165
177
 
166
178
  @media (prefers-reduced-motion: reduce) {
package/view.js CHANGED
@@ -126,10 +126,15 @@ export const ACTION_CLS = {
126
126
  button: "fcx-act-button",
127
127
  confirm: "fcx-act-confirm",
128
128
  confirmText: "fcx-act-confirm-text",
129
+ verify: "fcx-act-verify",
130
+ verifyTitle: "fcx-act-verify-title",
131
+ verifyLine: "fcx-act-verify-line",
132
+ verifyStatus: "fcx-act-verify-status",
129
133
  ack: "fcx-act-ack",
130
134
  status: "fcx-act-status",
131
135
  locked: "fcx-act-locked",
132
136
  resend: "fcx-act-resend",
137
+ successCopy: "fcx-act-success-copy",
133
138
  };
134
139
 
135
140
  const RISK_KEY = { low: "ui_action_risk_low", medium: "ui_action_risk_medium", high: "ui_action_risk_high" };
@@ -147,9 +152,10 @@ function fmtWhen(iso) {
147
152
  * @param {Array} pending session.pendingActions() [{requestId, offerId, state, reasonCode, message, label, unreported}]
148
153
  * @param {boolean} enabled agent.actionsEnabled
149
154
  * @param {string|null} confirming 확인 패널이 열린 offer_id
155
+ * @param {Record<string,object>} verifies session.actionVerifies() — offer_id → 대상 확정 상태 (T2.7)
150
156
  * @param {Record<string,string>} messages
151
157
  */
152
- export function actionOffersView({ offers = [], pending = [], enabled = true, confirming = null, messages = {} }) {
158
+ export function actionOffersView({ offers = [], pending = [], enabled = true, confirming = null, verifies = {}, messages = {} }) {
153
159
  const byOffer = new Map();
154
160
  for (const p of pending) byOffer.set(p.offerId, p);
155
161
  const items = offers.map((o) => {
@@ -164,6 +170,19 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
164
170
  else if (state === "failed") statusText = `${messages.ui_action_failed}${p && p.message ? " — " + p.message : ""}`;
165
171
  else if (state === "unknown") statusText = messages.ui_action_unknown;
166
172
  if (p && p.unreported) statusText = `${statusText} · ${messages.ui_action_unreported}`;
173
+ // success_copy 는 실행이 실제로 성공한 뒤에만 — 절차 자산의 "실행 성공 후 문구"
174
+ // (실행 전 카드·답변에 실으면 완료 사칭이 된다. 서버도 같은 이유로 승인 게이트를 둔다.)
175
+ const successCopy = state === "succeeded" ? String(o.success_copy || "") : "";
176
+ const needsVerify = Boolean(o.verify);
177
+ const vr = needsVerify ? (verifies[o.offer_id] || null) : null;
178
+ const verifyState = !needsVerify ? "" : (vr ? vr.state : "none");
179
+ let verifyText = "";
180
+ if (needsVerify && (verifyState === "none" || verifyState === "loading")) {
181
+ verifyText = messages.ui_action_verify_loading;
182
+ } else if (verifyState === "blocked") {
183
+ verifyText = (vr && vr.denyCopy)
184
+ || (vr && vr.reasonCode === "precondition_failed" ? messages.ui_action_verify_blocked : messages.ui_action_verify_failed);
185
+ }
167
186
  let lockedText = "";
168
187
  if (locked) {
169
188
  const who = o.lock && o.lock.actorClaimed ? ` · ${o.lock.actorClaimed}` : "";
@@ -180,11 +199,17 @@ export function actionOffersView({ offers = [], pending = [], enabled = true, co
180
199
  buttonLabel: messages.ui_action_execute,
181
200
  confirming: confirming === o.offer_id,
182
201
  confirmText: o.confirm || messages.ui_action_confirm_default,
202
+ // 대상 확정 (T2.7) — verify 동봉 offer 는 읽기 확인이 ok 여야 실행 버튼이 나온다 (위험도 무관)
203
+ needsVerify,
204
+ verifyOk: !needsVerify || verifyState === "ok",
205
+ verifyTitle: messages.ui_action_verify_title,
206
+ verifyLines: vr && Array.isArray(vr.display) ? vr.display.map((f) => `${f.label}: ${f.value}`) : [],
207
+ verifyText,
183
208
  needsAck: o.risk_level === "high",
184
209
  ackLabel: messages.ui_action_ack,
185
210
  okLabel: messages.ui_action_confirm_ok,
186
211
  cancelLabel: messages.ui_action_confirm_cancel,
187
- state, statusText, terminal,
212
+ state, statusText, terminal, successCopy,
188
213
  // 미보고만 "결과 다시 보고" — 재실행 버튼은 없다 (unknown 은 확인 필요로 남는다)
189
214
  canResend: Boolean(p && p.unreported),
190
215
  resendLabel: messages.ui_action_resend,
@@ -214,9 +239,17 @@ export function actionOffersTree(input) {
214
239
  children.push({
215
240
  tag: "div", cls: ACTION_CLS.confirm, children: [
216
241
  { tag: "div", cls: ACTION_CLS.confirmText, text: it.confirmText },
242
+ // 대상 확인 블록 (T2.7) — 읽기 결과를 상담사 눈으로 확인한 뒤에만 실행 버튼이 나온다
243
+ ...(it.needsVerify ? [{
244
+ tag: "div", cls: ACTION_CLS.verify, children: [
245
+ { tag: "div", cls: ACTION_CLS.verifyTitle, text: it.verifyTitle },
246
+ ...it.verifyLines.map((t) => ({ tag: "div", cls: ACTION_CLS.verifyLine, text: t })),
247
+ ...(it.verifyText ? [{ tag: "div", cls: ACTION_CLS.verifyStatus, text: it.verifyText, live: true }] : []),
248
+ ],
249
+ }] : []),
217
250
  // 고위험 2단 확인 — 토글 **버튼**(aria-pressed): 포커스·Space/Enter 가 되어야 키보드 사용자도 실행할 수 있다
218
251
  ...(it.needsAck ? [{ tag: "button", type: "button", cls: ACTION_CLS.ack, text: it.ackLabel, action: "ack", offerId: it.offerId, pressed: true }] : []),
219
- { tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId },
252
+ ...(it.verifyOk ? [{ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.okLabel, action: "execute", offerId: it.offerId }] : []),
220
253
  { tag: "button", cls: ACTION_CLS.button, type: "button", text: it.cancelLabel, action: "cancel", offerId: it.offerId },
221
254
  ],
222
255
  });
@@ -224,6 +257,7 @@ export function actionOffersTree(input) {
224
257
  children.push({ tag: "button", cls: ACTION_CLS.button, type: "button", text: it.buttonLabel, action: "confirm", offerId: it.offerId });
225
258
  }
226
259
  if (it.statusText) children.push({ tag: "div", cls: ACTION_CLS.status, text: it.statusText, state: it.state, live: true });
260
+ if (it.successCopy) children.push({ tag: "div", cls: ACTION_CLS.successCopy, text: it.successCopy });
227
261
  if (it.canResend) {
228
262
  children.push({ tag: "button", cls: ACTION_CLS.resend, type: "button", text: it.resendLabel, action: "resend", requestId: it.requestId });
229
263
  }
package/vue.js CHANGED
@@ -147,7 +147,12 @@ export const ActionOffersPanel = defineComponent({
147
147
  emit("result", r);
148
148
  };
149
149
  const actions = {
150
- confirm: (e) => { confirming.value = e.currentTarget.dataset.offer || null; ackedOffer.value = null; },
150
+ confirm: (e) => {
151
+ const id = e.currentTarget.dataset.offer || null;
152
+ confirming.value = id; ackedOffer.value = null;
153
+ const offer = offers.value.find((o) => o.offer_id === id);
154
+ if (offer && offer.verify && typeof props.session.verifyAction === "function") props.session.verifyAction(offer); // T2.7
155
+ },
151
156
  cancel: () => { confirming.value = null; ackedOffer.value = null; },
152
157
  ack: (e) => { const id = e.currentTarget.dataset.offer; ackedOffer.value = ackedOffer.value === id ? null : id; },
153
158
  execute: (e) => execute(e.currentTarget.dataset.offer),
@@ -155,7 +160,9 @@ export const ActionOffersPanel = defineComponent({
155
160
  };
156
161
  const tree = computed(() => actionOffersTree({
157
162
  offers: offers.value, pending: pending.value, enabled: Boolean(props.agent && props.agent.actionsEnabled),
158
- confirming: confirming.value, messages: props.agent ? props.agent.messages : {},
163
+ confirming: confirming.value,
164
+ verifies: props.session && typeof props.session.actionVerifies === "function" ? props.session.actionVerifies() : {},
165
+ messages: props.agent ? props.agent.messages : {},
159
166
  }));
160
167
  const render = (node) => {
161
168
  const p = { class: node.cls };