ework-aio 0.2.8 → 0.2.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-aio",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "All-in-one installer for ework (issue tracker) + ework-daemon (AI bridge) + opencode-ework (plugin). One command: npm i -g ework-aio && ework-aio install.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env bash
2
+ # End-to-end browser test: install → fake LLM → opencode config → Playwright.
3
+ #
4
+ # This is the "full product path" test. Compared to e2e-install.sh (which
5
+ # covers install/subcommand/lifecycle), this script:
6
+ # - Starts a fake OpenAI-compatible LLM server (scripts/fake-llm-server.ts)
7
+ # - Configures opencode.json to use it (no external API needed)
8
+ # - Creates an issue (which triggers daemon → opencode → fake LLM)
9
+ # - Drives a headless browser through ework-web UI
10
+ # - Verifies the resulting session is browsable and shows content
11
+ #
12
+ # Requires Docker image with Chromium pre-installed. The Dockerfile.regression
13
+ # image handles this; pass via IMAGE env or rely on the default.
14
+ #
15
+ # Usage: ./scripts/e2e-browser.sh [container-runtime] [npm-tag]
16
+
17
+ set -euo pipefail
18
+
19
+ RUNTIME="${1:-docker}"
20
+ NPM_TAG="${2:-latest}"
21
+ IMAGE="${IMAGE:-ework-aio:e2e}"
22
+
23
+ c_grn=$'\033[32m'; c_red=$'\033[31m'; c_ylw=$'\033[33m'; c_rst=$'\033[0m'
24
+ pass() { printf '%sPASS%s %s\n' "$c_grn" "$c_rst" "$*"; }
25
+ fail() { printf '%sFAIL%s %s\n' "$c_red" "$c_rst" "$*"; exit 1; }
26
+ info() { printf '%s…%s %s\n' "$c_ylw" "$c_rst" "$*" >&2; }
27
+
28
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
29
+
30
+ if ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then
31
+ info "Building $IMAGE (with Chromium deps — first build is slow)"
32
+ "$RUNTIME" build --network=host -f "$REPO_ROOT/Dockerfile.regression" \
33
+ -t "$IMAGE" "$REPO_ROOT"
34
+ fi
35
+
36
+ info "Using image: $IMAGE"
37
+
38
+ # Hardcoded ports — see e2e-install.sh for why we don't inherit from host.
39
+ WORK_PORT="14002"
40
+ DAEMON_PORT="14101"
41
+ FAKE_LLM_PORT="8400"
42
+
43
+ OPENCODE_HOST_BIN="${OPENCODE_HOST_BIN:-/home/dog/.local/bin/opencode}"
44
+
45
+ "$RUNTIME" run --rm -i --network host \
46
+ -v "$OPENCODE_HOST_BIN:/usr/local/bin/opencode:ro" \
47
+ -v "$REPO_ROOT/scripts:/host-scripts:ro" \
48
+ -v "$REPO_ROOT/test:/host-test:ro" \
49
+ -e WORK_PORT="$WORK_PORT" \
50
+ -e DAEMON_PORT="$DAEMON_PORT" \
51
+ -e FAKE_LLM_PORT="$FAKE_LLM_PORT" \
52
+ -e NPM_TAG="$NPM_TAG" \
53
+ -e HTTP_PROXY="${HTTP_PROXY:-}" \
54
+ -e HTTPS_PROXY="${HTTPS_PROXY:-}" \
55
+ -e http_proxy="${HTTP_PROXY:-}" \
56
+ -e https_proxy="${HTTPS_PROXY:-}" \
57
+ -e NO_PROXY="127.0.0.1,localhost" \
58
+ "$IMAGE" bash -euo pipefail <<'EOSCRIPT'
59
+ set -euo pipefail
60
+
61
+
62
+ c_grn=$'\033[32m'; c_red=$'\033[31m'; c_ylw=$'\033[33m'; c_rst=$'\033[0m'
63
+ pass() { printf '%sPASS%s %s\n' "$c_grn" "$c_rst" "$*"; }
64
+ fail() { printf '%sFAIL%s %s\n' "$c_red" "$c_rst" "$*"; exit 1; }
65
+ info() { printf '%s…%s %s\n' "$c_ylw" "$c_rst" "$*" >&2; }
66
+
67
+ WORK_PORT="${WORK_PORT:-14002}"
68
+ DAEMON_PORT="${DAEMON_PORT:-14101}"
69
+ FAKE_LLM_PORT="${FAKE_LLM_PORT:-8400}"
70
+ BOT_LOGIN="${BOT_LOGIN:-e2e-bot}"
71
+ NPM_TAG="${NPM_TAG:-latest}"
72
+ DATA_DIR=/tmp/aio-browser
73
+
74
+ info "clean state"
75
+ rm -rf "$DATA_DIR"
76
+ mkdir -p "$DATA_DIR"
77
+
78
+ info "opencode binary on PATH"
79
+ opencode --version | head -1
80
+
81
+ info "install ework-aio@${NPM_TAG} + deps"
82
+ for pkg in ework-web ework-daemon opencode-ework; do
83
+ npm install -g "$pkg@latest" 2>&1 | tail -2
84
+ done
85
+ npm install -g "ework-aio@${NPM_TAG}" 2>&1 | tail -2
86
+
87
+ info "install Playwright npm package (browser itself is pre-baked into image)"
88
+ # Install into a local node_modules so module resolution works regardless of
89
+ # whether we drive the test via `node` or `bun`. Global installs don't get
90
+ # picked up by Node's import resolver.
91
+ mkdir -p /tmp/test-runner
92
+ cd /tmp/test-runner
93
+ npm init -y >/dev/null
94
+ npm install playwright 2>&1 | tail -2
95
+
96
+ info "start fake LLM server in background"
97
+ PORT="$FAKE_LLM_PORT" bun run /host-scripts/fake-llm-server.ts 2>/tmp/fake-llm.log &
98
+ FAKE_LLM_PID=$!
99
+ # Wait for server to come up.
100
+ for i in $(seq 1 30); do
101
+ if curl -sf "http://127.0.0.1:$FAKE_LLM_PORT/v1/models" >/dev/null; then
102
+ pass "fake LLM up (pid $FAKE_LLM_PID, port $FAKE_LLM_PORT)"
103
+ break
104
+ fi
105
+ sleep 0.5
106
+ [[ $i -eq 30 ]] && { tail /tmp/fake-llm.log; fail "fake LLM did not come up"; }
107
+ done
108
+
109
+ info "run ework-aio install (writes opencode.json with plugin only)"
110
+ ework-aio install \
111
+ --allow-root \
112
+ --data-dir "$DATA_DIR" \
113
+ --port "$WORK_PORT" \
114
+ --daemon-port "$DAEMON_PORT" \
115
+ --bot-name e2e-bot \
116
+ --yes 2>&1 | tee /tmp/install.log | tail -5
117
+
118
+ pass "install exited 0"
119
+
120
+ info "verify services respond"
121
+ for i in $(seq 1 60); do
122
+ curl -sf "http://127.0.0.1:$WORK_PORT/login" >/dev/null && break
123
+ sleep 0.5
124
+ [[ $i -eq 60 ]] && fail "ework-web did not come up"
125
+ done
126
+ for i in $(seq 1 60); do
127
+ curl -sf "http://127.0.0.1:$DAEMON_PORT/api/status" >/dev/null && break
128
+ sleep 0.5
129
+ [[ $i -eq 60 ]] && fail "daemon did not come up"
130
+ done
131
+ pass "both services responding"
132
+
133
+ info "configure opencode to use fake LLM"
134
+ # Overwrite ~/.config/opencode/opencode.json with fake provider + plugin.
135
+ # install.ts only registers the plugin; we add the fake provider here so
136
+ # the test stays isolated from any real API credentials.
137
+ OPENCODE_CONFIG_DIR="$HOME/.config/opencode"
138
+ mkdir -p "$OPENCODE_CONFIG_DIR"
139
+ cat > "$OPENCODE_CONFIG_DIR/opencode.json" <<OCJSON
140
+ {
141
+ "\$schema": "https://opencode.ai/config.json",
142
+ "plugin": ["opencode-ework@latest"],
143
+ "provider": {
144
+ "fake": {
145
+ "npm": "@ai-sdk/openai-compatible",
146
+ "name": "Fake (E2E test)",
147
+ "options": {
148
+ "baseURL": "http://127.0.0.1:$FAKE_LLM_PORT/v1"
149
+ },
150
+ "models": {
151
+ "fake-model": {
152
+ "name": "Fake Model"
153
+ }
154
+ }
155
+ }
156
+ },
157
+ "model": "fake/fake-model"
158
+ }
159
+ OCJSON
160
+ pass "opencode.json points at fake LLM"
161
+
162
+ info "warm up opencode (first-run DB migration takes minutes)"
163
+ # Opencode prints "Performing one time database migration, may take a few
164
+ # minutes..." on its first invocation against a fresh DB. If we don't warm
165
+ # it up here, both the smoke test AND the daemon's spawned opencode get
166
+ # killed mid-migration and never reach the chat step. Running any cheap
167
+ # command against the DB triggers the migration once; subsequent runs are
168
+ # fast. `session list` is the cheapest touch we can do.
169
+ # </dev/null: opencode session list reads stdin (cosmetic for list mode,
170
+ # but defensive — see smoke test comment for the heredoc-eof trap).
171
+ timeout -s KILL 300 opencode session list </dev/null >/dev/null 2>&1 || true
172
+ pass "opencode warm-up done (migration triggered if needed)"
173
+
174
+ info "verify opencode can reach fake LLM (manual smoke)"
175
+ # SIGKILL after 90s — opencode can hang on tool-call prompts if the LLM
176
+ # response shape is wrong; SIGTERM is caught for cleanup and takes longer.
177
+ # 90s because even post-migration, first stream response can take 10-20s.
178
+ #
179
+ # </dev/null is critical: this script's heredoc provides bash's stdin.
180
+ # opencode run reads stdin when waiting for prompts, which would silently
181
+ # consume the rest of the heredoc — bash then hits EOF and exits without
182
+ # running any subsequent step.
183
+ cd /tmp && timeout -s KILL 90 opencode run --format json --model fake/fake-model --dir /tmp "manual smoke test" </dev/null > /tmp/smoke.json 2>&1 || true
184
+ pass "opencode smoke test ran (output captured)"
185
+ echo " events: $(wc -l < /tmp/smoke.json)"
186
+ echo " --- smoke.json content (first 1000 chars) ---"
187
+ head -c 1000 /tmp/smoke.json
188
+ echo
189
+ echo " --- fake-llm log tail ---"
190
+ tail -5 /tmp/fake-llm.log
191
+
192
+ info "create issue that triggers daemon → opencode → fake LLM"
193
+ # Build admin cookie (same recipe as e2e-install.sh).
194
+ WORK_TOKEN=$(grep ^WORK_TOKEN= "$DATA_DIR/ework-web/.env" | cut -d= -f2-)
195
+ WORK_COOKIE_SECRET=$(grep ^WORK_COOKIE_SECRET= "$DATA_DIR/ework-web/.env" | cut -d= -f2-)
196
+ COOKIE_SIG=$(printf '%s' "$WORK_TOKEN" \
197
+ | openssl dgst -sha256 -hmac "$WORK_COOKIE_SECRET" -binary \
198
+ | base64 | tr '+/' '-_' | tr -d '=')
199
+ AUTH_COOKIE="ework_auth=${WORK_TOKEN}.${COOKIE_SIG}"
200
+
201
+ # Create project (idempotent — if it exists, 303 is still returned).
202
+ curl -sS -o /dev/null -w 'project: %{http_code}\n' -X POST \
203
+ "http://127.0.0.1:$WORK_PORT/projects" \
204
+ -H "Cookie: $AUTH_COOKIE" \
205
+ --data-urlencode 'owner=e2e' \
206
+ --data-urlencode 'name=browser-test'
207
+
208
+ # Create issue (triggers webhook → daemon → opencode).
209
+ ISSUE_TITLE="Browser E2E $(date +%s)"
210
+ curl -sS -o /dev/null -w 'issue: %{http_code}\n' -X POST \
211
+ "http://127.0.0.1:$WORK_PORT/e2e/browser-test/issues" \
212
+ -H "Cookie: $AUTH_COOKIE" \
213
+ --data-urlencode "title=$ISSUE_TITLE" \
214
+ --data-urlencode 'body=please reply with anything'
215
+
216
+ info "wait for daemon to process the issue + spawn opencode"
217
+ # daemon polls every few seconds; opencode run takes a few seconds against
218
+ # fake LLM. Give it generous room.
219
+ for i in $(seq 1 60); do
220
+ ACTIVE=$(curl -sS "http://127.0.0.1:$DAEMON_PORT/api/status" | jq -r '.issues // 0')
221
+ if [[ "$ACTIVE" -ge 1 ]]; then
222
+ pass "daemon registered issue after ${i} seconds (issues=$ACTIVE)"
223
+ break
224
+ fi
225
+ sleep 1
226
+ [[ $i -eq 60 ]] && fail "daemon did not register issue"
227
+ done
228
+
229
+ info "wait for opencode to write a session"
230
+ OPENCODE_DB="$HOME/.local/share/opencode/opencode.db"
231
+ # Query opencode.db via bun (bun:sqlite ships with the runtime, no extra
232
+ # dependency to apt install). One-line JS keeps the bash flow readable.
233
+ sql_query() {
234
+ bun -e "const db=require('bun:sqlite');const d=new db.Database('$1',{readonly:true,create:false});try{process.stdout.write(JSON.stringify(d.prepare(\`$2\`).all()))}catch(e){process.stdout.write('')}" 2>/dev/null
235
+ }
236
+ # The smoke test in the previous step already wrote a session row. To avoid
237
+ # grabbing that one, capture its ID first and wait for a NEW session ID to
238
+ # appear (the daemon spawns its own opencode against a different --dir, so
239
+ # its session ID will differ).
240
+ PREV_SESSION_ID=$(sql_query "$OPENCODE_DB" "SELECT id FROM session ORDER BY time_updated DESC LIMIT 1;" \
241
+ | jq -r '.[0].id // empty' 2>/dev/null || echo "")
242
+ echo " smoke test session: ${PREV_SESSION_ID:-(none yet)}"
243
+ SESSION_ID=""
244
+ # 180s window: opencode spawn + fake-LLM roundtrip + write to DB. The
245
+ # daemon's poller picks up the issue within ~30s, then opencode takes
246
+ # 5-30s depending on warm-up state.
247
+ for i in $(seq 1 180); do
248
+ ROWS=$(sql_query "$OPENCODE_DB" "SELECT id FROM session ORDER BY time_updated DESC LIMIT 1;")
249
+ SESSION_ID=$(echo "$ROWS" | jq -r '.[0].id // empty' 2>/dev/null || echo "")
250
+ if [[ -n "$SESSION_ID" && "$SESSION_ID" != "$PREV_SESSION_ID" ]]; then
251
+ pass "session created: $SESSION_ID (after ${i}s)"
252
+ break
253
+ fi
254
+ sleep 1
255
+ [[ $i -eq 180 ]] && {
256
+ echo "--- daemon.log (last 40) ---"
257
+ tail -40 "$DATA_DIR/run/daemon.log" 2>/dev/null || echo "(no daemon.log)"
258
+ echo "--- fake-llm log (last 10) ---"
259
+ tail -10 /tmp/fake-llm.log
260
+ fail "daemon's opencode did not write a new session (last seen: $SESSION_ID)";
261
+ }
262
+ done
263
+
264
+ info "wait for session to have assistant content (fake LLM reply)"
265
+ for i in $(seq 1 30); do
266
+ ROWS=$(sql_query "$OPENCODE_DB" "SELECT COUNT(*) AS n FROM message WHERE session_id='${SESSION_ID}';")
267
+ MSG_COUNT=$(echo "$ROWS" | jq -r '.[0].n // 0' 2>/dev/null || echo 0)
268
+ if [[ "$MSG_COUNT" -ge 2 ]]; then
269
+ pass "session has $MSG_COUNT messages (user + assistant)"
270
+ break
271
+ fi
272
+ sleep 1
273
+ [[ $i -eq 30 ]] && fail "session never got assistant reply (msgs=$MSG_COUNT)";
274
+ done
275
+
276
+ info "verify session is browsable via awork-web"
277
+ # awork-web's OpencodeClient reads opencode.db and calls `opencode export`.
278
+ # Hit the /sessions list and the specific session page. Both require the
279
+ # admin auth cookie (otherwise 302 → /login).
280
+ SESSIONS_RESP=$(curl -sS -i -H "Cookie: $AUTH_COOKIE" "http://127.0.0.1:$WORK_PORT/sessions")
281
+ SESSIONS_HTML=$(echo "$SESSIONS_RESP" | tail -n +5)
282
+ SESSIONS_STATUS=$(echo "$SESSIONS_RESP" | head -1)
283
+ if ! echo "$SESSIONS_HTML" | grep -q "$SESSION_ID"; then
284
+ echo "--- HTTP status: $SESSIONS_STATUS ---"
285
+ echo "--- /sessions response (first 2000 chars of body) ---"
286
+ echo "$SESSIONS_HTML" | head -c 2000
287
+ echo
288
+ echo "--- sessions actually in opencode.db ---"
289
+ sql_query "$OPENCODE_DB" "SELECT id, time_created, time_archived FROM session ORDER BY time_created DESC LIMIT 5;" | jq . 2>/dev/null
290
+ echo "--- ework-web access log (last 20) ---"
291
+ tail -20 "$DATA_DIR/run/web-access.log" 2>/dev/null || echo "(no access log)"
292
+ fail "/sessions list does not contain the new session ID"
293
+ fi
294
+ pass "/sessions lists the new session"
295
+
296
+ SESSION_PAGE_HTML=$(curl -sS -H "Cookie: $AUTH_COOKIE" "http://127.0.0.1:$WORK_PORT/sessions/$SESSION_ID")
297
+ # Look for any marker that the fake LLM reply rendered. The reply body always
298
+ # starts with "E2E fake-LLM reply" so we grep for that substring.
299
+ echo "$SESSION_PAGE_HTML" | grep -q "E2E fake-LLM" \
300
+ || fail "session page does not contain fake-LLM reply text"
301
+ pass "session page renders fake-LLM reply content"
302
+
303
+ info "check whether the bot actually replied on the issue (auto-reply)"
304
+ # ework-web exposes the issue comments via /api/v1/repos/<o>/<r>/issues/<n>/comments
305
+ # (Gitea-compatible). Two distinct kinds of bot-authored comments exist:
306
+ # 1. [system] "picked up this issue" — posted by the daemon on session start
307
+ # (NOT an LLM reply; doesn't count).
308
+ # 2. [bot] actual reply — posted by the LLM via the opencode-ework `reply`
309
+ # tool. This is the auto-reply behaviour we're validating.
310
+ COMMENTS_JSON=$(curl -sS -H "Cookie: $AUTH_COOKIE" \
311
+ "http://127.0.0.1:$WORK_PORT/api/v1/repos/e2e/browser-test/issues/1/comments")
312
+ COMMENTS_COUNT=$(echo "$COMMENTS_JSON" | jq 'length' 2>/dev/null || echo 0)
313
+ BOT_COMMENTS_COUNT=$(echo "$COMMENTS_JSON" \
314
+ | jq --arg bot "$BOT_LOGIN" '[.[] | select(.user.login == $bot)] | length' 2>/dev/null || echo 0)
315
+ LLM_REPLIES=$(echo "$COMMENTS_JSON" \
316
+ | jq --arg bot "$BOT_LOGIN" \
317
+ '[.[] | select(.user.login == $bot and (.body | startswith("[bot]")))] | length' 2>/dev/null || echo 0)
318
+ echo " comments on issue #1: total=$COMMENTS_COUNT bot=$BOT_COMMENTS_COUNT llm-reply=$LLM_REPLIES"
319
+ echo " --- all bot comment bodies (first 400 chars each) ---"
320
+ echo "$COMMENTS_JSON" | jq -r --arg bot "$BOT_LOGIN" \
321
+ '.[] | select(.user.login == $bot) | " • " + (.body | .[0:400])' 2>/dev/null
322
+ if [[ "$LLM_REPLIES" -ge 1 ]]; then
323
+ pass "bot auto-replied on issue #1 ($LLM_REPLIES [bot]-prefixed LLM reply)"
324
+ else
325
+ echo " --- daemon.log (last 40) ---"
326
+ tail -40 "$DATA_DIR/run/daemon.log" 2>/dev/null || echo "(no daemon.log)"
327
+ echo " --- fake-llm.log (last 20) ---"
328
+ tail -20 /tmp/fake-llm.log 2>/dev/null
329
+ fail "bot did NOT post an LLM-driven reply on issue #1 (got $BOT_COMMENTS_COUNT bot comments but 0 starting with [bot])"
330
+ fi
331
+
332
+ info "drive headless browser through the UI"
333
+ # bun (not tsx) because the script imports `bun:sqlite` to peek at opencode.db.
334
+ cp /host-test/browser-flow.ts /tmp/test-runner/
335
+ WORK_PORT="$WORK_PORT" \
336
+ WORK_DATA_DIR="$DATA_DIR" \
337
+ OPENCODE_DB="$OPENCODE_DB" \
338
+ HEADLESS=1 \
339
+ bun /tmp/test-runner/browser-flow.ts
340
+ pass "browser flow test PASSED"
341
+
342
+ info "fake LLM log tail"
343
+ tail -10 /tmp/fake-llm.log
344
+
345
+ echo
346
+ echo "====================================================="
347
+ echo "BROWSER E2E PASSED"
348
+ echo "====================================================="
349
+ echo " session ID: $SESSION_ID"
350
+ echo " session URL: http://127.0.0.1:$WORK_PORT/sessions/$SESSION_ID"
351
+ echo " fake LLM log: /tmp/fake-llm.log"
352
+ echo " data dir: $DATA_DIR"
353
+
354
+ # Kill fake LLM (container exit also does this, but explicit is clearer).
355
+ kill "$FAKE_LLM_PID" 2>/dev/null || true
356
+ EOSCRIPT
357
+
358
+ echo
359
+ echo "${c_grn}BROWSER E2E COMPLETE${c_rst}"
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env bun
2
+ // Fake OpenAI-compatible LLM server for E2E tests.
3
+ //
4
+ // Listens on 127.0.0.1:PORT and responds to /v1/chat/completions with a
5
+ // deterministic, context-aware canned reply. Used by the docker E2E test
6
+ // to drive real `opencode run` against a stub LLM — opencode can't tell
7
+ // the difference, writes a real session to opencode.db, and awork-web can
8
+ // then render that session via its normal /sessions/:id route.
9
+ //
10
+ // Why not just stub the opencode binary? Because awork-web reads opencode.db
11
+ // directly (src/opencode.ts:98-139 listSessions) and calls `opencode export`
12
+ // for transcripts — stubbing the binary means re-implementing the SQLite
13
+ // schema, which would silently drift from real opencode. Talking to a fake
14
+ // LLM exercises every piece except the network call to a real model.
15
+ //
16
+ // Usage:
17
+ // PORT=8400 bun run scripts/fake-llm-server.ts
18
+ // # or just: bun run scripts/fake-llm-server.ts (defaults to 8400)
19
+
20
+ const PORT = parseInt(process.env.PORT ?? "8400", 10);
21
+ const HOST = process.env.HOST ?? "127.0.0.1";
22
+
23
+ const server = Bun.serve({
24
+ port: PORT,
25
+ hostname: HOST,
26
+ fetch(req) {
27
+ const url = new URL(req.url);
28
+ log(`${req.method} ${url.pathname}`);
29
+
30
+ if (req.method === "GET" && url.pathname === "/v1/models") {
31
+ return json({
32
+ object: "list",
33
+ data: [
34
+ {
35
+ id: "fake-model",
36
+ object: "model",
37
+ created: 1_700_000_000_000,
38
+ owned_by: "e2e-test",
39
+ },
40
+ ],
41
+ });
42
+ }
43
+
44
+ if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
45
+ return handleChatCompletion(req);
46
+ }
47
+
48
+ return json({ error: `not found: ${req.method} ${url.pathname}` }, 404);
49
+ },
50
+ });
51
+
52
+ function log(msg: string): void {
53
+ process.stderr.write(`[fake-llm] ${new Date().toISOString()} ${msg}\n`);
54
+ }
55
+
56
+ function json(body: unknown, status = 200): Response {
57
+ return new Response(JSON.stringify(body), {
58
+ status,
59
+ headers: {
60
+ "content-type": "application/json",
61
+ "access-control-allow-origin": "*",
62
+ },
63
+ });
64
+ }
65
+
66
+ async function handleChatCompletion(req: Request): Promise<Response> {
67
+ let body: any;
68
+ try {
69
+ body = await req.json();
70
+ } catch (err) {
71
+ return json({ error: `invalid JSON: ${(err as Error).message}` }, 400);
72
+ }
73
+
74
+ // Log the full request so we can see what opencode/@ai-sdk is asking for
75
+ // (stream vs non-stream, tool definitions, system prompt, etc).
76
+ log(` body: stream=${body?.stream} model=${body?.model} msgs=${body?.messages?.length} tools=${body?.tools?.length ?? 0}`);
77
+
78
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
79
+ const tools = Array.isArray(body?.tools) ? body.tools : [];
80
+ const lastMsg = messages[messages.length - 1];
81
+ const lastRole = lastMsg?.role;
82
+ const userMsgs = messages.filter((m: any) => m?.role === "user");
83
+ const lastUser = userMsgs[userMsgs.length - 1];
84
+ const userText = typeof lastUser?.content === "string"
85
+ ? lastUser.content
86
+ : Array.isArray(lastUser?.content)
87
+ ? lastUser.content.map((p: any) => p?.text ?? "").join(" ")
88
+ : "";
89
+
90
+ const hasReplyTool = tools.some((t: any) => t?.function?.name === "reply");
91
+
92
+ // Tool-call path: when `reply` is registered and the last message is the
93
+ // user's initial prompt (not a tool_result), emit a tool_use so opencode
94
+ // actually invokes the reply tool → ework-web posts a [bot] comment on the
95
+ // issue. Without this the daemon only posts its [system] "picked up"
96
+ // notification and the LLM-driven auto-reply loop never fires.
97
+ //
98
+ // After opencode executes the tool it makes a follow-up call with
99
+ // role=tool in the messages — at that point we drop back to text.
100
+ if (hasReplyTool && lastRole === "user") {
101
+ const ref = parseIssueRef(userText);
102
+ if (ref) {
103
+ log(` emitting reply tool_use → ${ref.owner}/${ref.repo}#${ref.number}`);
104
+ const toolReplyBody =
105
+ `[bot] E2E fake-LLM auto-reply.\n\n` +
106
+ `Picked up ${ref.owner}/${ref.repo}#${ref.number}. ` +
107
+ `This is a stub reply emitted by scripts/fake-llm-server.ts to exercise ` +
108
+ `the opencode-ework \`reply\` tool end-to-end. The real value of this ` +
109
+ `test is that opencode received a tool_use, executed the reply tool, ` +
110
+ `and ework-web posted this comment as ${process.env.BOT_USERNAME ?? "bot"}.`;
111
+ return toolCallResponse(body?.model ?? "fake-model", "reply", {
112
+ owner: ref.owner,
113
+ repo: ref.repo,
114
+ number: ref.number,
115
+ body: toolReplyBody,
116
+ }, body?.stream === true);
117
+ }
118
+ }
119
+
120
+ // Deterministic, context-aware reply: echo a snippet of the user's last
121
+ // message so the session transcript has traceable content (not just a
122
+ // fixed string). Truncated so the reply stays readable in the UI.
123
+ const snippet = userText.slice(0, 120).replace(/\s+/g, " ").trim();
124
+ const reply =
125
+ `E2E fake-LLM reply.\n\n` +
126
+ `You said: "${snippet}${userText.length > 120 ? "…" : ""}"\n\n` +
127
+ `This is a stub response from scripts/fake-llm-server.ts. ` +
128
+ `The real value of this test is that opencode wrote a real session row ` +
129
+ `to opencode.db and awork-web can render it end-to-end.`;
130
+
131
+ const promptTokens = roughTokens(JSON.stringify(messages));
132
+ const completionTokens = roughTokens(reply);
133
+ const usage = {
134
+ prompt_tokens: promptTokens,
135
+ completion_tokens: completionTokens,
136
+ total_tokens: promptTokens + completionTokens,
137
+ };
138
+
139
+ // opencode (@ai-sdk/openai-compatible) defaults to stream=true. Returning
140
+ // a non-streaming JSON response silently produces a 0-token session row
141
+ // with no assistant text — the request completes but the transcript is
142
+ // empty. Honor the stream flag and emit SSE chunks.
143
+ if (body?.stream === true) {
144
+ return streamResponse(body?.model ?? "fake-model", reply, usage);
145
+ }
146
+
147
+ // Non-streaming path (used by curl sanity checks in tests).
148
+ return json({
149
+ id: `chatcmpl-fake-${crypto.randomUUID()}`,
150
+ object: "chat.completion",
151
+ created: Math.floor(Date.now() / 1000),
152
+ model: body?.model ?? "fake-model",
153
+ choices: [
154
+ {
155
+ index: 0,
156
+ message: { role: "assistant", content: reply },
157
+ finish_reason: "stop",
158
+ },
159
+ ],
160
+ usage,
161
+ });
162
+ }
163
+
164
+ // Parse an issue ref of the form `<owner>/<repo>#<n>` out of arbitrary text
165
+ // (typically the user prompt built by ework-daemon's buildInitialPrompt,
166
+ // which includes "(gitea:owner/repo#N)"). Returns null if no match.
167
+ function parseIssueRef(text: string): { owner: string; repo: string; number: number } | null {
168
+ const m = text.match(/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#(\d+)/);
169
+ if (!m) return null;
170
+ const [, owner, repo, num] = m;
171
+ const number = parseInt(num, 10);
172
+ if (!Number.isFinite(number)) return null;
173
+ return { owner, repo, number };
174
+ }
175
+
176
+ // Emit a tool_use response (OpenAI function-calling format). Supports both
177
+ // streaming and non-streaming because opencode defaults to stream=true but
178
+ // curl smoke tests use non-streaming.
179
+ function toolCallResponse(model: string, toolName: string, args: Record<string, unknown>, stream: boolean): Response {
180
+ const id = `chatcmpl-fake-${crypto.randomUUID()}`;
181
+ const created = Math.floor(Date.now() / 1000);
182
+ const callId = `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
183
+ const argsJson = JSON.stringify(args);
184
+ const usage = {
185
+ prompt_tokens: roughTokens(argsJson),
186
+ completion_tokens: roughTokens(argsJson),
187
+ total_tokens: roughTokens(argsJson) * 2,
188
+ };
189
+
190
+ if (!stream) {
191
+ return json({
192
+ id,
193
+ object: "chat.completion",
194
+ created,
195
+ model,
196
+ choices: [
197
+ {
198
+ index: 0,
199
+ message: {
200
+ role: "assistant",
201
+ content: null,
202
+ tool_calls: [
203
+ {
204
+ id: callId,
205
+ type: "function",
206
+ function: { name: toolName, arguments: argsJson },
207
+ },
208
+ ],
209
+ },
210
+ finish_reason: "tool_calls",
211
+ },
212
+ ],
213
+ usage,
214
+ });
215
+ }
216
+
217
+ const encoder = new TextEncoder();
218
+ const readable = new ReadableStream({
219
+ start(controller) {
220
+ // Initial chunk: declare the tool_call with name + empty arguments.
221
+ controller.enqueue(
222
+ encoder.encode(
223
+ sseLine({
224
+ id,
225
+ object: "chat.completion.chunk",
226
+ created,
227
+ model,
228
+ choices: [{
229
+ index: 0,
230
+ delta: {
231
+ role: "assistant",
232
+ content: null,
233
+ tool_calls: [{
234
+ index: 0,
235
+ id: callId,
236
+ type: "function",
237
+ function: { name: toolName, arguments: "" },
238
+ }],
239
+ },
240
+ finish_reason: null,
241
+ }],
242
+ }),
243
+ ),
244
+ );
245
+ // Arguments chunk: full JSON in one shot (splitting is optional).
246
+ controller.enqueue(
247
+ encoder.encode(
248
+ sseLine({
249
+ id,
250
+ object: "chat.completion.chunk",
251
+ created,
252
+ model,
253
+ choices: [{
254
+ index: 0,
255
+ delta: {
256
+ tool_calls: [{ index: 0, function: { arguments: argsJson } }],
257
+ },
258
+ finish_reason: null,
259
+ }],
260
+ }),
261
+ ),
262
+ );
263
+ // Final chunk: finish_reason + usage.
264
+ controller.enqueue(
265
+ encoder.encode(
266
+ sseLine({
267
+ id,
268
+ object: "chat.completion.chunk",
269
+ created,
270
+ model,
271
+ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
272
+ usage,
273
+ }),
274
+ ),
275
+ );
276
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
277
+ controller.close();
278
+ },
279
+ });
280
+
281
+ return new Response(readable, {
282
+ headers: {
283
+ "content-type": "text/event-stream",
284
+ "cache-control": "no-cache",
285
+ "connection": "keep-alive",
286
+ "access-control-allow-origin": "*",
287
+ },
288
+ });
289
+ }
290
+
291
+ function streamResponse(model: string, reply: string, usage: any): Response {
292
+ const id = `chatcmpl-fake-${crypto.randomUUID()}`;
293
+ const created = Math.floor(Date.now() / 1000);
294
+
295
+ // Split into ~10 chunks so the streaming code path actually streams
296
+ // (single-chunk streams work but look unrealistic in logs).
297
+ const chunks: string[] = [];
298
+ const words = reply.split(/(\s+)/);
299
+ const perChunk = Math.max(1, Math.ceil(words.length / 10));
300
+ for (let i = 0; i < words.length; i += perChunk) {
301
+ chunks.push(words.slice(i, i + perChunk).join(""));
302
+ }
303
+
304
+ const encoder = new TextEncoder();
305
+ const readable = new ReadableStream({
306
+ start(controller) {
307
+ // First chunk: role + opening content.
308
+ controller.enqueue(
309
+ encoder.encode(
310
+ sseLine({
311
+ id,
312
+ object: "chat.completion.chunk",
313
+ created,
314
+ model,
315
+ choices: [{ index: 0, delta: { role: "assistant", content: chunks[0] ?? "" }, finish_reason: null }],
316
+ }),
317
+ ),
318
+ );
319
+ // Subsequent chunks: content deltas only.
320
+ for (let i = 1; i < chunks.length; i++) {
321
+ controller.enqueue(
322
+ encoder.encode(
323
+ sseLine({
324
+ id,
325
+ object: "chat.completion.chunk",
326
+ created,
327
+ model,
328
+ choices: [{ index: 0, delta: { content: chunks[i] }, finish_reason: null }],
329
+ }),
330
+ ),
331
+ );
332
+ }
333
+ // Final chunk: empty delta + finish_reason + usage (usage must be on
334
+ // the final chunk when stream_options.include_usage is set; opencode
335
+ // sets it so the session row gets non-zero token counts).
336
+ controller.enqueue(
337
+ encoder.encode(
338
+ sseLine({
339
+ id,
340
+ object: "chat.completion.chunk",
341
+ created,
342
+ model,
343
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
344
+ usage,
345
+ }),
346
+ ),
347
+ );
348
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
349
+ controller.close();
350
+ },
351
+ });
352
+
353
+ return new Response(readable, {
354
+ headers: {
355
+ "content-type": "text/event-stream",
356
+ "cache-control": "no-cache",
357
+ "connection": "keep-alive",
358
+ "access-control-allow-origin": "*",
359
+ },
360
+ });
361
+ }
362
+
363
+ function sseLine(obj: any): string {
364
+ return `data: ${JSON.stringify(obj)}\n\n`;
365
+ }
366
+
367
+ function roughTokens(text: string): number {
368
+ // Crude 4-chars-per-token heuristic. Token counts only need to be >0 for
369
+ // awork-web's heat bar (which divides by max-peak-token across the session).
370
+ // Real tokenizer isn't needed for test data.
371
+ return Math.max(1, Math.ceil(text.length / 4));
372
+ }
373
+
374
+ process.stderr.write(
375
+ `[fake-llm] listening on http://${HOST}:${PORT}\n` +
376
+ `[fake-llm] POST /v1/chat/completions -> canned OpenAI response\n` +
377
+ `[fake-llm] GET /v1/models -> { fake-model }\n`,
378
+ );
379
+
380
+ // Keep stderr unbuffered so logs show up immediately in docker output.
381
+ process.stderr.write(`[fake-llm] ready (pid ${process.pid})\n`);