File size: 9,919 Bytes
f0634fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
/**
 * ACP `session/request_permission` ↔ agent-core-v2 approval mappers.
 *
 * Pure functions that translate an `ApprovalRequest` (raised by the engine's
 * `AgentPermissionGate` and surfaced through the `interaction` kernel) into the
 * ACP `PermissionOption[]` + `ToolCallUpdate` surfaced to the client, and the
 * client's `RequestPermissionResponse` back into an `ApprovalResponse`. Kept
 * free of IO so the mappings stay unit-testable without a live connection.
 */

import type {
  PermissionOption,
  RequestPermissionResponse,
  ToolCallContent,
  ToolCallUpdate,
} from '@agentclientprotocol/sdk';
import type {
  SessionApprovalRequest as ApprovalRequest,
  SessionApprovalResponse as ApprovalResponse,
} from '@moonshot-ai/agent-core-v2';

import { displayBlockToAcpContent } from './convert';
import { acpToolCallId } from './events-map';

/**
 * Canonical option ids surfaced to the ACP client.
 *
 * The wire-level `PermissionOption.optionId` is opaque to the client (it
 * round-trips back in `RequestPermissionResponse.outcome.optionId`), so the
 * host is free to pick any stable string. These literals are the single source
 * of truth on both the build- and the parse-side; tests import them rather
 * than re-typing the strings.
 */
export const APPROVE_ONCE_OPTION_ID = 'approve_once';
export const APPROVE_ALWAYS_OPTION_ID = 'approve_always';
export const REJECT_OPTION_ID = 'reject';

/**
 * `plan_review` optionId namespace. Picked deliberately so the `plan_*` prefix
 * never collides with the canonical `approve_*` / `reject` namespace nor with
 * the question bridge's `q{n}_*` namespace.
 *
 *  - `plan_opt_<i>` β€” one per `display.options[i]` (rendered as `allow_once`
 *    so the user can pick A / B / C without re-entering the prompt).
 *  - `plan_approve` β€” fallback approve when `display.options` is absent or
 *    has fewer than two entries.
 *  - `plan_revise` / `plan_reject_and_exit` β€” the two reject-side exits.
 */
export const PLAN_APPROVE_OPTION_ID = 'plan_approve';
export const PLAN_REVISE_OPTION_ID = 'plan_revise';
export const PLAN_REJECT_AND_EXIT_OPTION_ID = 'plan_reject_and_exit';

function planOptOptionId(i: number): string {
  return `plan_opt_${i}`;
}

/**
 * The three canonical permission options surfaced to the ACP client for a
 * non-`plan_review` approval prompt.
 *
 * Order is load-bearing: ACP clients render options top-to-bottom, so
 * allow-once is the primary action, allow-always the secondary, and reject the
 * terminal/dangerous action that should be hardest to click by accident.
 */
const CANONICAL_OPTIONS: readonly PermissionOption[] = [
  { optionId: APPROVE_ONCE_OPTION_ID, name: 'Approve once', kind: 'allow_once' },
  {
    optionId: APPROVE_ALWAYS_OPTION_ID,
    name: 'Approve for this session',
    kind: 'allow_always',
  },
  { optionId: REJECT_OPTION_ID, name: 'Reject', kind: 'reject_once' },
];

/**
 * Build the {@link PermissionOption}[] surfaced to the ACP client for an
 * approval prompt.
 *
 * When the request's display block carries `kind: 'plan_review'`, the options
 * expand to one `allow_once` per `display.options[i]` (A / B / C) β€” or a
 * single `plan_approve` fallback when the policy did not supply β‰₯ 2 discrete
 * options β€” plus the two `reject_once` exits `Revise` and `Reject and Exit`.
 *
 * For every other display kind, returns the canonical 3-option list.
 */
export function approvalRequestToPermissionOptions(
  req: ApprovalRequest,
): readonly PermissionOption[] {
  if (req.display.kind !== 'plan_review') {
    return CANONICAL_OPTIONS;
  }
  const display = req.display;
  const approveOptions: PermissionOption[] =
    display.options !== undefined && display.options.length >= 2
      ? display.options.map((opt, i) => ({
          optionId: planOptOptionId(i),
          name: opt.label,
          kind: 'allow_once' as const,
        }))
      : [{ optionId: PLAN_APPROVE_OPTION_ID, name: 'Approve', kind: 'allow_once' as const }];
  return [
    ...approveOptions,
    { optionId: PLAN_REVISE_OPTION_ID, name: 'Revise', kind: 'reject_once' as const },
    {
      optionId: PLAN_REJECT_AND_EXIT_OPTION_ID,
      name: 'Reject and Exit',
      kind: 'reject_once' as const,
    },
  ];
}

/**
 * Translate an ACP {@link RequestPermissionResponse} into an engine
 * {@link ApprovalResponse}.
 *
 * Decision mapping (canonical / non-plan_review path):
 *  - `cancelled` outcome β†’ `decision: 'cancelled'`.
 *  - `approve_once`  β†’ `decision: 'approved'` (no scope, one-shot).
 *  - `approve_always` β†’ `decision: 'approved'` with `scope: 'session'` so the
 *    engine installs a session-runtime allow rule for subsequent invocations.
 *  - `reject`        β†’ `decision: 'rejected'`.
 *  - Legacy Python kimi-cli (< v0.9.0) ids `approve` / `approve_for_session`
 *    map like `approve_once` / `approve_always` so custom ACP clients built
 *    against the old SDK are not silently rejected.
 *  - Any other optionId β†’ defensive `rejected` (rejecting is strictly safer
 *    than approving for an unknown id).
 *
 * For `plan_review`, the `plan_opt_<i>` / `plan_approve` / `plan_revise` /
 * `plan_reject_and_exit` optionIds map directly to the discriminator, and the
 * matched option's label is attached as `selectedLabel` so the downstream
 * policy can drive its branch off a stable string.
 */
export function permissionResponseToApprovalResponse(
  req: ApprovalRequest,
  response: RequestPermissionResponse,
): ApprovalResponse {
  if (response.outcome.outcome === 'cancelled') {
    return { decision: 'cancelled' };
  }
  const optionId = response.outcome.optionId;
  if (req.display.kind === 'plan_review') {
    return mapPlanReviewOptionId(req.display, optionId);
  }
  switch (optionId) {
    case APPROVE_ONCE_OPTION_ID:
    // Legacy Python kimi-cli (< v0.9.0) used 'approve' as the allow-once
    // optionId. Keep accepting it so custom ACP clients built against the
    // old SDK are not silently rejected.
    case 'approve':
      return { decision: 'approved' };
    case APPROVE_ALWAYS_OPTION_ID:
    // Legacy Python kimi-cli (< v0.9.0) used 'approve_for_session' as the
    // allow-always optionId. Same backward-compatibility rationale as the
    // 'approve' branch above.
    case 'approve_for_session':
      return { decision: 'approved', scope: 'session' };
    case REJECT_OPTION_ID:
      return { decision: 'rejected' };
    default:
      // Unknown optionId β€” defensive fallback. Reject is safer than approve.
      return { decision: 'rejected' };
  }
}

function mapPlanReviewOptionId(
  display: Extract<ApprovalRequest['display'], { kind: 'plan_review' }>,
  optionId: string,
): ApprovalResponse {
  if (optionId === PLAN_APPROVE_OPTION_ID) {
    return { decision: 'approved' };
  }
  if (optionId === PLAN_REVISE_OPTION_ID) {
    return { decision: 'rejected', selectedLabel: 'Revise' };
  }
  if (optionId === PLAN_REJECT_AND_EXIT_OPTION_ID) {
    return { decision: 'rejected', selectedLabel: 'Reject and Exit' };
  }
  const match = /^plan_opt_(\d+)$/.exec(optionId);
  if (match) {
    const i = Number(match[1]);
    const opts = display.options;
    if (opts !== undefined && Number.isInteger(i) && i >= 0 && i < opts.length) {
      return { decision: 'approved', selectedLabel: opts[i]!.label };
    }
    return { decision: 'rejected' };
  }
  return { decision: 'rejected' };
}

/**
 * Build the ACP {@link ToolCallUpdate} that scopes a permission request to a
 * specific in-flight tool call.
 *
 * The `toolCallId` is the prefixed ACP wire id `${turnId}:${rawId}` β€” matching
 * the id format used by all other tool_call/tool_call_update notifications β€”
 * so the client can correlate the approval prompt with the tool card it
 * already rendered. If `req.turnId` is `undefined` the raw id is used as a
 * defensive fallback (in practice approvals always fire after
 * `tool.call.started`, so the fallback is effectively unreachable).
 *
 * Content shape:
 *  - If `req.display` produces a diff-bearing entry, prepend it so the diff /
 *    plan card is the headline of the approval prompt.
 *  - Always append a human-readable action summary
 *    (`"Requesting approval to ${req.action}"`) so the prompt is never empty.
 */
export function buildPermissionToolCallUpdate(req: ApprovalRequest): ToolCallUpdate {
  const rawId = req.toolCallId ?? req.toolName;
  const toolCallId = req.turnId !== undefined ? acpToolCallId(req.turnId, rawId) : rawId;
  const content: ToolCallContent[] = [];
  const headlineEntry = displayBlockToAcpContent(req.display);
  if (headlineEntry !== null) {
    content.push(headlineEntry);
  }
  content.push({
    type: 'content',
    content: { type: 'text', text: `Requesting approval to ${req.action}` },
  });
  return {
    toolCallId,
    title: req.toolName,
    content,
  };
}

/**
 * Look up the matched {@link PermissionOption}'s display name for the given
 * response and return a new {@link ApprovalResponse} carrying `selectedLabel`.
 * Returns the input unchanged when the outcome was `cancelled`, the optionId
 * is unknown, or it is in the `plan_*` namespace (the plan_review branch
 * attaches `selectedLabel` inside the mapper already).
 *
 * Pure: returns a fresh object (never mutates the input).
 */
export function attachSelectedLabel(
  response: RequestPermissionResponse,
  approval: ApprovalResponse,
  options: readonly PermissionOption[],
): ApprovalResponse {
  const outcome = response.outcome;
  if (outcome.outcome !== 'selected') return approval;
  if (
    outcome.optionId.startsWith('plan_opt_') ||
    outcome.optionId === PLAN_APPROVE_OPTION_ID ||
    outcome.optionId === PLAN_REVISE_OPTION_ID ||
    outcome.optionId === PLAN_REJECT_AND_EXIT_OPTION_ID
  ) {
    return approval;
  }
  const matched = options.find((o) => o.optionId === outcome.optionId);
  if (!matched) return approval;
  return { ...approval, selectedLabel: matched.name };
}