scripts/perf-slack-turn.mjs
Rama main · réplica de lectura del repositorio original.
import { execFileSync } from 'node:child_process';
import { generateKeyPairSync } from 'node:crypto';
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { cpus, homedir, loadavg } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { parseArgs } from 'node:util';
import {
EVENTS_PATH,
REPO_ROOT,
assertNodeVersion,
getFreePort,
loadFake,
postSignedEvent,
} from './lib/offline-harness.mjs';
import { classifyCloudflareDeploymentProfile } from './cloudflare-deployment-profile.mjs';
import { mintSetupCapability } from '../src/auth/setup-capability.mjs';
const VISIBLE_METHODS = new Set([
'agents.sessions.setStatus',
'assistant.threads.setStatus',
'reactions.add',
'chat.startStream',
'chat.postMessage',
]);
const CLASSIFIER_PROMPT = 'Classify one Slack interaction';
const CLEF_ANSWERS = new Set(['reply', 'work', 'react_only', 'uncertain', 'error']);
const TS_BASE = 1790000000;
const FINAL_TIMEOUT_MS = 60_000;
const STATE_RPC_SAMPLE_EVERY = 100;
const DEFAULT_RUNS_DIR = join(homedir(), '.chickpea', 'experiments', 'ttr-20261101', 'runs');
function parseFlags(argv) {
const { values } = parseArgs({
args: argv,
strict: true,
options: {
turns: { type: 'string', default: '7' },
'slack-latency-ms': { type: 'string', default: '120' },
'provider-delay-ms': { type: 'string', default: '1500' },
'state-rpc-delay-ms': { type: 'string', default: '40' },
'provider-delay-spread-ms': { type: 'string', default: '750' },
'thread-replies': { type: 'string', default: '1' },
'clef-delay-ms': { type: 'string', default: '250' },
'clef-answer': { type: 'string', default: 'reply' },
'skip-build': { type: 'boolean', default: false },
out: { type: 'string' },
'wrangler-log': { type: 'string' },
label: { type: 'string', default: '' },
},
});
const count = (name, min) => {
const value = Number(values[name]);
if (!Number.isInteger(value) || value < min) throw new Error(`--${name} must be an integer >= ${min}`);
return value;
};
if (!CLEF_ANSWERS.has(values['clef-answer'])) {
throw new Error(`--clef-answer must be one of ${[...CLEF_ANSWERS].join(', ')}`);
}
const expandHome = (path) => (path?.startsWith('~/') ? join(homedir(), path.slice(2)) : path);
const wranglerLog = expandHome(values['wrangler-log']);
return {
turns: count('turns', 1),
slackLatencyMs: count('slack-latency-ms', 0),
providerDelayMs: count('provider-delay-ms', 0),
stateRpcDelayMs: count('state-rpc-delay-ms', 0),
providerDelaySpreadMs: count('provider-delay-spread-ms', 0),
threadReplies: count('thread-replies', 0),
clefDelayMs: count('clef-delay-ms', 0),
clefAnswer: values['clef-answer'],
skipBuild: values['skip-build'],
out: resolve(expandHome(values.out) ?? join(DEFAULT_RUNS_DIR, `${new Date().toISOString().replace(/[:.]/g, '-')}.json`)),
label: values.label,
wranglerLog: wranglerLog ? resolve(wranglerLog) : undefined,
};
}
function git(...args) {
return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
}
function runtimeRecords(output, event) {
const text = output.replace(/\x1b\[[0-9;]*m/g, '');
const records = [];
for (const match of text.matchAll(new RegExp(`['"]?event['"]?\\s*:\\s*['"]${event}['"]`, 'g'))) {
const open = text.lastIndexOf('{', match.index);
const close = text.indexOf('}', match.index);
if (open < 0 || close < 0) continue;
const raw = text.slice(open, close + 1);
const fields = {};
const pairs = /['"]?([A-Za-z_]\w*)['"]?\s*:\s*('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false)/g;
for (const [, key, value] of raw.matchAll(pairs)) {
fields[key] = /^['"]/.test(value) ? value.slice(1, -1) : value === 'true' ? true : value === 'false' ? false : Number(value);
}
records.push({ raw, fields });
}
return records;
}
function maxIsolateCalls(output) {
return Math.max(0, ...runtimeRecords(output, 'state_rpc').map(({ fields }) => fields.isolateCalls ?? 0));
}
function finalWireEntry(entries, final) {
if (!final) return undefined;
const head = entries[final.index];
if (head.method !== 'chat.startStream') return head;
return entries.slice(final.index + 1)
.find((entry) => entry.method === 'chat.stopStream' && String(entry.body.ts ?? '') === head.responseTs);
}
function wireMetrics(entries, finals, t0) {
const since = (entry) => (entry ? entry.at - t0 : null);
const slack = entries.filter((entry) => entry.kind === 'slack');
const modelCalls = entries.filter((entry) => entry.kind === 'provider' && entry.method === 'chat/completions');
const clefCalls = entries.filter((entry) => entry.kind === 'provider' && entry.method === 'ai/run');
const finalEntry = finalWireEntry(entries, finals[0]);
const clear = finalEntry
? slack.filter((entry) => entry.at >= finalEntry.at && entry.method === 'assistant.threads.setStatus' &&
String(entry.body.status ?? '') === '').at(-1)
: undefined;
const byMethod = {};
for (const entry of slack) byMethod[entry.method] = (byMethod[entry.method] ?? 0) + 1;
const isClassifier = (entry) => JSON.stringify(entry.body).includes(CLASSIFIER_PROMPT);
return {
firstVisible: since(slack.find((entry) => VISIBLE_METHODS.has(entry.method))),
firstVisibleMethod: slack.find((entry) => VISIBLE_METHODS.has(entry.method))?.method ?? null,
firstModel: since(modelCalls[0]),
modelCalls: modelCalls.length,
classifierCalls: modelCalls.filter(isClassifier).length,
firstClef: since(clefCalls[0]),
clefCalls: clefCalls.length,
firstText: since(slack.find((entry) => entry.method === 'chat.startStream')),
final: since(finalEntry),
finalVia: finalEntry?.method ?? null,
clear: since(clear),
slackCalls: slack.length,
byMethod,
slackErrors: slack.filter((entry) => entry.ok === false).map((entry) => entry.method),
finalsCount: finals.length,
timeline: entries.map((entry) => ({
method: entry.kind === 'slack'
? entry.method
: `model:${entry.method === 'ai/run' ? 'clef' : isClassifier(entry) ? 'classifier' : entry.method}`,
at: entry.at - t0,
...(entry.respondedAt === undefined ? {} : { respondedAt: entry.respondedAt - t0 }),
})),
};
}
function turnLogMetrics(output, logStart) {
const slice = output.slice(logStart);
return {
turnLatency: runtimeRecords(slice, 'turn_latency').map(({ fields }) => ({
executor: fields.executor,
outcome: fields.outcome,
firstWrite: fields.firstWrite,
admissionToStartMs: fields.admissionToStartMs,
admissionToFirstWriteMs: fields.admissionToFirstWriteMs,
admissionToFinalMs: fields.admissionToFinalMs,
startToStageMs: fields.startToStageMs,
agentStageMs: fields.agentStageMs,
attemptMs: fields.attemptMs,
})),
relayAlarms: runtimeRecords(slice, 'relay_alarm').length,
stateRpc: {
maxIsolateCallsBefore: maxIsolateCalls(output.slice(0, logStart)),
maxIsolateCallsAfter: maxIsolateCalls(output),
slowCalls: runtimeRecords(slice, 'state_rpc').filter(({ fields }) => fields.slow === true).length,
},
};
}
function stats(values) {
const sorted = values.filter((value) => typeof value === 'number').sort((a, b) => a - b);
if (sorted.length === 0) return { n: 0, median: null, min: null, max: null };
const middle = Math.floor(sorted.length / 2);
const median = sorted.length % 2 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
return { n: sorted.length, median, min: sorted[0], max: sorted.at(-1) };
}
function summarize(samples) {
const summary = {};
for (const name of ['mention', 'reply']) {
const group = samples.filter((sample) => sample.case === name);
if (group.length === 0) continue;
const methods = {};
for (const sample of group) {
for (const [method, count] of Object.entries(sample.byMethod)) methods[method] = (methods[method] ?? 0) + count;
}
const rpcDeltas = group.map((sample) => sample.stateRpc.maxIsolateCallsAfter - sample.stateRpc.maxIsolateCallsBefore);
summary[name] = {
turns: group.length,
...Object.fromEntries(['final', 'firstVisible', 'firstClef', 'firstModel', 'firstText', 'ack', 'clear']
.map((metric) => [metric, stats(group.map((sample) => sample[metric]))])),
slackCallsMedian: stats(group.map((sample) => sample.slackCalls)).median,
topMethods: Object.entries(methods).sort((a, b) => b[1] - a[1]).slice(0, 8)
.map(([method, count]) => ({ method, perTurn: Number((count / group.length).toFixed(2)) })),
modelCallsMedian: stats(group.map((sample) => sample.modelCalls)).median,
clefCallsMedian: stats(group.map((sample) => sample.clefCalls)).median,
approxStateRpcsPerTurn: Math.round(rpcDeltas.reduce((sum, delta) => sum + delta, 0) / group.length),
errors: group.reduce((sum, sample) => sum + sample.errors.length, 0),
finals: group.reduce((sum, sample) => sum + sample.finalsCount, 0),
};
}
return summary;
}
const cell = (value, width = 6) => String(value ?? '-').padStart(width);
function printTable(samples) {
const columns = [
['case', 8], ['turn', 6], ['ack', 6], ['visible', 8], ['clef', 6], ['model', 6], ['text', 6], ['final', 6],
['clear', 6], ['slack', 6], ['models', 7], ['clefs', 6], ['rpc~', 6], ['finals', 7], ['err', 4], ['tl.start', 9],
['tl.first', 9], ['tl.final', 9],
];
console.log(columns.map(([name, width]) => cell(name, width)).join(' '));
for (const sample of samples) {
const latency = sample.turnLatency.find((record) => record.admissionToFinalMs !== undefined) ?? sample.turnLatency[0];
const row = [
sample.case, sample.reply === null ? `${sample.turn}` : `${sample.turn}.${sample.reply}`, sample.ack,
sample.firstVisible, sample.firstClef, sample.firstModel, sample.firstText, sample.final, sample.clear,
sample.slackCalls, sample.modelCalls, sample.clefCalls,
sample.stateRpc.maxIsolateCallsAfter - sample.stateRpc.maxIsolateCallsBefore,
sample.finalsCount, sample.errors.length, latency?.admissionToStartMs, latency?.admissionToFirstWriteMs,
latency?.admissionToFinalMs,
];
console.log(row.map((value, index) => cell(value, columns[index][1])).join(' '));
}
}
function printSummary(summary) {
for (const [name, group] of Object.entries(summary)) {
console.log(`\n${name} (${group.turns} turns), ms from the signed POST: median [min..max]`);
for (const metric of ['final', 'firstVisible', 'firstClef', 'firstModel', 'firstText', 'ack', 'clear']) {
const { median, min, max, n } = group[metric];
console.log(` ${metric.padEnd(13)} ${cell(median)} [${min ?? '-'}..${max ?? '-'}] n=${n}`);
}
console.log(` slack calls median ${group.slackCallsMedian}; model calls median ${group.modelCallsMedian}; clef calls median ${group.clefCallsMedian}`);
console.log(` top methods per turn: ${group.topMethods.map(({ method, perTurn }) => `${method}=${perTurn}`).join(', ')}`);
console.log(` state RPCs per turn ~${group.approxStateRpcsPerTurn} (approximate: 1-in-${STATE_RPC_SAMPLE_EVERY} samples)`);
console.log(` errors ${group.errors}; finals ${group.finals}`);
}
}
async function main() {
const flags = parseFlags(process.argv.slice(2));
assertNodeVersion();
const smoke = await import('./verify-cf-smoke.mjs');
const { FakeSlackBackend, STUB_REPLY_MARKER } = await loadFake();
const backend = new FakeSlackBackend(backendConfig(smoke));
let wrangler;
let cleaned;
const cleanup = () => (cleaned ??= (async () => {
if (wrangler) await smoke.stopWrangler(wrangler);
await backend.close();
rmSync(smoke.PERSIST_DIR, { recursive: true, force: true });
})());
process.once('SIGINT', () => {
console.error('\ninterrupted; stopping wrangler dev');
void cleanup().finally(() => process.exit(130));
});
try {
if (flags.skipBuild) {
if (!existsSync(smoke.CF_WRANGLER_CONFIG)) throw new Error('--skip-build needs an existing dist-cf build');
console.log('• --skip-build: reusing the existing dist-cf build');
} else {
smoke.buildCloudflareTarget('core');
}
const builtConfig = JSON.parse(readFileSync(smoke.CF_WRANGLER_CONFIG, 'utf8'));
const head = git('rev-parse', 'HEAD');
const build = {
skipped: flags.skipBuild,
profile: classifyCloudflareDeploymentProfile(builtConfig),
sourceCommit: builtConfig.vars?.CHICKPEA_SOURCE_COMMIT ?? null,
};
if (build.sourceCommit !== head) {
console.log(`• warning: dist-cf was built from ${build.sourceCommit}, HEAD is ${head}`);
}
const setup = await mintSetupCapability();
smoke.writeSmokeWranglerConfigs(setup);
smoke.applyLocalAuthMigrations();
const fake = await backend.listen(await getFreePort());
smoke.writeDevVars(fake.url);
relayClefToFake(smoke.CF_AI_SMOKE_CONFIG, fake.url);
if (flags.stateRpcDelayMs > 0) {
appendFileSync(
join(REPO_ROOT, 'dist-cf', 'chickpea', '.dev.vars'),
`CHICKPEA_PERF_STATE_RPC_DELAY_MS=${flags.stateRpcDelayMs}\n`,
);
}
const baseUrl = `http://127.0.0.1:${smoke.PORT}`;
const eventsUrl = `${baseUrl}${EVENTS_PATH}`;
wrangler = smoke.spawnWranglerDev();
if (flags.wranglerLog) {
mkdirSync(dirname(flags.wranglerLog), { recursive: true });
const stamp = (chunk) => appendFileSync(
flags.wranglerLog,
String(chunk).split('\n').map((line) => `[${Date.now()}] ${line}`).join('\n') + '\n',
);
wrangler.child.stdout.on('data', stamp);
wrangler.child.stderr.on('data', stamp);
}
console.log('• waiting for wrangler dev…');
await smoke.waitForSetupReady(wrangler, baseUrl);
await smoke.completeSlackNativeSetup(baseUrl, eventsUrl, setup, backend);
if (smoke.failures.length > 0) throw new Error(`Slack-native setup failed: ${smoke.failures.join('; ')}`);
const agentId = await publishLocalStubAgent(smoke, baseUrl);
backend.configure({
slack: { latencyMs: flags.slackLatencyMs },
provider: { delayMs: flags.providerDelayMs, delaySpreadMs: flags.providerDelaySpreadMs },
clef: { delayMs: flags.clefDelayMs, answer: flags.clefAnswer },
});
await backend.quiesce(1500, 30_000);
const setupTraffic = [...new Set(backend.wireLog.map((entry) => entry.method))];
console.log(`• ${agentId} on local-stub/smoke-model; setup traffic settled (${backend.wireLog.length} calls)`);
const runOutputStart = wrangler.getOutput().length;
const measure = async ({ caseName, turn, reply, payload, threadTs }) => {
if (wrangler.child.exitCode !== null) throw new Error(`wrangler dev exited (${wrangler.child.exitCode})`);
backend.reset();
const logStart = wrangler.getOutput().length;
const t0 = Date.now();
const admission = await postSignedEvent(eventsUrl, payload);
const ack = Date.now() - t0;
await smoke.waitForFinalCount(backend, 1, FINAL_TIMEOUT_MS);
await backend.quiesce(400, 8000);
const entries = [...backend.wireLog];
const finals = backend.finals();
const metrics = wireMetrics(entries, finals, t0);
const errors = [
...(admission.status === 200 || admission.status === 202 ? [] : [`ack HTTP ${admission.status}`]),
...(finals.length === 1 ? [] : [`${finals.length} finals`]),
...(finals.length > 0 && (finals[0].channel !== smoke.CHANNEL || finals[0].threadTs !== threadTs)
? [`final landed in ${finals[0].channel}/${finals[0].threadTs}`] : []),
...(finals.length > 0 && !finals[0].text.includes(STUB_REPLY_MARKER) ? ['final lacks the stub reply'] : []),
...metrics.slackErrors.map((method) => `slack ${method} ok:false`),
];
return {
case: caseName,
turn,
reply,
eventId: payload.event_id,
ts: payload.event.ts,
ack,
...metrics,
...turnLogMetrics(wrangler.getOutput(), logStart),
errors,
};
};
const samples = [];
for (let turn = 0; turn < flags.turns; turn += 1) {
const threadTs = `${TS_BASE + turn * 100}.000100`;
const mention = smoke.mentionEvent(`Ev_PERF_${turn}_MENTION`);
samples.push(await measure({
caseName: 'mention',
turn,
reply: null,
threadTs,
payload: {
...mention,
event_time: TS_BASE + turn * 100,
event: {
...mention.event,
text: `${smoke.defaultAgentMention} what is the capital of France?`,
ts: threadTs,
event_ts: threadTs,
},
},
}));
for (let reply = 0; reply < flags.threadReplies; reply += 1) {
const ts = `${TS_BASE + turn * 100 + reply + 1}.000100`;
const template = smoke.threadReplyEvent();
samples.push(await measure({
caseName: 'reply',
turn,
reply,
threadTs,
payload: {
...template,
event_id: `Ev_PERF_${turn}_REPLY_${reply}`,
event_time: TS_BASE + turn * 100 + reply + 1,
event: {
...template.event,
text: `${smoke.defaultAgentMention} and its population?`,
ts,
event_ts: ts,
thread_ts: threadTs,
},
},
}));
}
}
const runOutput = wrangler.getOutput().slice(runOutputStart);
const requested = flags.turns * (1 + flags.threadReplies);
const completed = samples.filter((sample) => sample.finalsCount === 1 && sample.errors.length === 0).length;
const errors = samples.reduce((sum, sample) => sum + sample.errors.length, 0);
const rpcSpan = maxIsolateCalls(wrangler.getOutput()) - maxIsolateCalls(wrangler.getOutput().slice(0, runOutputStart));
const summary = summarize(samples);
const report = {
schema: 'chickpea-perf-slack-turn/v1',
label: flags.label,
finishedAt: new Date().toISOString(),
git: { head, dirty: git('status', '--porcelain').length > 0 },
build,
flags,
host: { node: process.version, platform: process.platform, cpus: cpus().length, loadavg: loadavg() },
setupTraffic,
summary,
stateRpc: {
approxPerTurn: Math.round(rpcSpan / samples.length),
note: `max isolateCalls across the measured turns divided by turns; state_rpc logs 1 call in ${STATE_RPC_SAMPLE_EVERY}, so each turn's delta is +-${STATE_RPC_SAMPLE_EVERY}`,
},
rawSamples: {
turnLatency: runtimeRecords(runOutput, 'turn_latency')[0]?.raw ?? null,
stateRpc: runtimeRecords(runOutput, 'state_rpc')[0]?.raw ?? null,
relayAlarm: runtimeRecords(runOutput, 'relay_alarm')[0]?.raw ?? null,
},
turns: samples,
errors,
work: { completed, requested },
};
mkdirSync(dirname(flags.out), { recursive: true });
writeFileSync(flags.out, `${JSON.stringify(report, null, 2)}\n`);
console.log(`\nslack latency ${flags.slackLatencyMs} ms, provider delay ${flags.providerDelayMs}±${Math.floor(flags.providerDelaySpreadMs / 2)} ms, clef ${flags.clefAnswer} after ${flags.clefDelayMs} ms, state rpc delay ${flags.stateRpcDelayMs} ms, build ${build.profile}@${build.sourceCommit}`);
printTable(samples);
printSummary(summary);
console.log(`\nstate RPCs per turn ~${report.stateRpc.approxPerTurn} (${report.stateRpc.note})`);
for (const [name, raw] of Object.entries(report.rawSamples)) console.log(`raw ${name}: ${raw ?? 'none seen'}`);
for (const sample of samples.filter((entry) => entry.errors.length > 0)) {
console.log(`errors in ${sample.case} ${sample.turn}${sample.reply === null ? '' : `.${sample.reply}`}: ${sample.errors.join('; ')}`);
}
console.log(`report ${flags.out}`);
console.log(`errors=${errors} work=${completed}/${requested}`);
if (samples.some((sample) => sample.finalsCount !== 1)) process.exitCode = 1;
} catch (error) {
console.error(`\nFAIL perf-slack-turn: ${error instanceof Error ? error.message : String(error)}`);
if (wrangler) console.error(`\n--- wrangler dev output (tail) ---\n${wrangler.getOutput().split('\n').slice(-60).join('\n')}`);
process.exitCode = 1;
} finally {
await cleanup();
}
}
/** The AI stub relays Clef to the fake backend, which owns the answer and its delay and logs the call. */
function relayClefToFake(configPath, fakeUrl) {
const config = JSON.parse(readFileSync(configPath, 'utf8'));
config.vars = { ...(config.vars ?? {}), AI_SMOKE_CLEF_URL: fakeUrl };
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
function backendConfig(smoke) {
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
return {
slack: {
identity: { appId: smoke.APP_ID, botUserId: smoke.BOT_USER_ID, teamId: smoke.WORKSPACE, teamName: 'Perf Workspace' },
oauth: {
appId: smoke.APP_ID,
clientId: smoke.OAUTH_CLIENT_ID,
clientSecret: smoke.OAUTH_CLIENT_SECRET,
configurationToken: smoke.OAUTH_CONFIGURATION_TOKEN,
signingSecret: smoke.SIGNING_SECRET,
botToken: smoke.OAUTH_BOT_TOKEN,
installerUserId: smoke.OWNER_USER_ID,
privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }),
publicJwk: { ...publicKey.export({ format: 'jwk' }), kid: 'perf-rs256', alg: 'RS256', use: 'sig' },
},
channels: [{ id: smoke.CHANNEL, name: 'perf-mentions', isMember: true, teamId: smoke.WORKSPACE }],
channelMembers: { [smoke.CHANNEL]: [smoke.OWNER_USER_ID, smoke.BOT_USER_ID] },
workspaceUsers: [
{ id: smoke.OWNER_USER_ID, teamId: smoke.WORKSPACE },
{ id: smoke.BOT_USER_ID, teamId: smoke.WORKSPACE, isBot: true, isAppUser: true },
],
directMessages: { [smoke.OWNER_USER_ID]: smoke.ONBOARDING_DM_CHANNEL },
},
};
}
async function publishLocalStubAgent(smoke, baseUrl) {
const expect = (result, status, what) => {
if (result.status !== status) throw new Error(`${what} failed (HTTP ${result.status} ${JSON.stringify(result.body)})`);
return result.body;
};
expect(await smoke.adminFetch(baseUrl, '/admin/api/model-catalog/mode', {
method: 'PUT',
body: JSON.stringify({ mode: 'bundled' }),
}), 200, 'pinning the bundled model catalog');
const created = expect(await smoke.adminFetch(baseUrl, '/admin/api/agents', {
method: 'POST',
body: JSON.stringify({
id: 'agent_default',
name: 'Perf',
instructions: 'Answer briefly.',
enabled: true,
model: 'cloudflare/@cf/zai-org/glm-4.7-flash',
}),
}), 201, 'creating the Agent');
const granted = expect(await smoke.adminFetch(baseUrl, '/admin/api/agents/agent_default/channels', {
method: 'POST',
body: JSON.stringify({ workspaceId: smoke.WORKSPACE, channelId: smoke.CHANNEL }),
}), 201, 'granting the channel');
const userGroupId = granted?.agent?.slackPresence?.userGroupId;
if (typeof userGroupId !== 'string' || !/^S[A-Z0-9]+$/.test(userGroupId)) {
throw new Error('the published Agent has no Slack handle');
}
smoke.setDefaultAgentMention(`<!subteam^${userGroupId}>`);
expect(await smoke.adminFetch(baseUrl, '/admin/api/agents/agent_default', {
method: 'PATCH',
body: JSON.stringify({
expectedRevision: granted.agent.revision ?? created.agent.revision,
model: 'local-stub/smoke-model',
}),
}), 200, 'pinning the local stub model');
return created.agent.id;
}
await main();