ework-aio 0.5.36 → 0.5.37
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 +1 -1
- package/scripts/e2e-router.sh +231 -0
- package/scripts/fake-llm-server.ts +91 -328
- package/scripts/mock-opencode.sh +64 -0
- package/src/commands/add-daemon.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ework-aio",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.37",
|
|
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,231 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
NPM_TAG="${2:-latest}"
|
|
5
|
+
DATA_DIR="${E2E_DATA_DIR:-/tmp/e2e-router}"
|
|
6
|
+
FAKE_HOME="${E2E_FAKE_HOME:-/tmp/e2e-router-home}"
|
|
7
|
+
FAKE_LLM_PORT="${E2E_LLM_PORT:-8401}"
|
|
8
|
+
WEB_PORT="${E2E_WEB_PORT:-3002}"
|
|
9
|
+
ROUTER_PORT="${E2E_ROUTER_PORT:-3104}"
|
|
10
|
+
D1_PORT="${E2E_D1_PORT:-3101}"
|
|
11
|
+
D2_PORT="${E2E_D2_PORT:-3102}"
|
|
12
|
+
D3_PORT="${E2E_D3_PORT:-3103}"
|
|
13
|
+
|
|
14
|
+
c_red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
|
15
|
+
c_green() { printf '\033[32m%s\033[0m\n' "$*"; }
|
|
16
|
+
c_blue() { printf '\033[34m%s\033[0m\n' "$*"; }
|
|
17
|
+
c_yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
|
|
18
|
+
phase() { echo ""; c_blue "━━━ Phase $1: $2 ━━━"; }
|
|
19
|
+
ok() { c_green " ✓ $*"; }
|
|
20
|
+
warn() { c_yellow " ! $*"; }
|
|
21
|
+
fail() { c_red " ✗ FAIL: $*"; FAILED=$((FAILED+1)); }
|
|
22
|
+
|
|
23
|
+
FAILED=0
|
|
24
|
+
assert_eq() { if [[ "$1" == "$2" ]]; then ok "$3: $1"; else fail "$3: expected '$2', got '$1'"; fi; }
|
|
25
|
+
assert_ge() { if [[ "$1" -ge "$2" ]]; then ok "$3: $1 ≥ $2"; else fail "$3: expected ≥ $2, got $1"; fi; }
|
|
26
|
+
assert_gt() { if [[ "$1" -gt "$2" ]]; then ok "$3: $1 > $2"; else fail "$3: expected > $2, got $1"; fi; }
|
|
27
|
+
|
|
28
|
+
if [[ "${1:-}" == "docker" ]]; then
|
|
29
|
+
IMAGE="ework-aio:router-e2e"
|
|
30
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
31
|
+
echo "Building Docker image $IMAGE..."
|
|
32
|
+
docker build -f "$SCRIPT_DIR/../Dockerfile.regression" -t "$IMAGE" "$SCRIPT_DIR/.."
|
|
33
|
+
echo "Running router E2E (npm tag: $NPM_TAG)..."
|
|
34
|
+
docker run --rm --network host \
|
|
35
|
+
-e NPM_TAG="$NPM_TAG" \
|
|
36
|
+
-v "$SCRIPT_DIR/e2e-router.sh:/e2e-router.sh:ro" \
|
|
37
|
+
-v "$SCRIPT_DIR/fake-llm-server.ts:/fake-llm-server.ts:ro" \
|
|
38
|
+
"$IMAGE" \
|
|
39
|
+
bash -c "npm install -g ework-aio@\"\$NPM_TAG\" 2>/dev/null && bash /e2e-router.sh local"
|
|
40
|
+
exit $?
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
echo "╔════════════════════════════════════════════╗"
|
|
44
|
+
echo "║ e2e-router.sh — Multi-daemon routing E2E ║"
|
|
45
|
+
echo "║ Real opencode + fake LLM + 3 daemons ║"
|
|
46
|
+
echo "╚════════════════════════════════════════════╝"
|
|
47
|
+
|
|
48
|
+
phase 0 "Bootstrap (fake LLM + opencode config + services)"
|
|
49
|
+
|
|
50
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
51
|
+
|
|
52
|
+
ework-aio stop 2>/dev/null || true
|
|
53
|
+
ework-aio stop --data-dir "$DATA_DIR" 2>/dev/null || true
|
|
54
|
+
sleep 2
|
|
55
|
+
|
|
56
|
+
bun run "${SCRIPT_DIR}/fake-llm-server.ts" 2>/tmp/e2e-fakellm.log &
|
|
57
|
+
FAKE_LLM_PID=$!
|
|
58
|
+
sleep 1
|
|
59
|
+
if curl -sf "http://127.0.0.1:$FAKE_LLM_PORT/v1/models" >/dev/null 2>&1; then
|
|
60
|
+
ok "fake LLM on :$FAKE_LLM_PORT (pid $FAKE_LLM_PID)"
|
|
61
|
+
else
|
|
62
|
+
fail "fake LLM not responding"; cat /tmp/e2e-fakellm.log; exit 1
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
FAKE_XDG="$FAKE_HOME/.config"
|
|
66
|
+
rm -rf "$FAKE_HOME"
|
|
67
|
+
mkdir -p "$FAKE_XDG/opencode"
|
|
68
|
+
cat > "$FAKE_XDG/opencode/opencode.json" <<OCJSON
|
|
69
|
+
{
|
|
70
|
+
"\$schema": "https://opencode.ai/config.json",
|
|
71
|
+
"plugin": ["opencode-ework@latest"],
|
|
72
|
+
"provider": {
|
|
73
|
+
"fake": {
|
|
74
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
75
|
+
"name": "Fake (E2E)",
|
|
76
|
+
"options": { "baseURL": "http://127.0.0.1:$FAKE_LLM_PORT/v1" },
|
|
77
|
+
"models": { "fake-model": { "name": "Fake Model" } }
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"model": "fake/fake-model",
|
|
81
|
+
"permission": { "reply": "allow", "bash": "allow" }
|
|
82
|
+
}
|
|
83
|
+
OCJSON
|
|
84
|
+
ok "opencode config with fake provider"
|
|
85
|
+
|
|
86
|
+
export XDG_CONFIG_HOME="$FAKE_XDG"
|
|
87
|
+
OC_BIN="${OPENCODE_BINARY:-$(which opencode 2>/dev/null || echo /usr/local/bin/opencode)}"
|
|
88
|
+
timeout 120 "$OC_BIN" session list </dev/null >/dev/null 2>&1 || true
|
|
89
|
+
ok "opencode warmed up ($OC_BIN)"
|
|
90
|
+
|
|
91
|
+
rm -rf "$DATA_DIR"
|
|
92
|
+
mkdir -p "$DATA_DIR"
|
|
93
|
+
rm -f "$DATA_DIR/bot-token"
|
|
94
|
+
|
|
95
|
+
ework-aio install --yes --allow-root \
|
|
96
|
+
--data-dir "$DATA_DIR" \
|
|
97
|
+
--port "$WEB_PORT" \
|
|
98
|
+
--daemon-port "$D1_PORT" 2>&1 | tail -5
|
|
99
|
+
|
|
100
|
+
sleep 5
|
|
101
|
+
[[ "$(curl -sf http://127.0.0.1:$WEB_PORT/healthz 2>/dev/null || echo FAIL)" != "FAIL" ]] \
|
|
102
|
+
&& ok "web on :$WEB_PORT" || { fail "web not responding"; exit 1; }
|
|
103
|
+
[[ "$(curl -sf http://127.0.0.1:$ROUTER_PORT/api/health 2>/dev/null || echo FAIL)" != "FAIL" ]] \
|
|
104
|
+
&& ok "router on :$ROUTER_PORT" || { fail "router not responding"; exit 1; }
|
|
105
|
+
|
|
106
|
+
WORK_TOKEN=$(grep WORK_TOKEN "$DATA_DIR/ework-web/.env" | cut -d= -f2)
|
|
107
|
+
COOKIE_SECRET=$(grep WORK_COOKIE_SECRET "$DATA_DIR/ework-web/.env" | cut -d= -f2)
|
|
108
|
+
BOT_TOKEN=$(cat "$DATA_DIR/bot-token" 2>/dev/null || echo "")
|
|
109
|
+
|
|
110
|
+
AUTH_COOKIE=$(node -e "
|
|
111
|
+
const crypto = require('crypto');
|
|
112
|
+
const now = Math.floor(Date.now()/1000);
|
|
113
|
+
const msg = 'v2.dog.' + now;
|
|
114
|
+
console.log(msg + '.' + crypto.createHmac('sha256', '$COOKIE_SECRET').update(msg).digest('base64url'));
|
|
115
|
+
")
|
|
116
|
+
|
|
117
|
+
ework-aio add-daemon "$D2_PORT" --data-dir "$DATA_DIR" --allow-root -y 2>&1 | tail -2
|
|
118
|
+
sleep 2
|
|
119
|
+
ework-aio add-daemon "$D3_PORT" --data-dir "$DATA_DIR" --allow-root -y 2>&1 | tail -2
|
|
120
|
+
sleep 5
|
|
121
|
+
|
|
122
|
+
DAEMON_DB="$DATA_DIR/ework-daemon/ework-daemon.db"
|
|
123
|
+
DAEMON_COUNT=$(sqlite3 "$DAEMON_DB" "SELECT COUNT(*) FROM daemons WHERE status='active';" 2>/dev/null || echo 0)
|
|
124
|
+
assert_ge "$DAEMON_COUNT" 3 "daemons registered in DB"
|
|
125
|
+
|
|
126
|
+
phase 1 "Project + webhook → router"
|
|
127
|
+
LOC=$(curl -s -o /dev/null -D - -X POST \
|
|
128
|
+
-H "Cookie: ework_auth=$AUTH_COOKIE" \
|
|
129
|
+
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
130
|
+
-d "title=bootstrap&body=init" \
|
|
131
|
+
"http://127.0.0.1:$WEB_PORT/e2e/router-test/issues/new" 2>/dev/null \
|
|
132
|
+
| grep -i '^location:' | tr -d '\r' | awk '{print $2}')
|
|
133
|
+
[[ -n "$LOC" ]] && ok "project+issue created" || { fail "issue creation failed"; exit 1; }
|
|
134
|
+
sleep 8
|
|
135
|
+
|
|
136
|
+
ROUTER_LOG="$DATA_DIR/run/router.log"
|
|
137
|
+
ROUTE_BEFORE=$(grep -c '"routing"' "$ROUTER_LOG" 2>/dev/null || echo 0)
|
|
138
|
+
assert_gt "$ROUTE_BEFORE" 0 "router routed bootstrap issue"
|
|
139
|
+
|
|
140
|
+
phase 2 "Least-loaded distribution (3 issues)"
|
|
141
|
+
create_issue() {
|
|
142
|
+
curl -s -o /dev/null -D - -X POST \
|
|
143
|
+
-H "Cookie: ework_auth=$AUTH_COOKIE" \
|
|
144
|
+
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
145
|
+
-d "title=$1&body=$2" \
|
|
146
|
+
"http://127.0.0.1:$WEB_PORT/e2e/router-test/issues/new" 2>/dev/null \
|
|
147
|
+
| grep -i '^location:' | tr -d '\r' | awk '{print $2}' | grep -oP '\d+$'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
ISSUE1=$(create_issue "route-1" "test")
|
|
151
|
+
ISSUE2=$(create_issue "route-2" "test")
|
|
152
|
+
ISSUE3=$(create_issue "route-3" "test")
|
|
153
|
+
assert_gt "${#ISSUE1}" 0 0 "issue-1 (#$ISSUE1)"
|
|
154
|
+
assert_gt "${#ISSUE2}" 0 0 "issue-2 (#$ISSUE2)"
|
|
155
|
+
assert_gt "${#ISSUE3}" 0 0 "issue-3 (#$ISSUE3)"
|
|
156
|
+
|
|
157
|
+
sleep 15
|
|
158
|
+
|
|
159
|
+
ROUTE_AFTER=$(grep -c '"routing"' "$ROUTER_LOG" 2>/dev/null || echo 0)
|
|
160
|
+
NEW_ROUTES=$((ROUTE_AFTER - ROUTE_BEFORE))
|
|
161
|
+
assert_ge "$NEW_ROUTES" 3 "≥3 routing decisions"
|
|
162
|
+
|
|
163
|
+
D1_HITS=$(grep '"routing"' "$ROUTER_LOG" | grep -c "127.0.0.1:$D1_PORT" || echo 0)
|
|
164
|
+
D2_HITS=$(grep '"routing"' "$ROUTER_LOG" | grep -c "127.0.0.1:$D2_PORT" || echo 0)
|
|
165
|
+
D3_HITS=$(grep '"routing"' "$ROUTER_LOG" | grep -c "127.0.0.1:$D3_PORT" || echo 0)
|
|
166
|
+
echo " distribution: d1=$D1_HITS d2=$D2_HITS d3=$D3_HITS"
|
|
167
|
+
|
|
168
|
+
UNIQUE=0
|
|
169
|
+
[[ $D1_HITS -gt 0 ]] && UNIQUE=$((UNIQUE+1))
|
|
170
|
+
[[ $D2_HITS -gt 0 ]] && UNIQUE=$((UNIQUE+1))
|
|
171
|
+
[[ $D3_HITS -gt 0 ]] && UNIQUE=$((UNIQUE+1))
|
|
172
|
+
assert_ge "$UNIQUE" 2 "≥2 unique daemons used"
|
|
173
|
+
|
|
174
|
+
phase 3 "[bot] replies via real opencode + fake LLM"
|
|
175
|
+
for n in $ISSUE1 $ISSUE2 $ISSUE3; do
|
|
176
|
+
REPLIES=$(curl -sf -H "Authorization: token $BOT_TOKEN" \
|
|
177
|
+
"http://127.0.0.1:$WEB_PORT/api/v1/repos/e2e/router-test/issues/$n/comments" 2>/dev/null \
|
|
178
|
+
| jq -r '[.[] | select(.body | startswith("[bot]"))] | length' 2>/dev/null || echo 0)
|
|
179
|
+
assert_ge "$REPLIES" 1 "issue #$n has ≥1 [bot] reply"
|
|
180
|
+
done
|
|
181
|
+
|
|
182
|
+
phase 4 "Close → re-route"
|
|
183
|
+
curl -sf -X PATCH -H "Authorization: token $BOT_TOKEN" \
|
|
184
|
+
-H "Content-Type: application/json" -d '{"state":"closed"}' \
|
|
185
|
+
"http://127.0.0.1:$WEB_PORT/api/v1/repos/e2e/router-test/issues/$ISSUE1" >/dev/null 2>&1
|
|
186
|
+
sleep 3
|
|
187
|
+
ISSUE4=$(create_issue "route-4-after-close" "test")
|
|
188
|
+
assert_gt "${#ISSUE4}" 0 0 "issue-4 (#$ISSUE4)"
|
|
189
|
+
sleep 10
|
|
190
|
+
FINAL_ROUTES=$(grep -c '"routing"' "$ROUTER_LOG" 2>/dev/null || echo 0)
|
|
191
|
+
assert_gt "$FINAL_ROUTES" "$ROUTE_AFTER" "new route after issue-4"
|
|
192
|
+
|
|
193
|
+
phase 5 "Failover (kill daemon-2)"
|
|
194
|
+
D2_PID=$(cat "$DATA_DIR/run/daemon-2.pid" 2>/dev/null || echo "")
|
|
195
|
+
if [[ -n "$D2_PID" ]] && kill -0 "$D2_PID" 2>/dev/null; then
|
|
196
|
+
kill "$D2_PID" 2>/dev/null || true
|
|
197
|
+
ok "daemon-2 killed (pid $D2_PID)"
|
|
198
|
+
sleep 3
|
|
199
|
+
ISSUE5=$(create_issue "route-5-failover" "test")
|
|
200
|
+
assert_gt "${#ISSUE5}" 0 0 "issue-5 (#$ISSUE5)"
|
|
201
|
+
sleep 10
|
|
202
|
+
POST_KILL_D2=$(grep '"routing"' "$ROUTER_LOG" | tail -5 \
|
|
203
|
+
| grep -c "127.0.0.1:$D2_PORT" || echo 0)
|
|
204
|
+
assert_eq "$POST_KILL_D2" 0 "no routes to dead daemon-2"
|
|
205
|
+
else
|
|
206
|
+
warn "daemon-2 not running, skipping failover"
|
|
207
|
+
fi
|
|
208
|
+
|
|
209
|
+
phase 6 "Webhook delivery audit"
|
|
210
|
+
WEB_DB="$DATA_DIR/ework-web/ework-web.db"
|
|
211
|
+
if [[ -f "$WEB_DB" ]]; then
|
|
212
|
+
DELIVERIES=$(sqlite3 "$WEB_DB" \
|
|
213
|
+
"SELECT COUNT(*) FROM webhook_deliveries WHERE status_code >= 200 AND status_code < 300;" \
|
|
214
|
+
2>/dev/null || echo 0)
|
|
215
|
+
assert_ge "$DELIVERIES" 3 "≥3 successful webhook deliveries"
|
|
216
|
+
else
|
|
217
|
+
warn "web DB not found"
|
|
218
|
+
fi
|
|
219
|
+
|
|
220
|
+
echo ""
|
|
221
|
+
echo "════════════════════════════════════════════"
|
|
222
|
+
if [[ $FAILED -eq 0 ]]; then
|
|
223
|
+
c_green "✅ ALL ROUTER E2E PHASES PASSED"
|
|
224
|
+
else
|
|
225
|
+
c_red "❌ $FAILED ASSERTION(S) FAILED"
|
|
226
|
+
fi
|
|
227
|
+
echo "════════════════════════════════════════════"
|
|
228
|
+
|
|
229
|
+
kill "$FAKE_LLM_PID" 2>/dev/null || true
|
|
230
|
+
[[ "${KEEP_ALIVE:-0}" != "1" ]] && ework-aio stop 2>/dev/null || true
|
|
231
|
+
exit $FAILED
|
|
@@ -1,350 +1,112 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
|
|
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";
|
|
2
|
+
const PORT = Number(process.env.FAKE_LLM_PORT ?? 8401);
|
|
3
|
+
const HOST = process.env.FAKE_LLM_HOST ?? "127.0.0.1";
|
|
22
4
|
|
|
23
5
|
const server = Bun.serve({
|
|
24
6
|
port: PORT,
|
|
25
7
|
hostname: HOST,
|
|
26
8
|
fetch(req) {
|
|
27
9
|
const url = new URL(req.url);
|
|
28
|
-
log(`${req.method} ${url.pathname}`);
|
|
29
10
|
|
|
30
|
-
if (
|
|
31
|
-
return json({
|
|
11
|
+
if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
12
|
+
return Response.json({
|
|
32
13
|
object: "list",
|
|
33
|
-
data: [
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
},
|
|
40
|
-
],
|
|
14
|
+
data: [{
|
|
15
|
+
id: "fake/fake-model",
|
|
16
|
+
object: "model",
|
|
17
|
+
created: Math.floor(Date.now() / 1000),
|
|
18
|
+
owned_by: "e2e",
|
|
19
|
+
}],
|
|
41
20
|
});
|
|
42
21
|
}
|
|
43
22
|
|
|
44
|
-
if (
|
|
23
|
+
if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
|
|
45
24
|
return handleChatCompletion(req);
|
|
46
25
|
}
|
|
47
26
|
|
|
48
|
-
return
|
|
27
|
+
return new Response("Not found", { status: 404 });
|
|
49
28
|
},
|
|
50
29
|
});
|
|
51
30
|
|
|
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
31
|
async function handleChatCompletion(req: Request): Promise<Response> {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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,
|
|
32
|
+
const body = await req.json() as {
|
|
33
|
+
model?: string;
|
|
34
|
+
messages?: Array<{ role: string; content: unknown }>;
|
|
35
|
+
tools?: Array<unknown>;
|
|
36
|
+
stream?: boolean;
|
|
137
37
|
};
|
|
138
38
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
}
|
|
39
|
+
const model = body.model ?? "fake/fake-model";
|
|
40
|
+
const isStream = body.stream ?? false;
|
|
41
|
+
const hasTools = (body.tools?.length ?? 0) > 0;
|
|
146
42
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
}
|
|
43
|
+
if (!hasTools) {
|
|
44
|
+
return textResponse(model, "Acknowledged.", isStream);
|
|
45
|
+
}
|
|
175
46
|
|
|
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
47
|
const callId = `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
183
|
-
const
|
|
184
|
-
const usage = {
|
|
185
|
-
prompt_tokens: roughTokens(argsJson),
|
|
186
|
-
completion_tokens: roughTokens(argsJson),
|
|
187
|
-
total_tokens: roughTokens(argsJson) * 2,
|
|
188
|
-
};
|
|
48
|
+
const replyBody = JSON.stringify({ body: "[bot] Processed by fake LLM" });
|
|
189
49
|
|
|
190
|
-
if (!
|
|
191
|
-
return json({
|
|
192
|
-
id
|
|
50
|
+
if (!isStream) {
|
|
51
|
+
return Response.json({
|
|
52
|
+
id: `chatcmpl-fake-${crypto.randomUUID()}`,
|
|
193
53
|
object: "chat.completion",
|
|
194
|
-
created,
|
|
54
|
+
created: Math.floor(Date.now() / 1000),
|
|
195
55
|
model,
|
|
196
|
-
choices: [
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
function: { name: toolName, arguments: argsJson },
|
|
207
|
-
},
|
|
208
|
-
],
|
|
209
|
-
},
|
|
210
|
-
finish_reason: "tool_calls",
|
|
56
|
+
choices: [{
|
|
57
|
+
index: 0,
|
|
58
|
+
message: {
|
|
59
|
+
role: "assistant",
|
|
60
|
+
content: null,
|
|
61
|
+
tool_calls: [{
|
|
62
|
+
id: callId,
|
|
63
|
+
type: "function",
|
|
64
|
+
function: { name: "reply", arguments: replyBody },
|
|
65
|
+
}],
|
|
211
66
|
},
|
|
212
|
-
|
|
213
|
-
|
|
67
|
+
finish_reason: "tool_calls",
|
|
68
|
+
}],
|
|
69
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
214
70
|
});
|
|
215
71
|
}
|
|
216
72
|
|
|
217
73
|
const encoder = new TextEncoder();
|
|
74
|
+
const id = `chatcmpl-fake-${crypto.randomUUID()}`;
|
|
75
|
+
const created = Math.floor(Date.now() / 1000);
|
|
76
|
+
|
|
218
77
|
const readable = new ReadableStream({
|
|
219
78
|
start(controller) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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,
|
|
79
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
80
|
+
id, object: "chat.completion.chunk", created, model,
|
|
81
|
+
choices: [{
|
|
82
|
+
index: 0,
|
|
83
|
+
delta: {
|
|
84
|
+
role: "assistant",
|
|
85
|
+
content: null,
|
|
86
|
+
tool_calls: [{
|
|
87
|
+
index: 0, id: callId, type: "function",
|
|
88
|
+
function: { name: "reply", arguments: "" },
|
|
259
89
|
}],
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
}
|
|
90
|
+
},
|
|
91
|
+
finish_reason: null,
|
|
92
|
+
}],
|
|
93
|
+
})}\n\n`));
|
|
290
94
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
95
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
96
|
+
id, object: "chat.completion.chunk", created, model,
|
|
97
|
+
choices: [{
|
|
98
|
+
index: 0,
|
|
99
|
+
delta: { tool_calls: [{ index: 0, function: { arguments: replyBody } }] },
|
|
100
|
+
finish_reason: null,
|
|
101
|
+
}],
|
|
102
|
+
})}\n\n`));
|
|
294
103
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
for (let i = 0; i < words.length; i += perChunk) {
|
|
301
|
-
chunks.push(words.slice(i, i + perChunk).join(""));
|
|
302
|
-
}
|
|
104
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
105
|
+
id, object: "chat.completion.chunk", created, model,
|
|
106
|
+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
|
107
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
108
|
+
})}\n\n`));
|
|
303
109
|
|
|
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
110
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
349
111
|
controller.close();
|
|
350
112
|
},
|
|
@@ -354,28 +116,29 @@ function streamResponse(model: string, reply: string, usage: any): Response {
|
|
|
354
116
|
headers: {
|
|
355
117
|
"content-type": "text/event-stream",
|
|
356
118
|
"cache-control": "no-cache",
|
|
357
|
-
|
|
358
|
-
"access-control-allow-origin": "*",
|
|
119
|
+
connection: "keep-alive",
|
|
359
120
|
},
|
|
360
121
|
});
|
|
361
122
|
}
|
|
362
123
|
|
|
363
|
-
function
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
124
|
+
function textResponse(model: string, text: string, isStream: boolean): Response {
|
|
125
|
+
if (!isStream) {
|
|
126
|
+
return Response.json({
|
|
127
|
+
id: `chatcmpl-fake-${crypto.randomUUID()}`,
|
|
128
|
+
object: "chat.completion",
|
|
129
|
+
created: Math.floor(Date.now() / 1000),
|
|
130
|
+
model,
|
|
131
|
+
choices: [{
|
|
132
|
+
index: 0,
|
|
133
|
+
message: { role: "assistant", content: text },
|
|
134
|
+
finish_reason: "stop",
|
|
135
|
+
}],
|
|
136
|
+
usage: { prompt_tokens: 5, completion_tokens: 5, total_tokens: 10 },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return new Response("data: [DONE]\n\n", {
|
|
140
|
+
headers: { "content-type": "text/event-stream" },
|
|
141
|
+
});
|
|
372
142
|
}
|
|
373
143
|
|
|
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`);
|
|
144
|
+
process.stderr.write(`[fake-llm] listening on http://${HOST}:${PORT}\n`);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# mock-opencode: Drop-in replacement for `opencode run` in E2E tests.
|
|
3
|
+
#
|
|
4
|
+
# The daemon spawns: opencode run --format json --dir <workdir> [--session <id>] [--model <m>] "<prompt>"
|
|
5
|
+
# This mock:
|
|
6
|
+
# 1. Emits {"sessionID":"ses_mock_<ts>_<pid>"} to stdout (what the daemon parses)
|
|
7
|
+
# 2. Parses the prompt to extract issue ref (owner/repo#number)
|
|
8
|
+
# 3. Posts a [bot] reply via the Gitea API so finishRun sees a recent bot reply (no nudging)
|
|
9
|
+
# 4. Exits 0
|
|
10
|
+
#
|
|
11
|
+
# Env vars used: GITEA_URL, BOT_TOKEN (both set by the daemon's child env)
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
PROMPT=""
|
|
15
|
+
WORKDIR=""
|
|
16
|
+
SESSION_ID=""
|
|
17
|
+
|
|
18
|
+
while [[ $# -gt 0 ]]; do
|
|
19
|
+
case "$1" in
|
|
20
|
+
run) shift ;;
|
|
21
|
+
--format) shift ;;
|
|
22
|
+
json) shift ;;
|
|
23
|
+
--dir) WORKDIR="$2"; shift 2 ;;
|
|
24
|
+
--session) SESSION_ID="$2"; shift 2 ;;
|
|
25
|
+
--model) shift 2 ;;
|
|
26
|
+
*) PROMPT="$1"; shift ;;
|
|
27
|
+
esac
|
|
28
|
+
done
|
|
29
|
+
|
|
30
|
+
# Emit session ID JSON (daemon reads this from stdout)
|
|
31
|
+
SID="ses_mock_$(date +%s)_$$"
|
|
32
|
+
echo "{\"sessionID\":\"$SID\"}"
|
|
33
|
+
|
|
34
|
+
# Try to extract issue ref from the prompt text
|
|
35
|
+
# Format 1 (initial): - Issue: "title" (gitea:owner/repo#number)
|
|
36
|
+
# Format 2 (forward): posted a new comment on owner/repo#number
|
|
37
|
+
ISSUE_REF=""
|
|
38
|
+
if echo "$PROMPT" | grep -qoP 'Issue:.*?\([\w:]+([\w.-]+/[\w.-]+#\d+)\)' 2>/dev/null; then
|
|
39
|
+
ISSUE_REF=$(echo "$PROMPT" | grep -oP 'Issue:.*?\([\w:]+([\w.-]+/[\w.-]+#\d+)\)' | grep -oP '[\w.-]+/[\w.-]+#\d+' | head -1)
|
|
40
|
+
elif echo "$PROMPT" | grep -qoP 'comment on ([\w.-]+/[\w.-]+#\d+)' 2>/dev/null; then
|
|
41
|
+
ISSUE_REF=$(echo "$PROMPT" | grep -oP 'comment on ([\w.-]+/[\w.-]+#\d+)' | grep -oP '[\w.-]+/[\w.-]+#\d+' | head -1)
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
if [[ -z "$ISSUE_REF" || -z "${GITEA_URL:-}" || -z "${BOT_TOKEN:-}" ]]; then
|
|
45
|
+
# Can't post a reply — daemon will nudge, but that's fine for routing tests
|
|
46
|
+
sleep 0.5
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
OWNER=$(echo "$ISSUE_REF" | cut -d'#' -f1 | cut -d'/' -f1)
|
|
51
|
+
REPO=$(echo "$ISSUE_REF" | cut -d'#' -f1 | cut -d'/' -f2-)
|
|
52
|
+
NUMBER=$(echo "$ISSUE_REF" | cut -d'#' -f2)
|
|
53
|
+
|
|
54
|
+
# Simulate work (1s) then post [bot] reply
|
|
55
|
+
sleep 1
|
|
56
|
+
|
|
57
|
+
curl -sf -X POST \
|
|
58
|
+
-H "Authorization: token $BOT_TOKEN" \
|
|
59
|
+
-H "Content-Type: application/json" \
|
|
60
|
+
-d "{\"body\":\"[bot] Mock processed ($OWNER/$REPO#$NUMBER) on $(hostname)\"}" \
|
|
61
|
+
"${GITEA_URL}/api/v1/repos/${OWNER}/${REPO}/issues/${NUMBER}/comments" \
|
|
62
|
+
> /dev/null 2>&1 || true
|
|
63
|
+
|
|
64
|
+
exit 0
|
|
@@ -60,9 +60,15 @@ export async function runAddDaemon(
|
|
|
60
60
|
const eqIdx = line.indexOf("=");
|
|
61
61
|
if (eqIdx === -1) return line;
|
|
62
62
|
if (line.slice(0, eqIdx).trim().startsWith("#")) return line;
|
|
63
|
-
|
|
63
|
+
const key = line.slice(0, eqIdx).trim();
|
|
64
|
+
if (key === "DAEMON_PORT") {
|
|
64
65
|
return `DAEMON_PORT=${newPort}`;
|
|
65
66
|
}
|
|
67
|
+
if (key === "DAEMON_ENDPOINT") {
|
|
68
|
+
const oldVal = line.slice(eqIdx + 1).trim();
|
|
69
|
+
const host = oldVal.split(":")[0] || "127.0.0.1";
|
|
70
|
+
return `DAEMON_ENDPOINT=${host}:${newPort}`;
|
|
71
|
+
}
|
|
66
72
|
return line;
|
|
67
73
|
});
|
|
68
74
|
const body = serializeEnvFile(
|