File size: 4,458 Bytes
5710dd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * REST client for the pending-interactions endpoints:
 * `GET/POST {baseUrl}/api/v1/sessions/{sessionId}/approvals[/...]` and
 * `.../questions[/...]`.
 *
 * These are the only remaining surfaces for listing and answering a session's
 * pending approvals/questions: the engine's interaction kernel is a
 * process-global singleton with no DI channel on the `/api/v1/debug`
 * dispatcher, so the inspector talks to the same public REST surface the
 * clients use.
 */

export interface ApprovalWire {
  readonly approval_id: string;
  readonly session_id: string;
  readonly turn_id?: number;
  readonly tool_call_id: string;
  readonly tool_name: string;
  readonly action: string;
  readonly tool_input_display: unknown;
  readonly created_at: string;
  readonly expires_at: string;
}

export interface QuestionOptionWire {
  readonly id: string;
  readonly label: string;
  readonly description?: string;
}

export interface QuestionItemWire {
  readonly id: string;
  readonly question: string;
  readonly header?: string;
  readonly body?: string;
  readonly options: readonly QuestionOptionWire[];
  readonly multi_select?: boolean;
  readonly allow_other?: boolean;
  readonly other_label?: string;
  readonly other_description?: string;
}

export interface QuestionWire {
  readonly question_id: string;
  readonly session_id: string;
  readonly turn_id?: number;
  readonly tool_call_id?: string;
  readonly questions: readonly QuestionItemWire[];
  readonly created_at: string;
}

export type QuestionAnswerWire =
  | { readonly kind: 'single'; readonly option_id: string }
  | { readonly kind: 'multi'; readonly option_ids: readonly string[] }
  | { readonly kind: 'other'; readonly text: string }
  | {
      readonly kind: 'multi_with_other';
      readonly option_ids: readonly string[];
      readonly other_text: string;
    }
  | { readonly kind: 'skipped' };

export interface InteractionsApiOptions {
  readonly baseUrl: string;
  readonly token?: string;
  readonly sessionId: string;
  readonly fetchImpl?: typeof fetch;
}

async function call<T>(
  opts: InteractionsApiOptions,
  method: 'GET' | 'POST',
  path: string,
  body?: unknown,
  expectedCodes: readonly number[] = [0],
): Promise<T> {
  const headers: Record<string, string> = {};
  const token = opts.token?.trim();
  if (token !== undefined && token !== '') {
    headers['authorization'] = `Bearer ${token}`;
  }
  if (body !== undefined) headers['content-type'] = 'application/json';
  const doFetch = opts.fetchImpl ?? fetch;
  const res = await doFetch(
    `${opts.baseUrl}/api/v1/sessions/${encodeURIComponent(opts.sessionId)}${path}`,
    {
      method,
      headers,
      body: body === undefined ? undefined : JSON.stringify(body),
    },
  );
  const envelope = (await res.json()) as { code: number; msg: string; data: unknown };
  if (!expectedCodes.includes(envelope.code)) {
    throw new Error(`interactions call failed (${envelope.code}): ${envelope.msg}`);
  }
  return envelope.data as T;
}

export async function listPendingApprovals(
  opts: InteractionsApiOptions,
): Promise<readonly ApprovalWire[]> {
  const data = await call<{ items: readonly ApprovalWire[] }>(
    opts,
    'GET',
    '/approvals?status=pending',
  );
  return data.items;
}

export async function listPendingQuestions(
  opts: InteractionsApiOptions,
): Promise<readonly QuestionWire[]> {
  const data = await call<{ items: readonly QuestionWire[] }>(
    opts,
    'GET',
    '/questions?status=pending',
  );
  return data.items;
}

export function decideApproval(
  opts: InteractionsApiOptions,
  approvalId: string,
  decision: 'approved' | 'rejected',
): Promise<unknown> {
  return call(opts, 'POST', `/approvals/${encodeURIComponent(approvalId)}`, { decision });
}

export function answerQuestion(
  opts: InteractionsApiOptions,
  questionId: string,
  answers: Readonly<Record<string, QuestionAnswerWire>>,
  method?: 'enter' | 'space' | 'number_key' | 'click',
): Promise<unknown> {
  return call(opts, 'POST', `/questions/${encodeURIComponent(questionId)}`, { answers, method });
}

export function dismissQuestion(
  opts: InteractionsApiOptions,
  questionId: string,
): Promise<unknown> {
  // The dismiss endpoint reports success as a QUESTION_DISMISSED (40909)
  // envelope (see kap-server's question dismiss action), not code 0.
  return call(opts, 'POST', `/questions/${encodeURIComponent(questionId)}:dismiss`, undefined, [
    0,
    40909,
  ]);
}