@tiens.nguyen/gu-cli 1.0.832 → 1.0.834
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/README.md +1 -0
- package/browser-bridge.mjs +274 -0
- package/browser-command.mjs +116 -0
- package/browser-extension/README.md +111 -0
- package/browser-extension/background.js +483 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +35 -0
- package/browser-extension/popup.html +22 -0
- package/browser-extension/popup.js +22 -0
- package/gonext_agent_chat.py +339 -19
- package/gu-cli.mjs +3 -0
- package/gu-repl.mjs +66 -2
- package/gu_browser.py +263 -0
- package/gu_test_digest.py +352 -0
- package/package.json +10 -1
- package/shell-details.mjs +58 -4
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Test-result digest for run_command (task #362, GitHub #142) — PURE.
|
|
2
|
+
|
|
3
|
+
THE MODEL READ TEST OUTPUT THROUGH `| tail` AND `| grep`. Measured 2026-09-23 → 10-14 in local
|
|
4
|
+
Mongo: 84 of 397 turns ran tests, 32 of them spent 4+ steps doing it, and test output was piped
|
|
5
|
+
into tail/head/grep to find the result — the pipe that hid `No module named pytest` and let a
|
|
6
|
+
commit go in untested (#140 review). The answer is not a smaller window onto the output; it is
|
|
7
|
+
the result first: the counts, then each failing test with its message and file:line, computed
|
|
8
|
+
from the WHOLE output (the observation itself is capped, a digest never is).
|
|
9
|
+
|
|
10
|
+
One parser per runner, each recognising its own summary line, so a line that runs two runners
|
|
11
|
+
(`python3 -m unittest … && node --test`, this repo's npm test) gets both. Nothing recognised →
|
|
12
|
+
"" and the output is shown exactly as before. Names are the ones the runner prints, which are the
|
|
13
|
+
ones its run-one-test form takes (unittest's dotted id, pytest's node id, go's TestName…).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
MAX_FAILURES = 10 # per runner; the rest are counted
|
|
21
|
+
MAX_MESSAGE = 220 # chars of one failure's message
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _clip(text: str, n: int = MAX_MESSAGE) -> str:
|
|
25
|
+
# Line breaks become one space; runs of spaces INSIDE a line stay — 'GU AGENT' vs 'GU AGENT'
|
|
26
|
+
# is the whole failure, and collapsing it printed two identical strings.
|
|
27
|
+
text = re.sub(r"[ \t]*\n[ \t]*", " ", (text or "").strip())
|
|
28
|
+
return text if len(text) <= n else text[: n - 1] + "…"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _rel(path: str, cwd: str) -> str:
|
|
32
|
+
path = (path or "").strip().strip("'\"")
|
|
33
|
+
if path.startswith("file://"):
|
|
34
|
+
path = path[7:]
|
|
35
|
+
if cwd:
|
|
36
|
+
for base in {cwd, os.path.realpath(cwd)}:
|
|
37
|
+
if base and path.startswith(base.rstrip("/") + "/"):
|
|
38
|
+
return path[len(base.rstrip("/")) + 1:]
|
|
39
|
+
return path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fail(name, message="", where="") -> dict:
|
|
43
|
+
return {"name": _clip(name, 160), "message": _clip(message), "where": where}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---- node --test, spec reporter (the default) ---------------------------------------------------
|
|
47
|
+
_NODE_SPEC_SUM = re.compile(r"^ℹ (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$", re.M)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _node_spec(out: str, cwd: str):
|
|
51
|
+
counts = {}
|
|
52
|
+
for k, v in _NODE_SPEC_SUM.findall(out):
|
|
53
|
+
counts[k] = counts.get(k, 0) + int(v)
|
|
54
|
+
if "tests" not in counts:
|
|
55
|
+
return None
|
|
56
|
+
fails = []
|
|
57
|
+
block = out.split("✖ failing tests:", 1)
|
|
58
|
+
if len(block) == 2:
|
|
59
|
+
# "test at FILE:L:C" / "✖ name (ms)" / indented message lines, until the next "test at".
|
|
60
|
+
for m in re.finditer(r"^test at (\S+?):(\d+):\d+\n✖ (.+?) \([\d.]+m?s\)[^\n]*\n((?:[ \t].*\n?|\n)*)",
|
|
61
|
+
block[1], re.M):
|
|
62
|
+
body = [ln.strip() for ln in m.group(4).splitlines()]
|
|
63
|
+
msg = []
|
|
64
|
+
for ln in body:
|
|
65
|
+
if ln.startswith("at ") or ln.startswith("{") or ln.startswith("generatedMessage"):
|
|
66
|
+
break
|
|
67
|
+
if ln and ln not in ("+ actual - expected", "^"):
|
|
68
|
+
msg.append(ln)
|
|
69
|
+
text = " ".join(msg[:5])
|
|
70
|
+
if text.strip("'\"") == "test failed":
|
|
71
|
+
# A FILE that failed to load: the runner only says 'test failed'; the cause
|
|
72
|
+
# (ERR_MODULE_NOT_FOUND, a SyntaxError) was printed above the summary.
|
|
73
|
+
err = re.search(r"^(\w*Error\b[^\n]*)$", out, re.M)
|
|
74
|
+
text = _rel_in(err.group(1), cwd) if err else text
|
|
75
|
+
fails.append(_fail(m.group(3), text, f"{_rel(m.group(1), cwd)}:{m.group(2)}"))
|
|
76
|
+
else:
|
|
77
|
+
# A file that crashed before any test ran: "✖ tests/c.test.mjs (78ms)", the error above it.
|
|
78
|
+
err = re.search(r"^(\w*Error\b[^\n]*)$", out, re.M)
|
|
79
|
+
for m in re.finditer(r"^✖ (.+?) \([\d.]+m?s\)", out, re.M):
|
|
80
|
+
fails.append(_fail(m.group(1), _rel_in(err.group(1), cwd) if err else ""))
|
|
81
|
+
return {"runner": "node --test", "passed": counts.get("pass", 0), "failed": counts.get("fail", 0),
|
|
82
|
+
"skipped": counts.get("skipped", 0) + counts.get("todo", 0), "errors": counts.get("cancelled", 0),
|
|
83
|
+
"failures": fails}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _rel_in(text: str, cwd: str) -> str:
|
|
87
|
+
"""Absolute paths under cwd inside a message, shortened."""
|
|
88
|
+
if not cwd:
|
|
89
|
+
return text
|
|
90
|
+
for base in {cwd.rstrip("/"), os.path.realpath(cwd).rstrip("/")}:
|
|
91
|
+
text = text.replace("file://" + base + "/", "").replace(base + "/", "")
|
|
92
|
+
return text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---- node --test / anything TAP ------------------------------------------------------------------
|
|
96
|
+
_TAP_SUM = re.compile(r"^# (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$", re.M)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _tap(out: str, cwd: str):
|
|
100
|
+
counts = {}
|
|
101
|
+
for k, v in _TAP_SUM.findall(out):
|
|
102
|
+
counts[k] = counts.get(k, 0) + int(v)
|
|
103
|
+
if "tests" not in counts or "TAP version" not in out and "# Subtest" not in out:
|
|
104
|
+
return None
|
|
105
|
+
fails = []
|
|
106
|
+
for m in re.finditer(r"^[ \t]*not ok \d+ - (.+?)(?: # .*)?\n((?:[ \t]{2,}.*\n?|[ \t]*\n)*)", out, re.M):
|
|
107
|
+
yaml = m.group(2)
|
|
108
|
+
loc = re.search(r"location: '([^']+?):(\d+):\d+'", yaml)
|
|
109
|
+
err = re.search(r"error: \|-?\n((?:[ \t]{4,}.*\n?|[ \t]*\n)+)", yaml) or re.search(r"error: '([^']*)'", yaml)
|
|
110
|
+
msg = ""
|
|
111
|
+
if err:
|
|
112
|
+
msg = " ".join(ln.strip() for ln in err.group(1).splitlines()
|
|
113
|
+
if ln.strip() and ln.strip() not in ("+ actual - expected", "^"))
|
|
114
|
+
fails.append(_fail(m.group(1), msg, f"{_rel(loc.group(1), cwd)}:{loc.group(2)}" if loc else ""))
|
|
115
|
+
return {"runner": "node --test (TAP)", "passed": counts.get("pass", 0), "failed": counts.get("fail", 0),
|
|
116
|
+
"skipped": counts.get("skipped", 0) + counts.get("todo", 0), "errors": counts.get("cancelled", 0),
|
|
117
|
+
"failures": fails}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---- python unittest ------------------------------------------------------------------------------
|
|
121
|
+
def _unittest(out: str, cwd: str):
|
|
122
|
+
ran = re.findall(r"^Ran (\d+) tests? in [\d.]+s\s*$", out, re.M)
|
|
123
|
+
if not ran:
|
|
124
|
+
return None
|
|
125
|
+
total = sum(int(x) for x in ran)
|
|
126
|
+
failures = errors = skipped = 0
|
|
127
|
+
for line in re.findall(r"^(?:OK|FAILED)(?: \((.*)\))?\s*$", out, re.M):
|
|
128
|
+
for k, v in re.findall(r"(\w[\w ]*?)=(\d+)", line or ""):
|
|
129
|
+
k = k.strip()
|
|
130
|
+
if k == "failures":
|
|
131
|
+
failures += int(v)
|
|
132
|
+
elif k == "errors":
|
|
133
|
+
errors += int(v)
|
|
134
|
+
elif k == "skipped":
|
|
135
|
+
skipped += int(v)
|
|
136
|
+
fails = []
|
|
137
|
+
for m in re.finditer(r"^(FAIL|ERROR): (\S+) \((\S+?)\)[^\n]*\n-{20,}\n(.*?)(?=^={20,}|^-{20,}\nRan |\Z)",
|
|
138
|
+
out, re.M | re.S):
|
|
139
|
+
kind, short, dotted, body = m.groups()
|
|
140
|
+
lines = [ln for ln in body.rstrip().splitlines() if ln.strip()]
|
|
141
|
+
msg = lines[-1].strip() if lines else kind
|
|
142
|
+
locs = [(f, ln) for f, ln in re.findall(r'File "([^"]+)", line (\d+)', body)
|
|
143
|
+
if "/lib/python" not in f and "unittest/" not in f]
|
|
144
|
+
where = f"{_rel(locs[-1][0], cwd)}:{locs[-1][1]}" if locs else ""
|
|
145
|
+
fails.append(_fail(dotted if "." in dotted else short, msg, where))
|
|
146
|
+
return {"runner": "unittest", "passed": max(0, total - failures - errors - skipped), "failed": failures,
|
|
147
|
+
"skipped": skipped, "errors": errors, "failures": fails}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- pytest ---------------------------------------------------------------------------------------
|
|
151
|
+
_PYTEST_SUM = re.compile(r"^=+ ((?:\d+ \w+(?:, )?)+) in [\d.]+s(?: \([^)]*\))? =+\s*$", re.M)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _pytest(out: str, cwd: str):
|
|
155
|
+
sums = _PYTEST_SUM.findall(out)
|
|
156
|
+
if not sums:
|
|
157
|
+
return None
|
|
158
|
+
c = {}
|
|
159
|
+
for k_v in ", ".join(sums).split(", "):
|
|
160
|
+
m = re.match(r"(\d+) (\w+)", k_v.strip())
|
|
161
|
+
if m:
|
|
162
|
+
c[m.group(2)] = c.get(m.group(2), 0) + int(m.group(1))
|
|
163
|
+
fails = []
|
|
164
|
+
for m in re.finditer(r"^(FAILED|ERROR) (\S+?)(?: - (.*))?$", out, re.M):
|
|
165
|
+
nodeid = m.group(2)
|
|
166
|
+
loc = re.search(rf"^({re.escape(nodeid.split('::')[0])}):(\d+): ", out, re.M)
|
|
167
|
+
fails.append(_fail(nodeid, m.group(3) or m.group(1), f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
|
|
168
|
+
return {"runner": "pytest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
|
|
169
|
+
"skipped": c.get("skipped", 0) + c.get("xfailed", 0), "errors": c.get("error", 0) + c.get("errors", 0),
|
|
170
|
+
"failures": fails}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---- jest / vitest --------------------------------------------------------------------------------
|
|
174
|
+
def _jest(out: str, cwd: str):
|
|
175
|
+
m = re.search(r"^Tests:\s+(.*\d+ total)\s*$", out, re.M)
|
|
176
|
+
if not m:
|
|
177
|
+
return None
|
|
178
|
+
c = {k: int(v) for v, k in re.findall(r"(\d+) (\w+)", m.group(1))}
|
|
179
|
+
fails = []
|
|
180
|
+
for f in re.finditer(r"^\s*● (.+?)\n\n((?:.*\n){1,6})", out, re.M):
|
|
181
|
+
if f.group(1).startswith("Console"):
|
|
182
|
+
continue
|
|
183
|
+
msg = next((ln.strip() for ln in f.group(2).splitlines() if ln.strip()), "")
|
|
184
|
+
loc = re.search(r"\(([^()\s]+?):(\d+):\d+\)", f.group(2)) or re.search(r"at .*?([\w./-]+\.\w+):(\d+):\d+", out)
|
|
185
|
+
fails.append(_fail(f.group(1).replace(" › ", " > "), msg, f"{_rel(loc.group(1), cwd)}:{loc.group(2)}" if loc else ""))
|
|
186
|
+
return {"runner": "jest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
|
|
187
|
+
"skipped": c.get("skipped", 0) + c.get("todo", 0), "errors": 0, "failures": fails}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _vitest(out: str, cwd: str):
|
|
191
|
+
m = re.search(r"^\s*Tests\s+(.*?)\s*\((\d+)\)\s*$", out, re.M)
|
|
192
|
+
if not m or "|" not in m.group(1) and not re.search(r"\d+ (passed|failed)", m.group(1)):
|
|
193
|
+
return None
|
|
194
|
+
c = {k: int(v) for v, k in re.findall(r"(\d+) (\w+)", m.group(1))}
|
|
195
|
+
fails = []
|
|
196
|
+
for f in re.finditer(r"^[ \t]*FAIL[ \t]+(\S+) > (.+?)\n((?:.*\n){0,4})", out, re.M):
|
|
197
|
+
msg = next((ln.strip() for ln in f.group(3).splitlines() if re.search(r"Error|expected", ln)), "")
|
|
198
|
+
loc = re.search(r"❯ (\S+?):(\d+):\d+", f.group(3))
|
|
199
|
+
fails.append(_fail(f"{f.group(1)} > {f.group(2).strip()}", msg,
|
|
200
|
+
f"{loc.group(1)}:{loc.group(2)}" if loc else f.group(1)))
|
|
201
|
+
if not fails: # no "Failed Tests" section (a reporter without it): the × lines
|
|
202
|
+
for f in re.finditer(r"^[ \t]*× (.+?)(?:[ \t]+\d+ms)?\n[ \t]*→ (.*)$", out, re.M):
|
|
203
|
+
fails.append(_fail(f.group(1), f.group(2)))
|
|
204
|
+
return {"runner": "vitest", "passed": c.get("passed", 0), "failed": c.get("failed", 0),
|
|
205
|
+
"skipped": c.get("skipped", 0) + c.get("todo", 0), "errors": 0, "failures": fails}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---- go test ---------------------------------------------------------------------------------------
|
|
209
|
+
def _gotest(out: str, cwd: str):
|
|
210
|
+
pkgs = re.findall(r"^(ok|FAIL|---? FAIL)\s+(\S+)\s+(?:[\d.]+s|\(cached\)|\[)", out, re.M)
|
|
211
|
+
fails_raw = list(re.finditer(r"^[ \t]*--- FAIL: (\S+) \([\d.]+s\)", out, re.M))
|
|
212
|
+
if not pkgs and not fails_raw:
|
|
213
|
+
return None
|
|
214
|
+
passed = len(re.findall(r"^\s*--- PASS: ", out, re.M))
|
|
215
|
+
skipped = len(re.findall(r"^\s*--- SKIP: ", out, re.M))
|
|
216
|
+
fails = []
|
|
217
|
+
for f in fails_raw:
|
|
218
|
+
# Since Go 1.14 a test's t.Error lines are printed between its "=== RUN" and "--- FAIL".
|
|
219
|
+
run = re.search(rf"^=== RUN\s+{re.escape(f.group(1))}\n((?:[ \t]{{4,}}.*\n)*)", out, re.M)
|
|
220
|
+
first = next((ln.strip() for ln in (run.group(1) if run else "").splitlines() if ln.strip()), "")
|
|
221
|
+
loc = re.match(r"([\w./-]+\.go):(\d+): (.*)", first)
|
|
222
|
+
fails.append(_fail(f.group(1), loc.group(3) if loc else first, f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
|
|
223
|
+
ok_pkgs = sum(1 for k, _ in pkgs if k == "ok")
|
|
224
|
+
bad_pkgs = sum(1 for k, _ in pkgs if k == "FAIL")
|
|
225
|
+
return {"runner": "go test", "passed": passed, "failed": len(fails_raw) or bad_pkgs, "skipped": skipped,
|
|
226
|
+
"errors": 0, "failures": fails,
|
|
227
|
+
"note": f"{ok_pkgs} package(s) ok, {bad_pkgs} FAIL" if pkgs else ""}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---- cargo test ------------------------------------------------------------------------------------
|
|
231
|
+
def _cargo(out: str, cwd: str):
|
|
232
|
+
res = re.findall(r"^test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored", out, re.M)
|
|
233
|
+
if not res:
|
|
234
|
+
return None
|
|
235
|
+
p = sum(int(a) for a, _, _ in res)
|
|
236
|
+
f = sum(int(b) for _, b, _ in res)
|
|
237
|
+
s = sum(int(c) for _, _, c in res)
|
|
238
|
+
fails = []
|
|
239
|
+
for m in re.finditer(r"^---- (\S+) stdout ----\n((?:.*\n){1,4})", out, re.M):
|
|
240
|
+
body = m.group(2)
|
|
241
|
+
loc = re.search(r"panicked at ([^\s:]+):(\d+):\d+", body)
|
|
242
|
+
msg = [ln.strip() for ln in body.splitlines() if ln.strip() and "panicked at" not in ln]
|
|
243
|
+
fails.append(_fail(m.group(1), msg[0] if msg else "panicked", f"{loc.group(1)}:{loc.group(2)}" if loc else ""))
|
|
244
|
+
return {"runner": "cargo test", "passed": p, "failed": f, "skipped": s, "errors": 0, "failures": fails}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---- tsc / eslint (checks, not tests — same shape: count, then the first problems) ----------------
|
|
248
|
+
def _tsc(out: str, cwd: str):
|
|
249
|
+
errs = re.findall(r"^(.+?)\((\d+),\d+\): error (TS\d+): (.+)$", out, re.M)
|
|
250
|
+
if not errs:
|
|
251
|
+
return None
|
|
252
|
+
fails = [_fail(code, msg, f"{_rel(f, cwd)}:{ln}") for f, ln, code, msg in errs]
|
|
253
|
+
return {"runner": "tsc", "check": True, "errors": len(errs),
|
|
254
|
+
"files": len({e[0] for e in errs}), "failures": fails}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _eslint(out: str, cwd: str):
|
|
258
|
+
m = re.search(r"^✖ (\d+) problems? \((\d+) errors?, (\d+) warnings?\)", out, re.M)
|
|
259
|
+
if not m:
|
|
260
|
+
return None
|
|
261
|
+
fails, cur = [], ""
|
|
262
|
+
for ln in out.splitlines():
|
|
263
|
+
if ln and not ln.startswith(" ") and not ln.startswith("✖"):
|
|
264
|
+
cur = _rel(ln.strip(), cwd)
|
|
265
|
+
continue
|
|
266
|
+
e = re.match(r"^\s+(\d+):\d+\s+error\s+(.+?)\s{2,}(\S+)\s*$", ln)
|
|
267
|
+
if e and cur:
|
|
268
|
+
fails.append(_fail(e.group(3), e.group(2), f"{cur}:{e.group(1)}"))
|
|
269
|
+
return {"runner": "eslint", "check": True, "errors": int(m.group(2)), "warnings": int(m.group(3)),
|
|
270
|
+
"failures": fails}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
_PARSERS = (_unittest, _pytest, _node_spec, _tap, _jest, _vitest, _gotest, _cargo, _tsc, _eslint)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def parse(output: str, cwd: str = "") -> list[dict]:
|
|
277
|
+
"""Every runner whose summary appears in `output`, in that order. Never raises."""
|
|
278
|
+
found = []
|
|
279
|
+
for p in _PARSERS:
|
|
280
|
+
try:
|
|
281
|
+
r = p(output or "", cwd)
|
|
282
|
+
except Exception: # noqa: BLE001 — a digest must never break a command result
|
|
283
|
+
r = None
|
|
284
|
+
if r:
|
|
285
|
+
found.append(r)
|
|
286
|
+
return found
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _counts(r: dict) -> str:
|
|
290
|
+
if r.get("check"):
|
|
291
|
+
bits = [f"{r['errors']} error{'s' if r['errors'] != 1 else ''}"]
|
|
292
|
+
if r.get("warnings"):
|
|
293
|
+
bits.append(f"{r['warnings']} warning{'s' if r['warnings'] != 1 else ''}")
|
|
294
|
+
return " · ".join(bits)
|
|
295
|
+
bits = [f"{r['passed']} passed"]
|
|
296
|
+
if r["failed"]:
|
|
297
|
+
bits.append(f"{r['failed']} failed")
|
|
298
|
+
if r.get("errors"):
|
|
299
|
+
bits.append(f"{r['errors']} error{'s' if r['errors'] != 1 else ''}")
|
|
300
|
+
if r.get("skipped"):
|
|
301
|
+
bits.append(f"{r['skipped']} skipped")
|
|
302
|
+
if r.get("note"):
|
|
303
|
+
bits.append(r["note"])
|
|
304
|
+
return " · ".join(bits)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def digest(output: str, cwd: str = "") -> str:
|
|
308
|
+
"""The block run_command puts ABOVE the output: counts per runner, then each failure as
|
|
309
|
+
`✗ name — message (file:line)`. "" when no runner was recognised."""
|
|
310
|
+
runs = parse(output, cwd)
|
|
311
|
+
if not runs:
|
|
312
|
+
return ""
|
|
313
|
+
bad = [r for r in runs if r.get("failed") or r.get("errors") or r.get("failures")]
|
|
314
|
+
if not bad:
|
|
315
|
+
return "TEST SUMMARY: " + " · ".join(f"{r['runner']} {_counts(r)}" for r in runs) + " — all passed."
|
|
316
|
+
lines = ["TEST SUMMARY (from the whole output — read this before the log below):"]
|
|
317
|
+
for r in runs:
|
|
318
|
+
lines.append(f" {r['runner']}: {_counts(r)}")
|
|
319
|
+
for f in r["failures"][:MAX_FAILURES]:
|
|
320
|
+
tail = f" ({f['where']})" if f["where"] else ""
|
|
321
|
+
lines.append(f" ✗ {f['name']}" + (f" — {f['message']}" if f["message"] else "") + tail)
|
|
322
|
+
if len(r["failures"]) > MAX_FAILURES:
|
|
323
|
+
lines.append(f" … and {len(r['failures']) - MAX_FAILURES} more")
|
|
324
|
+
return "\n".join(lines)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---- running ONE test ------------------------------------------------------------------------------
|
|
328
|
+
# Per runner, the form that runs a single test — what the model needs after the first full run, so
|
|
329
|
+
# a fix is checked in seconds and the suite runs once at the end. Keyed on what the project's own
|
|
330
|
+
# test command contains.
|
|
331
|
+
ONE_TEST = (
|
|
332
|
+
(r"\bnode --test\b", "node --test --test-name-pattern='<name>' <file>"),
|
|
333
|
+
(r"\bunittest\b", "python3 -m unittest <module>.<Class>.<test_name>"),
|
|
334
|
+
(r"\bpytest\b", "python3 -m pytest '<file>::<test>' -x"),
|
|
335
|
+
(r"\bvitest\b", "npx vitest run <file> -t '<name>'"),
|
|
336
|
+
(r"\bjest\b", "npx jest <file> -t '<name>'"),
|
|
337
|
+
(r"\bgo test\b", "go test -run '^<TestName>$' ./<pkg>"),
|
|
338
|
+
(r"\bcargo test\b", "cargo test <name>"),
|
|
339
|
+
(r"\bmvn\b.*\btest\b", "mvn test -Dtest=<Class>#<method>"),
|
|
340
|
+
(r"\bgradlew?\b.*\btest\b", "./gradlew test --tests '<Class.method>'"),
|
|
341
|
+
(r"\brspec\b", "bundle exec rspec <file>:<line>"),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def one_test_forms(commands) -> list[str]:
|
|
346
|
+
"""Distinct run-one-test forms for the runners named in these test commands."""
|
|
347
|
+
seen = []
|
|
348
|
+
for cmd in commands or []:
|
|
349
|
+
for pat, form in ONE_TEST:
|
|
350
|
+
if re.search(pat, cmd or "") and form not in seen:
|
|
351
|
+
seen.append(form)
|
|
352
|
+
return seen
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.834",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -125,6 +125,13 @@
|
|
|
125
125
|
"skills.mjs",
|
|
126
126
|
"skill-source.mjs",
|
|
127
127
|
"shell-details.mjs",
|
|
128
|
+
"browser-command.mjs",
|
|
129
|
+
"browser-bridge.mjs",
|
|
130
|
+
"browser-extension/manifest.json",
|
|
131
|
+
"browser-extension/background.js",
|
|
132
|
+
"browser-extension/popup.html",
|
|
133
|
+
"browser-extension/popup.js",
|
|
134
|
+
"browser-extension/icons/",
|
|
128
135
|
"step-group.mjs",
|
|
129
136
|
"mcp-config.mjs",
|
|
130
137
|
"mcp-command.mjs",
|
|
@@ -135,6 +142,8 @@
|
|
|
135
142
|
"gonext_agent_chat.py",
|
|
136
143
|
"gu_mcp.py",
|
|
137
144
|
"gu_skills.py",
|
|
145
|
+
"gu_test_digest.py",
|
|
146
|
+
"gu_browser.py",
|
|
138
147
|
"gu_hooks.py",
|
|
139
148
|
"prompt_scaffold_check.py",
|
|
140
149
|
"gonext_transcribe.py",
|
package/shell-details.mjs
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// read from the same two facts the agent uses: is the process group alive, and has the shell
|
|
12
12
|
// written its exit file.
|
|
13
13
|
|
|
14
|
-
import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { isAbsolute, join, normalize } from "node:path";
|
|
16
|
+
import { basename, dirname, isAbsolute, join, normalize } from "node:path";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
|
|
19
19
|
export const shellsPath = (home = homedir()) => join(home, ".gonext", "shells.json");
|
|
@@ -63,7 +63,7 @@ export function shellState(rec, { alive, exit, now = Date.now() / 1000 } = {}) {
|
|
|
63
63
|
if (live && !ex && !rec?.killed) return { state: "running", code: null, runtime: Math.max(0, now - started), ended: now };
|
|
64
64
|
const ended = Number(rec?.ended) || ex?.at || now;
|
|
65
65
|
const state = rec?.killed ? "killed" : !ex ? "ended" : ex.code === 0 ? "exited" : "failed";
|
|
66
|
-
return { state, code: ex ? ex.code : null, runtime: Math.max(0, ended - started), ended };
|
|
66
|
+
return { state, code: ex ? ex.code : null, runtime: Math.max(0, ended - started), ended, by: String(rec?.killed_by ?? "") };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** 7m 2s — Claude Code's spelling. */
|
|
@@ -79,7 +79,10 @@ export function fmtRuntime(seconds) {
|
|
|
79
79
|
export function statusText(st) {
|
|
80
80
|
if (st.state === "running") return "running";
|
|
81
81
|
if (st.state === "exited" || st.state === "failed") return `exited ${st.code}`;
|
|
82
|
-
|
|
82
|
+
// Who stopped it (#362/#143): a bare "killed" read as a crash when the agent had stopped its own
|
|
83
|
+
// search on purpose.
|
|
84
|
+
if (st.state === "killed") return st.by ? `killed — stopped by ${st.by}` : "killed";
|
|
85
|
+
return "ended (exit code unknown)";
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/**
|
|
@@ -250,8 +253,59 @@ export async function killShell(rec, { path = shellsPath(), platform = process.p
|
|
|
250
253
|
const mine = shells.find((s) => String(s.id) === String(rec.id));
|
|
251
254
|
if (!mine || readExit(mine)) return; // it finished on its own first: keep the real exit code
|
|
252
255
|
mine.killed = true;
|
|
256
|
+
mine.killed_by = "you (Stop it in Shell details)";
|
|
257
|
+
mine.announced = true; // you stopped it — no recap turn for it
|
|
253
258
|
mine.ended = Date.now() / 1000;
|
|
254
259
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
255
260
|
writeFileSync(tmp, JSON.stringify({ shells }, null, 2));
|
|
256
261
|
renameSync(tmp, path);
|
|
257
262
|
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Shells whose ending the agent has not heard about (#362) — Claude Code tells its model when a
|
|
266
|
+
* background command completes; gu's terminal starts a short recap turn for these. Only this
|
|
267
|
+
* folder's shells (shells.json is shared by every project), only ones that ended while this
|
|
268
|
+
* terminal was open, never one the agent already read (`seen`) or was told about (`announced`),
|
|
269
|
+
* and never one you stopped yourself.
|
|
270
|
+
*/
|
|
271
|
+
export function shellsToRecap(entries, { cwd = process.cwd(), since = 0 } = {}) {
|
|
272
|
+
// The nearest folder that still exists, resolved (macOS: /var → /private/var), plus the rest —
|
|
273
|
+
// a build may have deleted the folder it ran in.
|
|
274
|
+
const real = (p) => {
|
|
275
|
+
const raw = String(p ?? "");
|
|
276
|
+
if (!raw) return raw;
|
|
277
|
+
try { return realpathSync(raw); } catch { /* gone */ }
|
|
278
|
+
const up = dirname(raw);
|
|
279
|
+
return up === raw ? raw : join(real(up), basename(raw));
|
|
280
|
+
};
|
|
281
|
+
const base = real(cwd).replace(/\/+$/, "");
|
|
282
|
+
const under = (wd) => {
|
|
283
|
+
const w = real(wd);
|
|
284
|
+
return w === base || w.startsWith(base + "/");
|
|
285
|
+
};
|
|
286
|
+
return entries
|
|
287
|
+
.filter(({ rec, st }) => st.state !== "running" && st.state !== "killed" && !rec.seen && !rec.announced
|
|
288
|
+
&& st.ended >= since && under(rec.workdir))
|
|
289
|
+
.map((e) => e.rec);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Mark these shells announced in shells.json, so neither the agent nor another terminal repeats it. */
|
|
293
|
+
export function markAnnounced(ids, { path = shellsPath() } = {}) {
|
|
294
|
+
const shells = readShells(path);
|
|
295
|
+
let n = 0;
|
|
296
|
+
for (const s of shells) if (ids.includes(String(s.id)) && !s.announced) { s.announced = true; n++; }
|
|
297
|
+
if (!n) return 0;
|
|
298
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
299
|
+
writeFileSync(tmp, JSON.stringify({ shells }, null, 2));
|
|
300
|
+
renameSync(tmp, path);
|
|
301
|
+
return n;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The recap question the terminal asks for finished shells. */
|
|
305
|
+
export function recapQuestion(entries) {
|
|
306
|
+
const parts = entries.map(({ rec, st }) =>
|
|
307
|
+
`shell ${rec.id} ${statusText(st)} after ${fmtRuntime(st.runtime)} (${String(rec.command ?? "").replace(/\s+/g, " ").trim().slice(0, 120)})`);
|
|
308
|
+
const ids = entries.map(({ rec }) => `shell_output('${rec.id}')`).join(", ");
|
|
309
|
+
return `[gu: background ${entries.length === 1 ? "shell" : "shells"} finished — ${parts.join("; ")}] `
|
|
310
|
+
+ `Read the result with ${ids}, tell me briefly what happened, and continue with the next step if the task needs one.`;
|
|
311
|
+
}
|