Skip to content

Commit 0813be2

Browse files
committed
ci: revive the assistant LLM smoke (Vercel bypass + v2 scenarios)
Every LLM smoke run has been red since mid-July for two stacked reasons. Access: the assistant domains sit behind the project firewall — Bot Protection challenges non-browser sources and the assistant-origin-guard rule denies POSTs without an app Origin — so the script's plain fetches died with 403/429 before reaching the service. The smoke now sends x-vercel-protection-bypass, matched by the assistant-smoke-bypass firewall rule (first in the chain, action Bypass); the workflow loads the secret from 1Password as VERCEL_AUTOMATION_BYPASS_SECRET with continue-on-error, so a missing vault item degrades to today's failure instead of a new one. Scenarios: the 403 masked that the script still tested the retired deterministic pipeline (POST /issues/preview, POST /issues) — those routes are gone since the v2 assistant-ui migration, and their 404 text surfaced as a JSON parse error. The scenarios now exercise the real v2 flow: the agent drafts a createLinearTicket tool call, the stream pauses on a tool-approval-request, and an approval resume (the history re-sent ending on the assistant message whose tool part carries approval) executes the creation. Off-topic asserts no draft is offered; the bug report approves the draft and checks the created ticket's identifier/url; the feedback scenario checks the draft intent without approving, so it creates nothing. A one-turn clarifying question from the model is tolerated before failing. Verified against dev: health and draft/approval chunks live; the resume wire format mirrors the chat route's unit-test fixture. The full creation path needs a fresh IP (the dev per-IP new-session budget was exhausted while testing) — first CI run will confirm.
1 parent a24c194 commit 0813be2

2 files changed

Lines changed: 130 additions & 88 deletions

File tree

.github/workflows/assistant-llm-smoke.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,21 @@ jobs:
3838
with:
3939
node-version-file: .nvmrc
4040

41+
# The assistant domains sit behind Vercel's bot challenge; the smoke's plain fetches pass it
42+
# with the "Protection Bypass for Automation" secret of the assistant Vercel project.
43+
# continue-on-error: a missing vault item degrades to today's behavior (403 on every
44+
# scenario) instead of failing the job earlier with a different error.
45+
- name: Load secrets
46+
id: secrets
47+
continue-on-error: true
48+
uses: 1password/load-secrets-action@eb2efd0703da22a93c467f2d1ffbb6826c11e19c # v4.0.0 (https://github.com/1Password/load-secrets-action/releases/tag/v4.0.0)
49+
with:
50+
export-env: false
51+
env:
52+
OP_SERVICE_ACCOUNT_TOKEN: '${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}'
53+
VERCEL_AUTOMATION_BYPASS_SECRET: op://kv_assistant_infra/VERCEL_AUTOMATION_BYPASS_SECRET/credential
54+
4155
- name: Run LLM smoke scenarios
56+
env:
57+
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ steps.secrets.outputs.VERCEL_AUTOMATION_BYPASS_SECRET || '' }}
4258
run: node apps/assistant/scripts/llmSmoke.mjs "${{ inputs.url || 'https://dev.assistant.aragon.org' }}"

apps/assistant/scripts/llmSmoke.mjs

Lines changed: 114 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
// (real models, real Linear team of that environment). Non-blocking by design — it validates that
33
// the prompts, the pipeline and the Linear integration still work end to end, not unit behavior.
44
//
5+
// Tickets are created the way the widget creates them: the agent drafts a createLinearTicket tool
6+
// call, the stream pauses on a tool approval request, and a resume request carrying the approved
7+
// tool part executes the creation.
8+
//
59
// Usage: node scripts/llmSmoke.mjs [baseUrl]
610
// The base URL defaults to ASSISTANT_SMOKE_URL or https://dev.assistant.aragon.org.
711

@@ -15,6 +19,16 @@ const baseUrl = (
1519

1620
const appContext = { route: '/llm-smoke', appVersion: 'llm-smoke' };
1721

22+
// The assistant domains sit behind Vercel's bot challenge, which a plain fetch cannot solve;
23+
// automation passes it with the secret checked by the assistant-smoke-bypass firewall rule.
24+
// Without the secret the header is omitted and every scenario fails with the challenge's 403.
25+
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
26+
const bypassHeaders = bypassSecret
27+
? { 'x-vercel-protection-bypass': bypassSecret }
28+
: {};
29+
30+
const createTicketToolName = 'createLinearTicket';
31+
1832
const failures = [];
1933

2034
const logStep = (message) => {
@@ -43,10 +57,13 @@ const chunksToText = (chunks) =>
4357
.map((chunk) => chunk.delta)
4458
.join('');
4559

60+
// One request against /chat, digested into what the scenarios assert on: the assembled reply
61+
// text, the createLinearTicket draft (input + approval request) when the agent produced one,
62+
// and the executed tool output on a resume.
4663
const sendChatTurn = async (sessionId, messages) => {
4764
const response = await fetch(`${baseUrl}/chat`, {
4865
method: 'POST',
49-
headers: { 'content-type': 'application/json' },
66+
headers: { 'content-type': 'application/json', ...bypassHeaders },
5067
body: JSON.stringify({ sessionId, messages, appContext }),
5168
});
5269

@@ -58,28 +75,78 @@ const sendChatTurn = async (sessionId, messages) => {
5875

5976
const chunks = await readStream(response);
6077
const text = chunksToText(chunks);
78+
const messageId = chunks.find((chunk) => chunk.type === 'start')?.messageId;
79+
const draftInput = chunks.find(
80+
(chunk) =>
81+
chunk.type === 'tool-input-available' &&
82+
chunk.toolName === createTicketToolName,
83+
);
84+
const approvalRequest = chunks.find(
85+
(chunk) =>
86+
chunk.type === 'tool-approval-request' &&
87+
chunk.toolCallId === draftInput?.toolCallId,
88+
);
89+
const toolOutput = chunks.find(
90+
(chunk) => chunk.type === 'tool-output-available',
91+
);
92+
93+
return { approvalRequest, draftInput, messageId, text, toolOutput };
94+
};
95+
96+
// Drives the conversation until the agent drafts a ticket, tolerating one clarifying question
97+
// (a real model sometimes asks for details before drafting). Returns the transcript so far and
98+
// the draft; throws when no draft appeared within the allowed turns.
99+
const converseUntilDraft = async (sessionId, openingText, followUpText) => {
100+
const messages = [buildUserMessage(openingText)];
101+
let turn = await sendChatTurn(sessionId, messages);
61102

62-
return {
63-
messages: [
64-
...messages,
103+
if (!(turn.draftInput && turn.approvalRequest)) {
104+
if (turn.text.length === 0) {
105+
throw new Error('expected a reply, got an empty stream');
106+
}
107+
logStep('no draft on the first turn, answering the follow-up');
108+
messages.push(
65109
{
66-
id: randomUUID(),
110+
id: turn.messageId ?? randomUUID(),
67111
role: 'assistant',
68-
parts: [{ type: 'text', text }],
112+
parts: [{ type: 'text', text: turn.text }],
69113
},
70-
],
71-
text,
72-
};
114+
buildUserMessage(followUpText),
115+
);
116+
turn = await sendChatTurn(sessionId, messages);
117+
}
118+
119+
if (!(turn.draftInput && turn.approvalRequest)) {
120+
throw new Error(
121+
`expected a ${createTicketToolName} draft with an approval request, got: ${turn.text.slice(0, 200)}`,
122+
);
123+
}
124+
125+
return { messages, turn };
73126
};
74127

75-
const postJson = async (path, sessionId, messages) => {
76-
const response = await fetch(`${baseUrl}${path}`, {
77-
method: 'POST',
78-
headers: { 'content-type': 'application/json' },
79-
body: JSON.stringify({ sessionId, messages, appContext }),
80-
});
128+
// Rebuilds the widget's approval resume: the history is re-sent ending on the assistant message
129+
// whose tool part carries the user's approval; executing that part is what creates the ticket.
130+
const approveDraft = async (sessionId, messages, turn) => {
131+
const assistantMessage = {
132+
id: turn.messageId ?? randomUUID(),
133+
role: 'assistant',
134+
parts: [
135+
{ type: 'text', text: turn.text },
136+
{
137+
type: `tool-${createTicketToolName}`,
138+
toolCallId: turn.draftInput.toolCallId,
139+
state: 'approval-responded',
140+
input: turn.draftInput.input,
141+
approval: {
142+
id: turn.approvalRequest.approvalId,
143+
approved: true,
144+
},
145+
},
146+
],
147+
};
81148

82-
return { status: response.status, body: await response.json() };
149+
return sendChatTurn(sessionId, [...messages, assistantMessage]);
83150
};
84151

85152
const runScenario = async (name, scenario) => {
@@ -94,7 +161,9 @@ const runScenario = async (name, scenario) => {
94161
};
95162

96163
await runScenario('health', async () => {
97-
const response = await fetch(`${baseUrl}/health`);
164+
const response = await fetch(`${baseUrl}/health`, {
165+
headers: bypassHeaders,
166+
});
98167
const body = await response.json();
99168
if (!response.ok || body.status !== 'ok') {
100169
throw new Error(`unexpected health response: ${JSON.stringify(body)}`);
@@ -114,103 +183,60 @@ await runScenario(
114183
throw new Error('expected a refusal message, got an empty stream');
115184
}
116185

117-
// The transcript carries no support request: the preview must come back unclear and
118-
// creation (without a stored snapshot) must be refused.
119-
const preview = await postJson(
120-
'/issues/preview',
121-
sessionId,
122-
turn.messages,
123-
);
124-
if (preview.status !== 200 || preview.body.status !== 'unclear') {
125-
throw new Error(
126-
`expected an unclear preview, got ${preview.status}: ${JSON.stringify(preview.body)}`,
127-
);
128-
}
129-
130-
const issue = await postJson('/issues', sessionId, turn.messages);
131-
if (issue.status !== 422) {
186+
// The transcript carries no support request: the agent must decline in text and never
187+
// draft a ticket for approval.
188+
if (turn.draftInput || turn.approvalRequest) {
132189
throw new Error(
133-
`expected 422 for an off-topic transcript, got ${issue.status}`,
190+
`expected no ticket draft for an off-topic transcript, got: ${JSON.stringify(turn.draftInput?.input)}`,
134191
);
135192
}
136193
},
137194
);
138195

139196
await runScenario(
140-
'bug report previews a reviewable ticket and creates it',
197+
'bug report drafts a reviewable ticket and creates it on approval',
141198
async () => {
142199
const sessionId = randomUUID();
143-
const turn = await sendChatTurn(sessionId, [
144-
buildUserMessage(
145-
'I found a bug in the app: the proposal page crashes with a blank screen whenever I open any proposal on ethereum mainnet. It started today and reproduces every time I click a proposal in the list. My email is llm-smoke@aragon.org.',
146-
),
147-
]);
148-
if (turn.text.length === 0) {
149-
throw new Error('expected a reply, got an empty stream');
150-
}
151-
152-
const preview = await postJson(
153-
'/issues/preview',
200+
const { messages, turn } = await converseUntilDraft(
154201
sessionId,
155-
turn.messages,
156-
);
157-
if (
158-
preview.status !== 200 ||
159-
preview.body.status !== 'ready' ||
160-
!preview.body.summary
161-
) {
162-
throw new Error(
163-
`expected a ready preview with a summary, got ${preview.status}: ${JSON.stringify(preview.body)}`,
164-
);
165-
}
166-
logStep(
167-
`preview: intent=${preview.body.intent} summary=${preview.body.summary}`,
202+
'I found a bug in the app: the proposal page crashes with a blank screen whenever I open any proposal on ethereum mainnet. It started today and reproduces every time I click a proposal in the list. My email is llm-smoke@aragon.org.',
203+
'It happens on every proposal, Chrome on desktop, no console access. Please just file the ticket with what we have.',
168204
);
169205

170-
const issue = await postJson('/issues', sessionId, turn.messages);
171-
if (issue.status !== 201) {
172-
throw new Error(
173-
`expected 201 from POST /issues, got ${issue.status}: ${JSON.stringify(issue.body)}`,
174-
);
175-
}
176-
if (!issue.body.identifier || !issue.body.url) {
206+
const { input } = turn.draftInput;
207+
if (!input.title || !input.description) {
177208
throw new Error(
178-
`issue response misses identifier/url: ${JSON.stringify(issue.body)}`,
209+
`draft misses title/description: ${JSON.stringify(input)}`,
179210
);
180211
}
181-
logStep(`created ${issue.body.identifier} (${issue.body.url})`);
212+
logStep(`draft: intent=${input.intent} title=${input.title}`);
182213

183-
// Retrying the same session must be idempotent and return the same issue.
184-
const retry = await postJson('/issues', sessionId, turn.messages);
185-
if (retry.status !== 200 || retry.body.issueId !== issue.body.issueId) {
214+
const resume = await approveDraft(sessionId, messages, turn);
215+
const output = resume.toolOutput?.output;
216+
if (!output?.identifier || !output?.url) {
186217
throw new Error(
187-
`expected an idempotent retry, got ${retry.status}: ${JSON.stringify(retry.body)}`,
218+
`expected the executed tool output with identifier/url, got: ${JSON.stringify(resume.toolOutput ?? resume.text.slice(0, 200))}`,
188219
);
189220
}
221+
if (resume.text.length === 0) {
222+
throw new Error('expected a closing message after the creation');
223+
}
224+
logStep(`created ${output.identifier} (${output.url})`);
190225
},
191226
);
192227

193228
await runScenario('feedback intent is understood', async () => {
194229
const sessionId = randomUUID();
195-
const turn = await sendChatTurn(sessionId, [
196-
buildUserMessage(
197-
'Just some feedback: I love the new governance designer, but the save button is hard to find on small screens.',
198-
),
199-
]);
200-
201-
if (turn.text.length === 0) {
202-
throw new Error('expected a reply, got an empty stream');
203-
}
230+
// Draft only — the approval is never sent, so no ticket is created.
231+
const { turn } = await converseUntilDraft(
232+
sessionId,
233+
'Just some feedback: I love the new governance designer, but the save button is hard to find on small screens. Please pass it on to the team.',
234+
'Nothing more to add — please just file the feedback as described.',
235+
);
204236

205-
const preview = await postJson('/issues/preview', sessionId, turn.messages);
206-
if (preview.status !== 200 || preview.body.status !== 'ready') {
207-
throw new Error(
208-
`expected a ready preview, got ${preview.status}: ${JSON.stringify(preview.body)}`,
209-
);
210-
}
211-
if (preview.body.intent !== 'feedback') {
237+
if (turn.draftInput.input.intent !== 'feedback') {
212238
throw new Error(
213-
`expected intent 'feedback', got '${String(preview.body.intent)}'`,
239+
`expected intent 'feedback', got '${String(turn.draftInput.input.intent)}'`,
214240
);
215241
}
216242
});

0 commit comments

Comments
 (0)