Lint-Schulden ausserhalb von src/components — und der Auftrag hat zwei
blinde Flecken freigelegt, die groesser sind als die Schulden selbst.
`scripts/**` hatte NULL Regeln. Der TypeScript-Block greift *.ts/tsx, der
mobile-client-Block greift mobile-client/**/*.js, und .mjs faellt zwischen
beide. `npx eslint scripts` listete zehn Dateien und meldete nichts — ein
gruener Lauf ueber eine ungepruefte Zone, was schlimmer ist als ein roter:
er behauptet Deckung. Mit Regeln kamen 42 Meldungen zum Vorschein, davon
11 `no-undef` — in einem Node-Modul kein Stil, sondern ein ReferenceError
zur Laufzeit. Alle 42 bezahlt.
`e2e/` stand in KEINEM tsconfig-Projekt. 17 Playwright-Dateien plus
playwright.config.ts hat `npm run typecheck` nie gelesen. Neues
tsconfig.e2e.json, referenziert aus tsconfig.json: neu sichtbare Fehler 0
bei 18 zusaetzlich geprueften Dateien. Die Zone war nicht kaputt, sie war
unbeobachtet.
Ein echter Fehler im ganzen Haufen, und er stand hinter der Konvention:
`builtin-tools.ts:506` faengt `fallbackErr`, wirft ihn weg und meldet `e`,
den Fehler des ERSTEN Weges. Im Browser-/Dev-Modus ist `web_fetch` dort
grundsaetzlich unerreichbar, `e` also auf jedem Fehlschlag derselbe Satz —
DNS, CORS, 404, Timeout sehen fuer das Modell identisch aus. Dreissig
Zeilen hoeher schreibt dieselbe Datei die Regel selbst hin. Mit Test.
Die "10 Parse-Fehler" waren keine: es sind tote eslint-disable-Direktiven,
die eslints JSON mit `ruleId: null` kodiert wie einen Parse-Fehler. Sechs
davon unterdruecken Regeln, die in keiner Config je eingeschaltet waren —
jemand glaubte an ein Gate, das nie existierte. Jetzt `error` statt `warn`.
Regel-Lockerungen, jede an BEIDEN Seiten der Grenze gemessen (der Test
laesst die echte eslint.config.js ueber die Node-API laufen, kein Nachbau):
`no-fallthrough` allowEmptyCase (ein leeres case-Label kann nirgends
hineinfallen — der Kommentar kostet den Fehler, nicht der Code),
`no-unused-vars` mit `^_` (von 43 Meldungen ueberlebt genau EINE, und die
ist der echte Fehler oben), `react-refresh/only-export-components` aus
(23 der 29 Exporte werden von Tests importiert — das ist die Naht, an der
dieses Projekt Logik aus Komponenten zieht; die Alternative waere eine
Namensliste, also genau der zweite Pflegeweg, der hier der Grundfehler ist).
eslint . : 161 Fehler + 10 Warnungen -> 60 Fehler + 0 Warnungen.
Offen fuer das scharfe Gate: 45 in src/components (zweiter Durchgang),
15 in vite.config.ts (ZB-7).
Geprueft im isolierten Worktree auf 70489a23 (nur diese 33 Dateien, und
node_modules diesmal KOPIERT statt verlinkt — Lehre aus dem Zwischenfall):
tsc -b --force 0 (jetzt inkl. e2e) · vite build 0 · madge 0 Zyklen ·
vitest 7851 gruen / 0 rot (= Basis 7809 + 42 aus dem Token-Paket) ·
eslint 60/0, Verteilung nachgezaehlt und deckungsgleich.
Zwei eigene Sonden auf die Kernbehauptung, dass die Zonen jetzt echt
gedeckt sind:
undefinierter Bezeichner in scripts/ => `no-undef` schlaegt an
frische tote Unterdrueckung in src/lib => als FEHLER gemeldet, nicht als
Warnung durchgewinkt
345 lines
18 KiB
JavaScript
345 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Real agent E2E driven through the Remote bridge. Replicates exactly
|
||
* what mobile_landing()'s runToolLoop does:
|
||
* 1. Auth via /remote-api/auth (passcode → JWT).
|
||
* 2. Build messages: system + user.
|
||
* 3. POST to /api/chat (proxied to Ollama) with native `tools` array.
|
||
* 4. Ollama returns tool_calls; we execute each via /remote-api/agent-tool.
|
||
* 5. Push observations back as tool messages, loop until done.
|
||
*
|
||
* Two modes:
|
||
* --mode agent → AGENT_PROMPT (autonomous file-writer)
|
||
* --mode codex → CODEX_PROMPT (autonomous coding agent)
|
||
*
|
||
* Usage:
|
||
* node scripts/remote-agent-e2e.mjs --base http://<ip>:<port> --code <6-digit> --mode agent --model gemma4:e4b
|
||
*/
|
||
|
||
import { argv, exit } from 'node:process'
|
||
|
||
function parseArgs() {
|
||
const out = {
|
||
base: null, code: null, model: 'gemma4:e4b',
|
||
mode: 'agent', chatId: 'agent-e2e',
|
||
task: null,
|
||
}
|
||
for (let i = 2; i < argv.length; i++) {
|
||
const a = argv[i]
|
||
if (a === '--base') out.base = argv[++i]
|
||
else if (a === '--code') out.code = argv[++i]
|
||
else if (a === '--model') out.model = argv[++i]
|
||
else if (a === '--mode') out.mode = argv[++i]
|
||
else if (a === '--chat-id') out.chatId = argv[++i]
|
||
else if (a === '--task') out.task = argv[++i]
|
||
}
|
||
if (!out.base || !out.code) { console.error('Usage: --base <url> --code <6-digit> [--mode agent|codex]'); exit(2) }
|
||
if (!['agent', 'codex'].includes(out.mode)) { console.error('--mode must be agent or codex'); exit(2) }
|
||
return out
|
||
}
|
||
|
||
// ── Tool definitions — full 13-tool AGENT_TOOLS array from remote.rs::mobile_landing()
|
||
// (parity with src/api/mcp/builtin-tools.ts). 10 of these are exposed to Codex too. ──
|
||
const AGENT_TOOLS = [
|
||
{ name: 'web_search', description: 'Search the web via the configured provider. Returns ranked {title, url, snippet}.',
|
||
parameters: [
|
||
{ name: 'query', type: 'string', description: 'The search query', required: true },
|
||
{ name: 'maxResults', type: 'number', description: 'Max results (default 5)', required: false },
|
||
] },
|
||
{ name: 'web_fetch', description: 'Fetch a single URL and return readable text up to 24000 chars.',
|
||
parameters: [
|
||
{ name: 'url', type: 'string', description: 'Full URL with http(s)://', required: true },
|
||
] },
|
||
{ name: 'file_read', description: 'Read a UTF-8 text file. Returns full contents.',
|
||
parameters: [
|
||
{ name: 'path', type: 'string', description: 'Path (relative or absolute)', required: true },
|
||
] },
|
||
{ name: 'file_write', description: 'Write a UTF-8 text file. Creates parent directories automatically. Relative paths resolve to the chat workspace folder.',
|
||
parameters: [
|
||
{ name: 'path', type: 'string', description: 'Target file path', required: true },
|
||
{ name: 'content', type: 'string', description: 'File contents', required: true },
|
||
] },
|
||
{ name: 'file_list', description: 'List directory contents. Set recursive:true for full tree.',
|
||
parameters: [
|
||
{ name: 'path', type: 'string', description: 'Directory path', required: true },
|
||
{ name: 'recursive', type: 'boolean', description: 'Include all subdirectories', required: false },
|
||
] },
|
||
{ name: 'file_search', description: 'Regex grep across files in a directory.',
|
||
parameters: [
|
||
{ name: 'path', type: 'string', description: 'Directory to search', required: true },
|
||
{ name: 'query', type: 'string', description: 'Regex pattern', required: true },
|
||
] },
|
||
{ name: 'shell_execute', description: 'Run a shell command. PowerShell on Windows. cwd defaults to chat workspace.',
|
||
parameters: [
|
||
{ name: 'command', type: 'string', description: 'Shell command', required: true },
|
||
] },
|
||
{ name: 'code_execute', description: 'Run a Python script. Runs from the chat workspace.',
|
||
parameters: [
|
||
{ name: 'code', type: 'string', description: 'Python source code', required: true },
|
||
] },
|
||
{ name: 'system_info', description: 'Return OS, architecture, hostname, RAM, CPU count.',
|
||
parameters: [] },
|
||
{ name: 'process_list', description: 'List top 30 running processes by memory.',
|
||
parameters: [] },
|
||
{ name: 'screenshot', description: 'Capture the primary display as a base64 PNG.',
|
||
parameters: [] },
|
||
{ name: 'image_generate', description: 'Generate an image from text via local ComfyUI. (Returns graceful error in Remote.)',
|
||
parameters: [
|
||
{ name: 'prompt', type: 'string', description: 'Positive text description', required: true },
|
||
] },
|
||
{ name: 'get_current_time', description: 'Return current local date, time, timezone.',
|
||
parameters: [] },
|
||
]
|
||
|
||
const CODEX_TOOL_NAMES = ['file_read','file_write','file_list','file_search','shell_execute','code_execute','system_info','get_current_time','web_search','web_fetch']
|
||
const AGENT_TOOL_NAMES = AGENT_TOOLS.map(t => t.name)
|
||
|
||
function buildToolDefs(tools) {
|
||
return tools.map(t => {
|
||
const props = {}
|
||
const req = []
|
||
for (const p of t.parameters || []) {
|
||
props[p.name] = { type: p.type, description: p.description }
|
||
if (p.required) req.push(p.name)
|
||
}
|
||
return {
|
||
type: 'function',
|
||
function: { name: t.name, description: t.description,
|
||
parameters: { type: 'object', properties: props, required: req } },
|
||
}
|
||
})
|
||
}
|
||
|
||
const AGENT_PROMPT = 'You are an autonomous AI agent inside Locally Uncensored. You execute tasks end-to-end via tools — you do NOT just describe what to do.\n\nAUTONOMY CONTRACT:\n- When asked to BUILD / CREATE / WRITE something, execute it via tools.\n- NEVER produce a code block followed by "save this as X". Execute it via file_write.\n- NEVER say "Now I will create X" as plain prose and stop. Do the next step right now as a tool call.\n\nFile rules:\n- file_write AUTOMATICALLY creates missing parent directories — do NOT shell out to mkdir / New-Item.\n- Relative paths resolve to the current chat workspace folder. Use relative paths.\n\nBe concise. All the work happens in tool calls.'
|
||
|
||
const CODEX_PROMPT = 'You are Codex, an autonomous coding agent. Execute coding tasks end-to-end by reading files, writing code, and running shell commands. You MUST use tools.\n\nAUTONOMY CONTRACT:\n- COMPLETE multi-step tasks without prompting between steps.\n- NEVER say "Now I will create X" and stop. Execute the next step.\n- Chain ALL N steps in one session as concrete tool calls.\n\nWorkflow: explore → plan → implement (file_write) — chain ALL writes without stopping → final 1-3 sentence summary.\n\nFile & directory rules:\n- file_write AUTOMATICALLY creates missing parent directories. Never `mkdir` / `New-Item -ItemType Directory`.\n- All relative paths resolve to the current chat workspace.\n- shell_execute runs inside the workspace folder by default.\n- After 2-3 failures of the same approach, switch strategy.\n\nBe concise. Respond in the same language the user uses.'
|
||
|
||
// Tasks force EVERY tool in the relevant set to be exercised in one run
|
||
// ("überall alle testen pflicht ALLE IMMER"). Agent has 13, Codex 10.
|
||
const TASK_AGENT = `You are running an agent compliance test. Build a tiny static greeting site inside folder "gemtest" AND prove every Remote tool works.
|
||
|
||
You MUST call ALL 13 tools below at least once during this run. Do NOT emit a final answer until every tool listed has been used:
|
||
1. get_current_time → record start time
|
||
2. system_info → capture host facts
|
||
3. process_list → snapshot running processes (just call it once)
|
||
4. file_write → gemtest/index.html with <h1 id="hero">Hello LU E2E</h1> and <link rel="stylesheet" href="style.css">
|
||
5. file_write → gemtest/style.css (body sans-serif on dark bg, #hero = 32px)
|
||
6. file_write → gemtest/MARKER.txt containing exactly "MAGIC_LU_AGENT_E2E"
|
||
7. file_search → grep gemtest/ for "MAGIC_LU_AGENT_E2E" (must hit MARKER.txt)
|
||
8. file_list → list gemtest/ with recursive=true
|
||
9. file_read → read gemtest/index.html back
|
||
10. code_execute → Python that lists gemtest/ and writes gemtest/manifest.json
|
||
11. shell_execute → "Get-ChildItem gemtest" or "ls gemtest" to confirm layout
|
||
12. screenshot → call screenshot once (do not embed result, just confirm tool works)
|
||
13. web_search → query "locally uncensored github" with maxResults 1
|
||
14. web_fetch → fetch "https://example.com" (must succeed)
|
||
15. image_generate → prompt "test" (Remote returns a graceful "desktop only" error — that still counts as "tool reachable")
|
||
|
||
After every required tool was called, write a 1-2 sentence final summary listing which tools you used. DO NOT SKIP ANY TOOL.`
|
||
|
||
const TASK_CODEX = `You are running a Codex compliance test. Build a Python utility "wordcount.py" in folder "wctest" AND prove every Codex tool works.
|
||
|
||
You MUST call ALL 10 Codex tools below at least once during this run. Do NOT emit a final answer until every tool below has been used:
|
||
1. get_current_time → record start time
|
||
2. system_info → capture host facts
|
||
3. file_write → wctest/wordcount.py (reads argv[1], prints JSON {"file","lines","words","bytes"})
|
||
4. file_write → wctest/sample.txt with exactly 3 lines, including "MAGIC_LU_CODEX_E2E"
|
||
5. file_search → grep wctest/ for "MAGIC_LU_CODEX_E2E" (must hit sample.txt)
|
||
6. file_list → wctest/ recursive=true
|
||
7. file_read → wctest/wordcount.py back
|
||
8. shell_execute → "python wctest/wordcount.py wctest/sample.txt"
|
||
9. code_execute → Python that writes wctest/runinfo.json with timing + result
|
||
10. web_search → query "python json module" with maxResults 1
|
||
11. web_fetch → fetch "https://example.com"
|
||
|
||
After every required tool was called, write a 1-2 sentence final summary listing which tools you used. DO NOT SKIP ANY TOOL.`
|
||
|
||
async function authenticate(base, code) {
|
||
const r = await fetch(`${base}/remote-api/auth`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ passcode: code }),
|
||
})
|
||
if (!r.ok) throw new Error(`auth ${r.status}: ${await r.text()}`)
|
||
return (await r.json()).token
|
||
}
|
||
|
||
async function runTool(base, token, chatId, tool, args) {
|
||
const r = await fetch(`${base}/remote-api/agent-tool`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||
body: JSON.stringify({ tool, args, chatId }),
|
||
})
|
||
return await r.json()
|
||
}
|
||
|
||
function observationToString(data) {
|
||
if (!data) return ''
|
||
if (typeof data.error === 'string') return 'Error: ' + data.error
|
||
if (typeof data.content === 'string') return data.content
|
||
if (data.status === 'saved') return 'File saved: ' + (data.path || '')
|
||
if (Array.isArray(data.entries)) return JSON.stringify(data.entries.slice(0, 50), null, 2)
|
||
if (Array.isArray(data.results)) return JSON.stringify(data.results.slice(0, 20), null, 2)
|
||
if (data.exitCode !== undefined) {
|
||
if (data.timedOut) return 'Execution timed out.'
|
||
if (data.exitCode !== 0) return `Error (${data.exitCode}):\n${data.stderr || data.stdout}`
|
||
return data.stdout || 'Done.'
|
||
}
|
||
if (data.iso || data.iso_local) return JSON.stringify(data)
|
||
return JSON.stringify(data)
|
||
}
|
||
|
||
// runToolLoop logic ported from mobile_landing()
|
||
function isSystemPromptEcho(content) {
|
||
if (!content) return false
|
||
const head = String(content).trim().slice(0, 240)
|
||
if (/^(hello[!,.]?\s+|hi[!,.]?\s+|hey[!,.]?\s+)?(i['’]?m|i am|you are)\s+(codex|an autonomous|the agent|an? ai)/i.test(head)) return true
|
||
if (/^(i am|i['’]m)\s+ready\s+to\s+(receive|assist|help)/i.test(head)) return true
|
||
if (/^(hello|hi|hey)[!,.]?\s+i['’]?m\s+ready/i.test(head)) return true
|
||
return false
|
||
}
|
||
|
||
function repairToolCallArgs(tc) {
|
||
if (!tc?.function) return tc
|
||
let a = tc.function.arguments
|
||
if (typeof a === 'string') {
|
||
// Kein JSON: dann bleibt der String stehen und der Aufrufer sieht ihn
|
||
// unveraendert — genau das ist die Reparatur, die diese Funktion anbietet.
|
||
try { tc.function.arguments = JSON.parse(a) } catch { /* String bleibt String */ }
|
||
}
|
||
return tc
|
||
}
|
||
|
||
async function chat(base, token, model, messages, tools) {
|
||
const r = await fetch(`${base}/api/chat`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||
body: JSON.stringify({ model, messages, tools, stream: false, options: { temperature: 0.2 } }),
|
||
})
|
||
if (!r.ok) throw new Error(`/api/chat ${r.status}: ${await r.text()}`)
|
||
return await r.json()
|
||
}
|
||
|
||
async function runAgentLoop({ base, token, model, chatId, system, task, allowedNames }) {
|
||
const filtered = AGENT_TOOLS.filter(t => allowedNames.includes(t.name))
|
||
const tools = buildToolDefs(filtered)
|
||
const messages = [
|
||
{ role: 'system', content: system },
|
||
{ role: 'user', content: task },
|
||
]
|
||
const transcript = []
|
||
const usedTools = new Set()
|
||
const maxIter = 60 // doubled to give nudges room
|
||
let echoRetries = 3
|
||
let consecutiveErrors = 0
|
||
const maxConsecutiveErrors = 5
|
||
let final = null
|
||
let nudgesUsed = 0
|
||
const maxNudges = 3 // up to 3 follow-up nudges if the model stops early
|
||
|
||
for (let iter = 1; iter <= maxIter; iter++) {
|
||
process.stdout.write(` · iter ${iter}: model thinking...`)
|
||
const t0 = Date.now()
|
||
const res = await chat(base, token, model, messages, tools)
|
||
const ms = Date.now() - t0
|
||
process.stdout.write(` (${ms}ms)\n`)
|
||
const msg = res.message || {}
|
||
let content = msg.content || ''
|
||
let toolCalls = (msg.tool_calls || []).map(repairToolCallArgs)
|
||
|
||
if (isSystemPromptEcho(content)) {
|
||
if (echoRetries > 0) {
|
||
echoRetries--
|
||
console.log(` · echo detected → silent retry (left=${echoRetries})`)
|
||
messages.push({ role: 'user', content: 'Continue the task. Do not introduce yourself again. Resume from the last successful step using the appropriate tool call.' })
|
||
continue
|
||
}
|
||
console.log(` · echo retries spent → dropping content`)
|
||
content = ''
|
||
}
|
||
|
||
if (!toolCalls.length) {
|
||
// Compliance check: if there are still mandated tools that haven't
|
||
// been touched, nudge the model to use them before letting it finish.
|
||
const missing = allowedNames.filter(n => !usedTools.has(n))
|
||
if (missing.length > 0 && nudgesUsed < maxNudges) {
|
||
nudgesUsed++
|
||
console.log(` · model wants to stop but ${missing.length} tools still missing → nudge ${nudgesUsed}/${maxNudges}: ${missing.join(', ')}`)
|
||
// Push the model's would-be final as an assistant message so the
|
||
// history stays coherent, then add a user nudge.
|
||
messages.push({ role: 'assistant', content: content || '' })
|
||
messages.push({ role: 'user', content:
|
||
`You stopped before exercising every required tool. Still missing: ${missing.join(', ')}. ` +
|
||
`Call each of those tools NOW (one per turn is fine) with a small useful invocation, then write the final summary. ` +
|
||
`Tools list reminder: ${allowedNames.join(', ')}.`
|
||
})
|
||
continue
|
||
}
|
||
final = content || null
|
||
transcript.push({ kind: 'final', content: final })
|
||
console.log(` · no toolCalls → finalAnswer (after ${nudgesUsed} nudges)\n "${(final || '(empty → fallback summary will kick in on the UI)').slice(0, 200)}"`)
|
||
break
|
||
}
|
||
|
||
messages.push({ role: 'assistant', content, tool_calls: toolCalls.map(tc => ({ function: { name: tc.function.name, arguments: tc.function.arguments } })) })
|
||
|
||
for (const tc of toolCalls) {
|
||
const name = tc.function.name
|
||
usedTools.add(name)
|
||
const args = tc.function.arguments || {}
|
||
const argsPretty = JSON.stringify(args).slice(0, 200)
|
||
console.log(` → ${name} ${argsPretty}`)
|
||
const data = await runTool(base, token, chatId, name, args)
|
||
const obs = observationToString(data)
|
||
const isErr = /^(Error|Permission denied|Network error)/i.test(obs)
|
||
if (isErr) consecutiveErrors++; else consecutiveErrors = 0
|
||
const obsShort = obs.slice(0, 220).replaceAll('\n', ' ')
|
||
console.log(` ${isErr ? '✗' : '✓'} ${obsShort}${obs.length > 220 ? '…' : ''}`)
|
||
transcript.push({ kind: 'tool', name, args, observation: obs, error: isErr })
|
||
messages.push({ role: 'tool', content: obs })
|
||
|
||
if (consecutiveErrors >= maxConsecutiveErrors) {
|
||
console.log(` ! consecutiveErrors=${consecutiveErrors} → bailing`)
|
||
final = null
|
||
break
|
||
}
|
||
}
|
||
if (consecutiveErrors >= maxConsecutiveErrors) break
|
||
}
|
||
|
||
return { final, transcript, usedTools, consecutiveErrors, echoRetries, nudgesUsed }
|
||
}
|
||
|
||
async function main() {
|
||
const { base, code, mode, model, chatId, task } = parseArgs()
|
||
const token = await authenticate(base, code)
|
||
console.log(`[e2e] Authenticated. Mode=${mode} Model=${model} ChatId=${chatId}`)
|
||
const system = mode === 'codex' ? CODEX_PROMPT : AGENT_PROMPT
|
||
const userTask = task || (mode === 'codex' ? TASK_CODEX : TASK_AGENT)
|
||
console.log(`[e2e] Task:\n ${userTask.replaceAll('\n', '\n ')}\n`)
|
||
|
||
const allowedNames = mode === 'codex' ? CODEX_TOOL_NAMES : AGENT_TOOL_NAMES
|
||
console.log(`[e2e] Tools available: ${allowedNames.length} (${allowedNames.join(', ')})\n`)
|
||
const t0 = Date.now()
|
||
const result = await runAgentLoop({ base, token, model, chatId, system, task: userTask, allowedNames })
|
||
const elapsed = Date.now() - t0
|
||
|
||
const missing = allowedNames.filter(n => !result.usedTools.has(n))
|
||
console.log(`\n━━ Run summary ━━`)
|
||
console.log(`Mode: ${mode}`)
|
||
console.log(`Tools available: ${allowedNames.length}`)
|
||
console.log(`Tools exercised (${result.usedTools.size}): ${[...result.usedTools].sort().join(', ')}`)
|
||
console.log(`Tools NOT exercised (${missing.length}): ${missing.length ? missing.join(', ') : '(none — all tools hit ✓)'}`)
|
||
console.log(`Tool turns recorded: ${result.transcript.filter(t => t.kind === 'tool').length}`)
|
||
console.log(`Errors during run: ${result.transcript.filter(t => t.kind === 'tool' && t.error).length}`)
|
||
console.log(`Echo retries used: ${3 - result.echoRetries}`)
|
||
console.log(`Consecutive-err count: ${result.consecutiveErrors}`)
|
||
console.log(`Tool-coverage nudges used: ${result.nudgesUsed}`)
|
||
console.log(`Total wall time: ${elapsed} ms`)
|
||
const hasGreeting = result.final && /^(hello|hi|hey).{0,40}(codex|the agent|an ai)/i.test(result.final)
|
||
console.log(`Final answer was a greeting: ${hasGreeting ? 'YES (BUG)' : 'NO ✓'}`)
|
||
console.log(`Final answer:\n "${(result.final || '(no final, fallback would kick in)').slice(0, 400)}"`)
|
||
exit(hasGreeting || missing.length > 0 ? 1 : 0)
|
||
}
|
||
|
||
main().catch(e => { console.error('[e2e] fatal:', e); exit(2) })
|