@datalayer/agent-runtimes 1.5.25 → 1.5.27

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.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Mediated sign-in (PAP_DATALAYER P-03, MOBILE M-039): a personal agent signs
3
+ * the person in at a company that asks for what only the person knows — a
4
+ * name and a password, or a code the company sent them. The person types it
5
+ * on a page of their own Datalayer client, signed in to Datalayer; it goes
6
+ * to IAM with the one-use protected ticket the sign-in named
7
+ * (`/pap/my/protected/{ticket}`), which passes it to the company once. The
8
+ * agent never sees it. The Studio's `/pap/sign-in` and Datalayer Mobile use
9
+ * this one module.
10
+ *
11
+ * Pure but for the given `fetch`: nothing typed is kept — not in an address,
12
+ * not in storage, not in an error.
13
+ *
14
+ * @module portable/protectedInput
15
+ */
16
+ /** One thing the company asks the person to type. */
17
+ export type ProtectedField = {
18
+ name: string;
19
+ label: string;
20
+ /** Typed as a password: hidden, never filled in by the browser. */
21
+ secret: boolean;
22
+ };
23
+ /** What the company asks, of whom, until when. */
24
+ export type ProtectedRequest = {
25
+ companyName: string;
26
+ /** The company's own domain: what the person can check. */
27
+ companyDomain: string;
28
+ purpose: 'credentials' | 'one_time_code';
29
+ fields: ProtectedField[];
30
+ /** Where the company sent its code, when it did. */
31
+ sentTo: string | null;
32
+ expiresAt: string;
33
+ };
34
+ /** What IAM keeps of one field, at most. */
35
+ export declare const MAX_VALUE_LENGTH = 1024;
36
+ /** What the page says (checked against the vocabulary). */
37
+ export declare const PROTECTED_INPUT_WORDS: {
38
+ readonly title: (company: string) => string;
39
+ readonly intro: (company: string) => string;
40
+ readonly asks: "Who asks";
41
+ readonly where: "Where";
42
+ readonly whereHint: "The company’s address: the part you can check.";
43
+ readonly sentCode: (company: string, sentTo: string) => string;
44
+ readonly sentCodeSomewhere: (company: string) => string;
45
+ readonly code: "Code";
46
+ readonly signIn: (company: string) => string;
47
+ readonly sendCode: "Send the code";
48
+ readonly sending: "Sending…";
49
+ readonly fillEvery: "Fill in every field.";
50
+ readonly tooLong: (label: string) => string;
51
+ readonly done: "Done. Go back to your conversation: the agent continues once it checks.";
52
+ readonly expires: (at: string) => string;
53
+ readonly reading: "Reading the request…";
54
+ readonly gone: "This request is unknown, already answered or expired. Ask your personal agent to start again.";
55
+ readonly incomplete: "This link is incomplete: it names no request. Start again from your personal agent.";
56
+ readonly signedOut: "Sign in to Datalayer first.";
57
+ };
58
+ /** Whether a link names a request. */
59
+ export declare const ticketComplete: (ticket: string) => boolean;
60
+ /** Raised when IAM does not know the request (unknown, answered or expired): the page says only that. */
61
+ export declare class ProtectedRequestGone extends Error {
62
+ constructor();
63
+ }
64
+ /** IAM's answer read whole, or refused with a sentence: no default. */
65
+ export declare function protectedRequestOf(raw: unknown): ProtectedRequest;
66
+ /** The fields still empty, by name: the form is sent only when there is none. */
67
+ export declare const emptyFields: (request: Pick<ProtectedRequest, "fields">, values: Record<string, string>) => string[];
68
+ /** What keeps the form from being sent, in sentences; none when it can be. Never what was typed. */
69
+ export declare function fieldProblems(request: Pick<ProtectedRequest, 'fields'>, values: Record<string, string>): string[];
70
+ /** The words on the one button. */
71
+ export declare const submitWords: (request: Pick<ProtectedRequest, "purpose" | "companyName">) => string;
72
+ /** What the code page says of where the code went. */
73
+ export declare const sentWords: (request: Pick<ProtectedRequest, "companyName" | "sentTo">) => string;
74
+ /**
75
+ * The body sent: exactly the fields asked, none empty, as typed. A code is
76
+ * sent without its spaces; a password, exactly as typed.
77
+ */
78
+ export declare function submitBody(request: Pick<ProtectedRequest, 'fields' | 'purpose'>, values: Record<string, string>): {
79
+ values: Record<string, string>;
80
+ };
81
+ /** The ticket a mediated sign-in page names (`…?ticket=`), or `''`. */
82
+ export declare function ticketOfPage(open: string): string;
83
+ /** IAM's protected-input routes, with the person's own sign-in. */
84
+ export declare function protectedInputClient({ iamUrl, getAccessToken, fetch: fetcher, }: {
85
+ iamUrl: string;
86
+ getAccessToken: () => string | undefined;
87
+ fetch: typeof fetch;
88
+ }): {
89
+ /** What the company asks; reading it does not use the request up. */
90
+ read: (ticket: string) => Promise<ProtectedRequest>;
91
+ /** What the person typed, sent once to IAM. */
92
+ send: (ticket: string, request: Pick<ProtectedRequest, "fields" | "purpose">, values: Record<string, string>) => Promise<void>;
93
+ };
@@ -0,0 +1,186 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /** What IAM keeps of one field, at most. */
6
+ export const MAX_VALUE_LENGTH = 1024;
7
+ /** What the page says (checked against the vocabulary). */
8
+ export const PROTECTED_INPUT_WORDS = {
9
+ title: (company) => `Sign in at ${company}`,
10
+ intro: (company) => `Your personal agent is signing you in at ${company}, which asks for what only you know. Type it here: Datalayer passes it to ${company} once, and your agent never sees it.`,
11
+ asks: 'Who asks',
12
+ where: 'Where',
13
+ whereHint: 'The company’s address: the part you can check.',
14
+ sentCode: (company, sentTo) => `${company} sent a code to ${sentTo}.`,
15
+ sentCodeSomewhere: (company) => `${company} sent you a code.`,
16
+ code: 'Code',
17
+ signIn: (company) => `Sign in at ${company}`,
18
+ sendCode: 'Send the code',
19
+ sending: 'Sending…',
20
+ fillEvery: 'Fill in every field.',
21
+ tooLong: (label) => `${label} is longer than ${MAX_VALUE_LENGTH} characters.`,
22
+ done: 'Done. Go back to your conversation: the agent continues once it checks.',
23
+ expires: (at) => `This request ends at ${new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}.`,
24
+ reading: 'Reading the request…',
25
+ gone: 'This request is unknown, already answered or expired. Ask your personal agent to start again.',
26
+ incomplete: 'This link is incomplete: it names no request. Start again from your personal agent.',
27
+ signedOut: 'Sign in to Datalayer first.',
28
+ };
29
+ const TICKET = /^[A-Za-z0-9_-]{8,256}$/;
30
+ const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
31
+ /** Whether a link names a request. */
32
+ export const ticketComplete = (ticket) => TICKET.test(ticket.trim());
33
+ /** Raised when IAM does not know the request (unknown, answered or expired): the page says only that. */
34
+ export class ProtectedRequestGone extends Error {
35
+ constructor() {
36
+ super(PROTECTED_INPUT_WORDS.gone);
37
+ this.name = 'ProtectedRequestGone';
38
+ }
39
+ }
40
+ /** IAM's answer read whole, or refused with a sentence: no default. */
41
+ export function protectedRequestOf(raw) {
42
+ const refused = () => new Error('The request could not be read.');
43
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
44
+ throw refused();
45
+ }
46
+ const data = raw;
47
+ if (typeof data.company_name !== 'string' ||
48
+ typeof data.company_domain !== 'string' ||
49
+ !data.company_domain ||
50
+ (data.purpose !== 'credentials' && data.purpose !== 'one_time_code') ||
51
+ !Array.isArray(data.fields) ||
52
+ data.fields.length === 0 ||
53
+ data.fields.length > 10 ||
54
+ typeof data.expires_at !== 'string' ||
55
+ !(data.sent_to === null ||
56
+ data.sent_to === undefined ||
57
+ typeof data.sent_to === 'string')) {
58
+ throw refused();
59
+ }
60
+ const names = new Set();
61
+ const fields = data.fields.map(item => {
62
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
63
+ throw refused();
64
+ }
65
+ const entry = item;
66
+ if (typeof entry.name !== 'string' ||
67
+ !FIELD_NAME.test(entry.name) ||
68
+ names.has(entry.name) ||
69
+ typeof entry.label !== 'string' ||
70
+ typeof entry.secret !== 'boolean') {
71
+ throw refused();
72
+ }
73
+ names.add(entry.name);
74
+ return {
75
+ name: entry.name,
76
+ label: entry.label || entry.name,
77
+ secret: entry.secret,
78
+ };
79
+ });
80
+ if (data.purpose === 'one_time_code' && fields.length !== 1) {
81
+ throw refused();
82
+ }
83
+ return {
84
+ companyName: data.company_name || data.company_domain,
85
+ companyDomain: data.company_domain,
86
+ purpose: data.purpose,
87
+ fields,
88
+ sentTo: typeof data.sent_to === 'string' && data.sent_to ? data.sent_to : null,
89
+ expiresAt: data.expires_at,
90
+ };
91
+ }
92
+ /** The fields still empty, by name: the form is sent only when there is none. */
93
+ export const emptyFields = (request, values) => request.fields
94
+ .filter(field => !(values[field.name] ?? '').trim())
95
+ .map(field => field.name);
96
+ /** What keeps the form from being sent, in sentences; none when it can be. Never what was typed. */
97
+ export function fieldProblems(request, values) {
98
+ const problems = [];
99
+ if (emptyFields(request, values).length) {
100
+ problems.push(PROTECTED_INPUT_WORDS.fillEvery);
101
+ }
102
+ for (const field of request.fields) {
103
+ if ((values[field.name] ?? '').length > MAX_VALUE_LENGTH) {
104
+ problems.push(PROTECTED_INPUT_WORDS.tooLong(field.label));
105
+ }
106
+ }
107
+ return problems;
108
+ }
109
+ /** The words on the one button. */
110
+ export const submitWords = (request) => request.purpose === 'one_time_code'
111
+ ? PROTECTED_INPUT_WORDS.sendCode
112
+ : PROTECTED_INPUT_WORDS.signIn(request.companyName);
113
+ /** What the code page says of where the code went. */
114
+ export const sentWords = (request) => request.sentTo
115
+ ? PROTECTED_INPUT_WORDS.sentCode(request.companyName, request.sentTo)
116
+ : PROTECTED_INPUT_WORDS.sentCodeSomewhere(request.companyName);
117
+ /**
118
+ * The body sent: exactly the fields asked, none empty, as typed. A code is
119
+ * sent without its spaces; a password, exactly as typed.
120
+ */
121
+ export function submitBody(request, values) {
122
+ const problems = fieldProblems(request, values);
123
+ if (problems.length) {
124
+ throw new Error(problems.join(' '));
125
+ }
126
+ const sent = {};
127
+ for (const field of request.fields) {
128
+ const typed = values[field.name];
129
+ sent[field.name] =
130
+ request.purpose === 'one_time_code' ? typed.replace(/\s+/g, '') : typed;
131
+ }
132
+ return { values: sent };
133
+ }
134
+ /** The ticket a mediated sign-in page names (`…?ticket=`), or `''`. */
135
+ export function ticketOfPage(open) {
136
+ try {
137
+ const ticket = new URL(open).searchParams.get('ticket') ?? '';
138
+ return ticketComplete(ticket) ? ticket.trim() : '';
139
+ }
140
+ catch {
141
+ return '';
142
+ }
143
+ }
144
+ /** IAM's protected-input routes, with the person's own sign-in. */
145
+ export function protectedInputClient({ iamUrl, getAccessToken, fetch: fetcher, }) {
146
+ const address = (ticket) => {
147
+ if (!iamUrl) {
148
+ throw new Error('Datalayer’s sign-in service is not configured.');
149
+ }
150
+ return `${iamUrl.replace(/\/+$/, '')}/api/iam/v1/pap/my/protected/${encodeURIComponent(ticket.trim())}`;
151
+ };
152
+ const headers = () => {
153
+ const token = getAccessToken();
154
+ return {
155
+ 'Content-Type': 'application/json',
156
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
157
+ };
158
+ };
159
+ /** IAM's refusal in its own sentence; never what was typed. */
160
+ const answered = async (response, what) => {
161
+ const body = (await response.json().catch(() => ({})));
162
+ if (response.status === 404) {
163
+ throw new ProtectedRequestGone();
164
+ }
165
+ if (!response.ok) {
166
+ throw new Error(`${what}: ${typeof body.detail === 'string' ? body.detail : response.status}`);
167
+ }
168
+ return body;
169
+ };
170
+ return {
171
+ /** What the company asks; reading it does not use the request up. */
172
+ read: async (ticket) => protectedRequestOf(await answered(await fetcher(address(ticket), { headers: headers() }), 'The request could not be read')),
173
+ /** What the person typed, sent once to IAM. */
174
+ send: async (ticket, request, values) => {
175
+ const body = submitBody(request, values);
176
+ const answer = await answered(await fetcher(address(ticket), {
177
+ method: 'POST',
178
+ headers: headers(),
179
+ body: JSON.stringify(body),
180
+ }), 'It was not sent');
181
+ if (answer.kept !== true) {
182
+ throw new Error('It was not sent: the answer does not say it was kept.');
183
+ }
184
+ },
185
+ };
186
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * How a streamed turn went, measured without its words (MOBILE M-036, §10):
3
+ * the time to its first streamed token, to its complete answer, how long a
4
+ * Stop took, the category of a stream that failed, and how often a lost
5
+ * stream was read back. Never a prompt, an answer, a tool's arguments or an
6
+ * id — only durations, counts and coarse categories — and an OTLP/JSON body
7
+ * of them for the platform's metrics ingest.
8
+ *
9
+ * Pure: the clock is given.
10
+ *
11
+ * @module portable/runMeasures
12
+ */
13
+ import { type AgUiEvent } from './agui';
14
+ /** Why a stream failed, coarsely. */
15
+ export type StreamFailure = 'refused' | 'cut_short' | 'network' | 'other';
16
+ /** One turn, measured. */
17
+ export type RunMeasure = {
18
+ surface: 'application' | 'agent';
19
+ firstTokenMs: number | null;
20
+ answerMs: number | null;
21
+ stopLatencyMs: number | null;
22
+ failure: StreamFailure | null;
23
+ };
24
+ /** The category of a stream's failure; never its message. */
25
+ export declare function failureOf(error: unknown): StreamFailure;
26
+ /** Measures one turn from its start: fed its events, its Stop, its failure; read once it ended. */
27
+ export declare class RunMeter {
28
+ private readonly surface;
29
+ private readonly now;
30
+ private readonly started;
31
+ private firstToken;
32
+ private answered;
33
+ private stopLatency;
34
+ private failed;
35
+ constructor(surface: RunMeasure['surface'], now?: () => number);
36
+ event(event: AgUiEvent): void;
37
+ stopped(latencyMs: number): void;
38
+ failure(error: unknown): void;
39
+ read(): RunMeasure;
40
+ }
41
+ /**
42
+ * The OTLP/JSON body of turns measured and lost streams read back: gauges of
43
+ * milliseconds and counters, each point with the surface and, for a failure,
44
+ * its category — nothing else.
45
+ */
46
+ export declare function otlpMetricsOf(measures: RunMeasure[], recoveries: number, resource: Record<string, string>, nowMs: number): Record<string, unknown>;
@@ -0,0 +1,156 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * How a streamed turn went, measured without its words (MOBILE M-036, §10):
7
+ * the time to its first streamed token, to its complete answer, how long a
8
+ * Stop took, the category of a stream that failed, and how often a lost
9
+ * stream was read back. Never a prompt, an answer, a tool's arguments or an
10
+ * id — only durations, counts and coarse categories — and an OTLP/JSON body
11
+ * of them for the platform's metrics ingest.
12
+ *
13
+ * Pure: the clock is given.
14
+ *
15
+ * @module portable/runMeasures
16
+ */
17
+ import { RUN_CUT_SHORT } from './agui';
18
+ import { RuntimeRefusal } from './sessions';
19
+ /** The category of a stream's failure; never its message. */
20
+ export function failureOf(error) {
21
+ if (error instanceof RuntimeRefusal)
22
+ return 'refused';
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ if (message === RUN_CUT_SHORT || message.includes(RUN_CUT_SHORT))
25
+ return 'cut_short';
26
+ if (error instanceof TypeError ||
27
+ /network|offline|timed? ?out|fetch/i.test(message))
28
+ return 'network';
29
+ return 'other';
30
+ }
31
+ /** Measures one turn from its start: fed its events, its Stop, its failure; read once it ended. */
32
+ export class RunMeter {
33
+ surface;
34
+ now;
35
+ started;
36
+ firstToken = null;
37
+ answered = null;
38
+ stopLatency = null;
39
+ failed = null;
40
+ constructor(surface, now = () => Date.now()) {
41
+ this.surface = surface;
42
+ this.now = now;
43
+ this.started = now();
44
+ }
45
+ event(event) {
46
+ if (this.firstToken === null && event.type === 'TEXT_MESSAGE_CONTENT') {
47
+ this.firstToken = this.now() - this.started;
48
+ }
49
+ if (event.type === 'RUN_FINISHED' && this.answered === null) {
50
+ this.answered = this.now() - this.started;
51
+ }
52
+ }
53
+ stopped(latencyMs) {
54
+ this.stopLatency = Math.max(0, latencyMs);
55
+ }
56
+ failure(error) {
57
+ this.failed = failureOf(error);
58
+ }
59
+ read() {
60
+ return {
61
+ surface: this.surface,
62
+ firstTokenMs: this.firstToken,
63
+ answerMs: this.failed || this.stopLatency !== null ? null : this.answered,
64
+ stopLatencyMs: this.stopLatency,
65
+ failure: this.failed,
66
+ };
67
+ }
68
+ }
69
+ const attributes = (values) => Object.entries(values).map(([key, value]) => ({
70
+ key,
71
+ value: { stringValue: value },
72
+ }));
73
+ /**
74
+ * The OTLP/JSON body of turns measured and lost streams read back: gauges of
75
+ * milliseconds and counters, each point with the surface and, for a failure,
76
+ * its category — nothing else.
77
+ */
78
+ export function otlpMetricsOf(measures, recoveries, resource, nowMs) {
79
+ const time = String(BigInt(Math.trunc(nowMs)) * 1000000n);
80
+ const gauge = (name, pick) => ({
81
+ name,
82
+ unit: 'ms',
83
+ gauge: {
84
+ dataPoints: measures.flatMap(measure => {
85
+ const value = pick(measure);
86
+ return value === null
87
+ ? []
88
+ : [
89
+ {
90
+ asDouble: value,
91
+ timeUnixNano: time,
92
+ attributes: attributes({ surface: measure.surface }),
93
+ },
94
+ ];
95
+ }),
96
+ },
97
+ });
98
+ const failures = new Map();
99
+ for (const measure of measures) {
100
+ if (measure.failure) {
101
+ const key = `${measure.surface}\n${measure.failure}`;
102
+ failures.set(key, (failures.get(key) ?? 0) + 1);
103
+ }
104
+ }
105
+ const metrics = [
106
+ gauge('datalayer.client.run.first_token', measure => measure.firstTokenMs),
107
+ gauge('datalayer.client.run.answer', measure => measure.answerMs),
108
+ gauge('datalayer.client.run.stop_latency', measure => measure.stopLatencyMs),
109
+ {
110
+ name: 'datalayer.client.run.failures',
111
+ sum: {
112
+ aggregationTemporality: 1,
113
+ isMonotonic: true,
114
+ dataPoints: [...failures].map(([key, count]) => {
115
+ const [surface, category] = key.split('\n');
116
+ return {
117
+ asInt: String(count),
118
+ timeUnixNano: time,
119
+ attributes: attributes({ surface, category }),
120
+ };
121
+ }),
122
+ },
123
+ },
124
+ {
125
+ name: 'datalayer.client.run.recoveries',
126
+ sum: {
127
+ aggregationTemporality: 1,
128
+ isMonotonic: true,
129
+ dataPoints: recoveries > 0
130
+ ? [
131
+ {
132
+ asInt: String(recoveries),
133
+ timeUnixNano: time,
134
+ attributes: [],
135
+ },
136
+ ]
137
+ : [],
138
+ },
139
+ },
140
+ ].filter(metric => 'gauge' in metric
141
+ ? metric.gauge.dataPoints.length > 0
142
+ : metric.sum.dataPoints.length > 0);
143
+ return {
144
+ resourceMetrics: [
145
+ {
146
+ resource: { attributes: attributes(resource) },
147
+ scopeMetrics: [
148
+ {
149
+ scope: { name: '@datalayer/agent-runtimes/portable/runMeasures' },
150
+ metrics,
151
+ },
152
+ ],
153
+ },
154
+ ],
155
+ };
156
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * How a sign-in under way stands, asked by the person's own client (MOBILE
3
+ * M-039): the gateway's `GET /api/mcp/v1/pap/sign-in?app_uid&company`, with
4
+ * the person's own sign-in, finishes what can be finished — a Direct return
5
+ * IAM bound to their browser and the company asked, a Device code the
6
+ * company confirmed — and says where it stands.
7
+ *
8
+ * A continuation is accepted only when it is bound: the answer names the
9
+ * application and the company the card was for, and a verified issuer. The
10
+ * client then goes on in the same conversation (its session), once.
11
+ *
12
+ * Pure but for the given `fetch`.
13
+ *
14
+ * @module portable/signInStatus
15
+ */
16
+ /** Where the gateway answers how a sign-in stands. */
17
+ export declare const SIGN_IN_STATUS_PATH = "/api/mcp/v1/pap/sign-in";
18
+ /** How often a client asks while a sign-in waits, and never past its expiry. */
19
+ export declare const SIGN_IN_POLL_MS = 5000;
20
+ export type SignInStands = {
21
+ appUid: string;
22
+ company: string;
23
+ issuer: string;
24
+ status: 'pending' | 'signed_in' | 'refused' | 'denied' | 'not_started' | 'other';
25
+ reason: string;
26
+ };
27
+ /** The gateway's answer, or `null` when it does not bind to this application and company. */
28
+ export declare function signInStandsOf(raw: unknown, expected: {
29
+ appUid: string;
30
+ company: string;
31
+ }): SignInStands | null;
32
+ /** Asks how a sign-in stands; `null` for none under way (404) or an answer that does not bind. */
33
+ export declare function signInStatusClient({ baseUrl, getAccessToken, fetch: fetcher, }: {
34
+ baseUrl: string;
35
+ getAccessToken: () => string | undefined;
36
+ fetch: typeof fetch;
37
+ }): (appUid: string, company: string) => Promise<SignInStands | null>;
@@ -0,0 +1,74 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * How a sign-in under way stands, asked by the person's own client (MOBILE
7
+ * M-039): the gateway's `GET /api/mcp/v1/pap/sign-in?app_uid&company`, with
8
+ * the person's own sign-in, finishes what can be finished — a Direct return
9
+ * IAM bound to their browser and the company asked, a Device code the
10
+ * company confirmed — and says where it stands.
11
+ *
12
+ * A continuation is accepted only when it is bound: the answer names the
13
+ * application and the company the card was for, and a verified issuer. The
14
+ * client then goes on in the same conversation (its session), once.
15
+ *
16
+ * Pure but for the given `fetch`.
17
+ *
18
+ * @module portable/signInStatus
19
+ */
20
+ /** Where the gateway answers how a sign-in stands. */
21
+ export const SIGN_IN_STATUS_PATH = '/api/mcp/v1/pap/sign-in';
22
+ /** How often a client asks while a sign-in waits, and never past its expiry. */
23
+ export const SIGN_IN_POLL_MS = 5000;
24
+ const STATUSES = [
25
+ 'pending',
26
+ 'signed_in',
27
+ 'refused',
28
+ 'denied',
29
+ 'not_started',
30
+ ];
31
+ /** The gateway's answer, or `null` when it does not bind to this application and company. */
32
+ export function signInStandsOf(raw, expected) {
33
+ const data = raw && typeof raw === 'object' && !Array.isArray(raw)
34
+ ? raw
35
+ : null;
36
+ if (!data ||
37
+ data.app_uid !== expected.appUid ||
38
+ typeof data.company !== 'string' ||
39
+ data.company.toLowerCase() !== expected.company.toLowerCase() ||
40
+ typeof data.issuer !== 'string' ||
41
+ !data.issuer.startsWith('https://')) {
42
+ return null;
43
+ }
44
+ return {
45
+ appUid: expected.appUid,
46
+ company: data.company,
47
+ issuer: data.issuer,
48
+ status: STATUSES.find(status => status === data.status) ?? 'other',
49
+ reason: typeof data.reason === 'string' ? data.reason : '',
50
+ };
51
+ }
52
+ /** Asks how a sign-in stands; `null` for none under way (404) or an answer that does not bind. */
53
+ export function signInStatusClient({ baseUrl, getAccessToken, fetch: fetcher, }) {
54
+ return async (appUid, company) => {
55
+ const token = getAccessToken();
56
+ if (!token) {
57
+ return null;
58
+ }
59
+ const query = new URLSearchParams({ app_uid: appUid, company });
60
+ const response = await fetcher(`${baseUrl.replace(/\/+$/, '')}${SIGN_IN_STATUS_PATH}?${query.toString()}`, {
61
+ headers: {
62
+ Authorization: `Bearer ${token}`,
63
+ Accept: 'application/json',
64
+ },
65
+ });
66
+ if (response.status === 404) {
67
+ return null;
68
+ }
69
+ if (!response.ok) {
70
+ throw new Error(`How the sign-in at ${company} stands could not be read (${response.status}).`);
71
+ }
72
+ return signInStandsOf(await response.json(), { appUid, company });
73
+ };
74
+ }
@@ -454,15 +454,20 @@ export class ACPAdapter extends BaseProtocolAdapter {
454
454
  });
455
455
  }
456
456
  if (updateType === 'tool_call_update') {
457
+ // A call's result, once it says it ended; its progress before then is
458
+ // not a result. ACP names the call it updates.
457
459
  const progress = update;
458
- this.emit({
459
- type: 'tool-result',
460
- toolResult: {
461
- success: progress.status !== 'failed',
462
- result: progress.rawOutput,
463
- },
464
- timestamp: new Date(),
465
- });
460
+ if (progress.status === 'completed' || progress.status === 'failed') {
461
+ this.emit({
462
+ type: 'tool-result',
463
+ toolResult: {
464
+ toolCallId: progress.toolCallId ?? '',
465
+ success: progress.status === 'completed',
466
+ result: progress.rawOutput,
467
+ },
468
+ timestamp: new Date(),
469
+ });
470
+ }
466
471
  }
467
472
  if (updateType === 'agent_thought_chunk') {
468
473
  const thought = textOf(update.content);
@@ -4,6 +4,7 @@
4
4
  * @module chat/utils
5
5
  */
6
6
  import { QueryClient } from '@tanstack/react-query';
7
+ import { sanitizeAssistantContent } from '../portable/adapterEvents';
7
8
  import type { ChatMessage } from '../types/messages';
8
9
  import type { ProtocolConfig } from '../types/protocol';
9
10
  import { type BaseProtocolAdapter } from '../protocols';
@@ -16,14 +17,7 @@ export declare const PRIMER_PORTAL_ROOT_ID = "__primerPortalRoot__";
16
17
  * Check if an item is a tool call message
17
18
  */
18
19
  export declare function isToolCallMessage(item: DisplayItem): item is ToolCallMessage;
19
- /**
20
- * Strip low-level protocol/tool-call markup from assistant text content.
21
- *
22
- * Some models occasionally leak internal call formats (for example,
23
- * <calls>...</calls> or <tml:invoke ...>) into natural-language responses.
24
- * Keep user/system text untouched and sanitize assistant-visible text only.
25
- */
26
- export declare function sanitizeAssistantContent(content: string): string;
20
+ export { sanitizeAssistantContent };
27
21
  /**
28
22
  * Extract text content from a ChatMessage
29
23
  */