@tiens.nguyen/gu-cli 1.0.831 → 1.0.833
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/gonext_agent_chat.py +272 -19
- package/gu-repl.mjs +12 -2
- package/gu_test_digest.py +352 -0
- package/package.json +2 -1
- package/shell-details.mjs +36 -3
package/gonext_agent_chat.py
CHANGED
|
@@ -7711,13 +7711,13 @@ def _shell_commands(tokens):
|
|
|
7711
7711
|
while i < len(tokens):
|
|
7712
7712
|
kind, text, a, b, quoted = tokens[i]
|
|
7713
7713
|
if kind == "op" and text in _SHELL_SEPARATORS:
|
|
7714
|
-
if cur and (cur["argv"] or cur["redirects"]):
|
|
7714
|
+
if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
|
|
7715
7715
|
cmds.append(cur)
|
|
7716
7716
|
cur, sep = None, text
|
|
7717
7717
|
i += 1
|
|
7718
7718
|
continue
|
|
7719
7719
|
if cur is None:
|
|
7720
|
-
cur = {"argv": [], "spans": [], "sep": sep, "redirects": []}
|
|
7720
|
+
cur = {"argv": [], "spans": [], "sep": sep, "redirects": [], "assigns": []}
|
|
7721
7721
|
if kind == "op":
|
|
7722
7722
|
nxt = tokens[i + 1] if i + 1 < len(tokens) else None
|
|
7723
7723
|
tgt = nxt[1] if nxt and nxt[0] == "word" else ""
|
|
@@ -7725,16 +7725,73 @@ def _shell_commands(tokens):
|
|
|
7725
7725
|
i += 2 if tgt or (nxt and nxt[0] == "word") else 1
|
|
7726
7726
|
continue
|
|
7727
7727
|
if not cur["argv"] and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", text):
|
|
7728
|
+
cur["assigns"].append(tuple(text.split("=", 1)))
|
|
7728
7729
|
i += 1
|
|
7729
7730
|
continue
|
|
7730
7731
|
cur["argv"].append(text)
|
|
7731
7732
|
cur["spans"].append((a, b))
|
|
7732
7733
|
i += 1
|
|
7733
|
-
if cur and (cur["argv"] or cur["redirects"]):
|
|
7734
|
+
if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
|
|
7734
7735
|
cmds.append(cur)
|
|
7735
7736
|
return cmds
|
|
7736
7737
|
|
|
7737
7738
|
|
|
7739
|
+
# A RUNNER IN A VARIABLE THAT THE LINE ITSELF SET (#362). `U=/Applications/…/Unity && $U -batchmode
|
|
7740
|
+
# -runTests …` asked "the program to run is only known at run time" — but it is known: the same
|
|
7741
|
+
# line assigned it a literal. The check now resolves that case and judges the REAL program, by
|
|
7742
|
+
# re-scanning a copy of the line with the literal in place — so every rule (privilege, denylist,
|
|
7743
|
+
# allowlist, deletes) sees it: `S=sudo && $S rm -rf /` is still sudo. Only a top-level assignment
|
|
7744
|
+
# whose value has no $, `, glob or leading ~, made before the use and not inside a pipeline (a
|
|
7745
|
+
# subshell); anything else keeps asking. bash still runs the line exactly as written.
|
|
7746
|
+
# Anything else that can set or clear a variable makes the value unknowable from the text.
|
|
7747
|
+
_SHELL_VAR_CHANGERS = {"unset", "read", "declare", "typeset", "local", "readonly", "source", ".",
|
|
7748
|
+
"eval", "for", "select", "mapfile", "readarray", "getopts", "printf", "let"}
|
|
7749
|
+
_SHELL_VAR_RUNNER_RE = re.compile(r"^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$")
|
|
7750
|
+
|
|
7751
|
+
|
|
7752
|
+
def _shell_literal_value(v: str) -> bool:
|
|
7753
|
+
return bool(v) and not re.search(r"[$`*?\[]", v) and not v.startswith("~")
|
|
7754
|
+
|
|
7755
|
+
|
|
7756
|
+
def _shell_resolve_runner_vars(command: str, scan) -> str:
|
|
7757
|
+
"""`command` with each top-level `$X` runner replaced by X's literal value, or "" when there
|
|
7758
|
+
is nothing this line can resolve. For CHECKING only — never executed."""
|
|
7759
|
+
import shlex as _shlex
|
|
7760
|
+
if not scan or scan.get("error"):
|
|
7761
|
+
return ""
|
|
7762
|
+
cmds = _shell_commands(scan["tokens"])
|
|
7763
|
+
if any(c["argv"] and _shell_prog_name(c["argv"][0]) in _SHELL_VAR_CHANGERS for c in cmds):
|
|
7764
|
+
return ""
|
|
7765
|
+
env, edits = {}, []
|
|
7766
|
+
for k, c in enumerate(cmds):
|
|
7767
|
+
in_pipe = c["sep"] in ("|", "|&") or (k + 1 < len(cmds) and cmds[k + 1]["sep"] in ("|", "|&"))
|
|
7768
|
+
a = c["argv"]
|
|
7769
|
+
pairs = []
|
|
7770
|
+
if not a:
|
|
7771
|
+
pairs = list(c.get("assigns") or [])
|
|
7772
|
+
elif a[0] == "export":
|
|
7773
|
+
pairs = [tuple(x.split("=", 1)) for x in a[1:] if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", x)]
|
|
7774
|
+
if pairs:
|
|
7775
|
+
for name, val in pairs:
|
|
7776
|
+
if not in_pipe and _shell_literal_value(val):
|
|
7777
|
+
env[name] = val
|
|
7778
|
+
else:
|
|
7779
|
+
env.pop(name, None)
|
|
7780
|
+
continue
|
|
7781
|
+
m = _SHELL_VAR_RUNNER_RE.match(a[0]) if a else None
|
|
7782
|
+
if m and (m.group(1) or m.group(2)) in env:
|
|
7783
|
+
start, end = c["spans"][0]
|
|
7784
|
+
val = env[m.group(1) or m.group(2)]
|
|
7785
|
+
# "$U" is one word; a bare $U is WORD-SPLIT by bash — `U="sudo rm" && $U -rf /` runs
|
|
7786
|
+
# sudo. Substituted raw, the re-scan splits it the same way and sees sudo.
|
|
7787
|
+
edits.append(((start, end), _shlex.quote(val) if command[start:end].startswith('"') else val))
|
|
7788
|
+
if not edits:
|
|
7789
|
+
return ""
|
|
7790
|
+
for (start, end), val in sorted(edits, reverse=True):
|
|
7791
|
+
command = command[:start] + val + command[end:]
|
|
7792
|
+
return command
|
|
7793
|
+
|
|
7794
|
+
|
|
7738
7795
|
def _shell_needed(scan):
|
|
7739
7796
|
"""Does this line need a shell to mean what it says? False → run it as argv, as before."""
|
|
7740
7797
|
return bool(scan["heredocs"] or scan["substs"] or scan["expands"]
|
|
@@ -7967,7 +8024,9 @@ def _run_command_prompt_line(shell, windows=None):
|
|
|
7967
8024
|
"interactive input() CLI fails with EOFError. A server or GUI app is left RUNNING — "
|
|
7968
8025
|
"stop_server closes it. " + _BG_LINE)
|
|
7969
8026
|
return (
|
|
7970
|
-
" run_command IS A SHELL (bash)
|
|
8027
|
+
" run_command IS A SHELL (bash), and it KEEPS its folder and exported variables from one "
|
|
8028
|
+
"call to the next in this turn (`cd sub`, then `npm test` runs in sub; an explicit workdir "
|
|
8029
|
+
"wins). Pipes, redirects, `;`, `||`, `$( )`, globs and heredocs "
|
|
7971
8030
|
"work, so put a whole sequence in ONE call — dependent steps with &&, independent ones with "
|
|
7972
8031
|
"`;` (e.g. `cd app && npm test 2>&1 | tail -40`); a multi-line command goes in a "
|
|
7973
8032
|
"triple-quoted string.\n"
|
|
@@ -7980,6 +8039,13 @@ def _run_command_prompt_line(shell, windows=None):
|
|
|
7980
8039
|
" GITHUB: use `gh`. Multi-line text goes as a heredoc on stdin — "
|
|
7981
8040
|
"`gh issue comment 12 --body-file - <<'EOF'` … `EOF` (same for gh issue create / gh pr "
|
|
7982
8041
|
"create) — never write a body file first, and do not run `gh auth status` first.\n"
|
|
8042
|
+
# #362: how Claude Code checks work — scripts as heredocs, the one function not the app,
|
|
8043
|
+
# cheapest check first, and never a pipe between a test run and the decision it feeds.
|
|
8044
|
+
" CHECKS: a Python/Node script goes as a heredoc — `python3 - <<'EOF'` … `EOF` (node: "
|
|
8045
|
+
"`node --input-type=module - <<'EOF'`) — never `python3 -c` built from joined strings. To "
|
|
8046
|
+
"check one function, load its module and call it with real input instead of running the "
|
|
8047
|
+
"app. Order: syntax check → the ONE test → the suite once. Test output arrives with a TEST "
|
|
8048
|
+
"SUMMARY of each failure first, so never pipe it into tail/grep (a pipe hides the exit code).\n"
|
|
7983
8049
|
" Read and search FILES with read_file_lines / grep_repo, not cat/grep/sed. `sudo`/`su` "
|
|
7984
8050
|
"are blocked; piping into sh/python and writing or deleting outside the project ask the "
|
|
7985
8051
|
"user first; an interactive input() CLI fails with EOFError. A server or GUI app is left "
|
|
@@ -8099,9 +8165,28 @@ def _test_commands_line(root: str) -> str:
|
|
|
8099
8165
|
cmds = _project_test_commands(root) if root else []
|
|
8100
8166
|
if not cmds:
|
|
8101
8167
|
return ""
|
|
8102
|
-
|
|
8168
|
+
line = (" TEST COMMANDS, from this project's own manifests — use these, never guess a runner "
|
|
8103
8169
|
"(a missing pytest fails silently behind a pipe): "
|
|
8104
8170
|
+ "; ".join(f"{d} → {c}" for d, c in cmds) + "\n")
|
|
8171
|
+
try:
|
|
8172
|
+
import gu_test_digest as _td
|
|
8173
|
+
forms = _td.one_test_forms(c for _, c in cmds)
|
|
8174
|
+
except Exception: # noqa: BLE001
|
|
8175
|
+
forms = []
|
|
8176
|
+
if forms:
|
|
8177
|
+
# #362: check a fix with ONE test (seconds), run the suite once at the end.
|
|
8178
|
+
line += (" ONE TEST while fixing (the suite once at the end): " + " · ".join(forms) + "\n")
|
|
8179
|
+
return line
|
|
8180
|
+
|
|
8181
|
+
|
|
8182
|
+
def _test_digest(output: str, cwd: str = "") -> str:
|
|
8183
|
+
"""gu_test_digest.digest, or "" if the module is missing — a summary must never fail a command."""
|
|
8184
|
+
try:
|
|
8185
|
+
import gu_test_digest as _td
|
|
8186
|
+
return _td.digest(output or "", cwd or "")
|
|
8187
|
+
except Exception as e: # noqa: BLE001
|
|
8188
|
+
_log(f"test digest skip: {e}")
|
|
8189
|
+
return ""
|
|
8105
8190
|
|
|
8106
8191
|
|
|
8107
8192
|
# bash's words for a line whose quotes do not pair up.
|
|
@@ -8994,10 +9079,10 @@ def _ws_cap_servers(max_live: int = 3) -> list:
|
|
|
8994
9079
|
# running" link and the Shell details view (shell-details.mjs).
|
|
8995
9080
|
#
|
|
8996
9081
|
# THE EXIT CODE OUTLIVES THE AGENT. The agent is one process per turn; a build that ends after the
|
|
8997
|
-
# turn has no parent left to wait() on it. So a shell line runs as `
|
|
8998
|
-
#
|
|
8999
|
-
#
|
|
9000
|
-
# has no such wrapper and says "exit code unknown" instead of guessing.
|
|
9082
|
+
# turn has no parent left to wait() on it. So a shell line runs as `eval "$1"` under an EXIT trap
|
|
9083
|
+
# that writes its exit code to `exit_file` AND exits with it (without that every failure read as
|
|
9084
|
+
# EXITED 0) — see _run_state_wrapper, which also keeps the folder and exports for the next call.
|
|
9085
|
+
# The argv-only path has no such wrapper and says "exit code unknown" instead of guessing.
|
|
9001
9086
|
_SHELLS_KEEP = 20 # newest records kept, running or not
|
|
9002
9087
|
_SHELLS_KEEP_DONE_S = 86400 # a finished shell is listed for a day
|
|
9003
9088
|
|
|
@@ -9164,6 +9249,55 @@ def _shell_or_server(shells: list, servers: list, key: str):
|
|
|
9164
9249
|
return max(cands, key=lambda x: float(x.get("started") or 0)) if cands else None
|
|
9165
9250
|
|
|
9166
9251
|
|
|
9252
|
+
# A SHELL THAT WRITES ITS OWN LOG (#362). Unity `-batchmode … -logFile Logs/all.log` prints nothing
|
|
9253
|
+
# for its whole 5-minute run — everything goes to that file — so the shell's output read "No output
|
|
9254
|
+
# available" until the end. When a shell has no output of its own, the file its command names is
|
|
9255
|
+
# the next best thing: an explicit log flag first, then `tee`, then the last `>`/`>>` redirect.
|
|
9256
|
+
_LOG_FLAG_RE = re.compile(r"^--?(?:log-?file|logfile|log-path|log)(?:=(.+))?$", re.I)
|
|
9257
|
+
|
|
9258
|
+
|
|
9259
|
+
def _shell_log_file(command: str, workdir: str = "") -> str:
|
|
9260
|
+
"""The log file a command line writes to, as an absolute path that exists — or ""."""
|
|
9261
|
+
scan = _shell_scan(command or "")
|
|
9262
|
+
if scan.get("error"):
|
|
9263
|
+
return ""
|
|
9264
|
+
flag = tee = redir = ""
|
|
9265
|
+
for c in _shell_commands(scan["tokens"]):
|
|
9266
|
+
a = c["argv"]
|
|
9267
|
+
for i, w in enumerate(a):
|
|
9268
|
+
m = _LOG_FLAG_RE.match(w)
|
|
9269
|
+
if m:
|
|
9270
|
+
flag = m.group(1) or (a[i + 1] if i + 1 < len(a) else "") or flag
|
|
9271
|
+
if a and _shell_prog_name(a[0]) == "tee":
|
|
9272
|
+
files = [x for x in a[1:] if not x.startswith("-")]
|
|
9273
|
+
tee = files[-1] if files else tee
|
|
9274
|
+
for op, tgt in c["redirects"]:
|
|
9275
|
+
if op in (">", ">>", "&>", "&>>", "1>", "1>>") and tgt and not tgt.startswith("&"):
|
|
9276
|
+
redir = tgt
|
|
9277
|
+
for cand in (flag, tee, redir):
|
|
9278
|
+
if not cand or cand in ("/dev/null", "-"):
|
|
9279
|
+
continue
|
|
9280
|
+
path = cand.replace("$PWD", workdir or "").replace("${PWD}", workdir or "")
|
|
9281
|
+
path = os.path.expanduser(path)
|
|
9282
|
+
if not os.path.isabs(path):
|
|
9283
|
+
path = os.path.join(workdir or os.getcwd(), path)
|
|
9284
|
+
if os.path.isfile(path):
|
|
9285
|
+
return os.path.normpath(path)
|
|
9286
|
+
return ""
|
|
9287
|
+
|
|
9288
|
+
|
|
9289
|
+
def _file_tail(path: str, max_chars: int = 4000) -> str:
|
|
9290
|
+
try:
|
|
9291
|
+
with open(path, "rb") as fh:
|
|
9292
|
+
fh.seek(0, 2)
|
|
9293
|
+
size = fh.tell()
|
|
9294
|
+
fh.seek(max(0, size - max_chars))
|
|
9295
|
+
text = fh.read().decode("utf-8", errors="replace")
|
|
9296
|
+
return text if size <= max_chars else text.split("\n", 1)[-1]
|
|
9297
|
+
except OSError:
|
|
9298
|
+
return ""
|
|
9299
|
+
|
|
9300
|
+
|
|
9167
9301
|
def _shell_kill(s: dict) -> None:
|
|
9168
9302
|
"""Stop a shell's whole process group and mark it killed (unless it already finished)."""
|
|
9169
9303
|
import time as _t
|
|
@@ -9195,6 +9329,77 @@ def _shells_context_line(shells: list, now=None) -> str:
|
|
|
9195
9329
|
+ ". Read one with shell_output(<id>); stop one with stop_server(<id>).\n")
|
|
9196
9330
|
|
|
9197
9331
|
|
|
9332
|
+
# THE SHELL KEEPS ITS FOLDER AND EXPORTS BETWEEN CALLS (#362), the way Claude Code's Bash tool does.
|
|
9333
|
+
# Every run_command was a fresh bash, so `cd tools/x` or `source .venv/bin/activate` lasted one call
|
|
9334
|
+
# and every line started `cd … &&`. Not a long-lived pty (a hung one would wedge the turn): each
|
|
9335
|
+
# line runs in the outer shell via `eval`, and an EXIT trap — which also fires on `exit 3` — saves
|
|
9336
|
+
# `export -p` and `pwd` to this turn's state file; the next call sources it and starts in that
|
|
9337
|
+
# folder. One turn only: the file is removed when the agent exits. An explicit workdir wins.
|
|
9338
|
+
_RUN_STATE = {"path": ""}
|
|
9339
|
+
# Builtins that change the shell itself. As argv they run nothing (`export` is no program; macOS's
|
|
9340
|
+
# /usr/bin/cd exits 0 having changed nothing) — with a shell, a line using one goes to bash.
|
|
9341
|
+
_SHELL_STATE_BUILTINS = {"cd", "export", "source", ".", "set", "unset", "alias", "unalias", "pushd",
|
|
9342
|
+
"popd", "umask", "shopt", "declare", "typeset", "readonly", "trap", "ulimit",
|
|
9343
|
+
"hash", "eval"}
|
|
9344
|
+
_RUN_STATE_SKIP = r"^declare -x (PWD|OLDPWD|SHLVL|_)="
|
|
9345
|
+
|
|
9346
|
+
|
|
9347
|
+
def _run_state_path() -> str:
|
|
9348
|
+
import atexit
|
|
9349
|
+
import time as _t
|
|
9350
|
+
if not _RUN_STATE["path"]:
|
|
9351
|
+
d = os.path.join(os.path.expanduser("~"), ".gonext", "run-state")
|
|
9352
|
+
os.makedirs(d, exist_ok=True)
|
|
9353
|
+
# A background shell that ends after its turn rewrites its file; sweep day-old ones.
|
|
9354
|
+
try:
|
|
9355
|
+
for n in os.listdir(d):
|
|
9356
|
+
fp = os.path.join(d, n)
|
|
9357
|
+
if _t.time() - os.path.getmtime(fp) > 86400:
|
|
9358
|
+
os.remove(fp)
|
|
9359
|
+
except OSError:
|
|
9360
|
+
pass
|
|
9361
|
+
_RUN_STATE["path"] = os.path.join(d, f"{os.getpid()}.sh")
|
|
9362
|
+
|
|
9363
|
+
def _clean(base=_RUN_STATE["path"]):
|
|
9364
|
+
for ext in ("", ".cwd", ".tmp"):
|
|
9365
|
+
try:
|
|
9366
|
+
os.remove(base + ext)
|
|
9367
|
+
except OSError:
|
|
9368
|
+
pass
|
|
9369
|
+
atexit.register(_clean)
|
|
9370
|
+
return _RUN_STATE["path"]
|
|
9371
|
+
|
|
9372
|
+
|
|
9373
|
+
def _run_state_cwd() -> str:
|
|
9374
|
+
"""The folder the last shell line ended in, this turn — or ""."""
|
|
9375
|
+
p = _RUN_STATE["path"]
|
|
9376
|
+
if not p:
|
|
9377
|
+
return ""
|
|
9378
|
+
try:
|
|
9379
|
+
with open(p + ".cwd", encoding="utf-8") as fh:
|
|
9380
|
+
return fh.read().strip()
|
|
9381
|
+
except OSError:
|
|
9382
|
+
return ""
|
|
9383
|
+
|
|
9384
|
+
|
|
9385
|
+
def _run_state_set_cwd(cwd: str) -> None:
|
|
9386
|
+
"""Record the folder an && chain ended in — the chain runs its `cd` itself, not in bash."""
|
|
9387
|
+
try:
|
|
9388
|
+
with open(_run_state_path() + ".cwd", "w", encoding="utf-8") as fh:
|
|
9389
|
+
fh.write(cwd)
|
|
9390
|
+
except OSError:
|
|
9391
|
+
pass
|
|
9392
|
+
|
|
9393
|
+
|
|
9394
|
+
def _run_state_wrapper() -> str:
|
|
9395
|
+
"""The outer script: $1 the line, $2 the exit file, $3 the state file."""
|
|
9396
|
+
return ('[ -f "$3" ] && . "$3" >/dev/null 2>&1; '
|
|
9397
|
+
"trap '__gu_rc=$?; echo $__gu_rc > \"$2\"; "
|
|
9398
|
+
f"export -p | grep -vE \"{_RUN_STATE_SKIP}\" > \"$3.tmp\" 2>/dev/null && mv -f \"$3.tmp\" \"$3\"; "
|
|
9399
|
+
"pwd > \"$3.cwd\"; exit $__gu_rc' EXIT; "
|
|
9400
|
+
'eval "$1"')
|
|
9401
|
+
|
|
9402
|
+
|
|
9198
9403
|
def _ws_scrubbed_env() -> dict:
|
|
9199
9404
|
"""Minimal child env for run_command. The worker process env holds secrets
|
|
9200
9405
|
(GONEXT_WORKER_KEY etc. via worker.env) — a repo's test script must NEVER see
|
|
@@ -14578,6 +14783,14 @@ def run_agent_chat(cfg):
|
|
|
14578
14783
|
if os.path.isdir(os.path.join(_active, _stripped)):
|
|
14579
14784
|
_wd_in = _stripped
|
|
14580
14785
|
wd = _rag_read_allowed(_wd_in or _default_ws_root())
|
|
14786
|
+
_wd_before = wd
|
|
14787
|
+
if not _wd_in and _run_shell_bin():
|
|
14788
|
+
_kept = _run_state_cwd()
|
|
14789
|
+
if _kept and os.path.isdir(_kept):
|
|
14790
|
+
try:
|
|
14791
|
+
wd = _rag_read_allowed(_kept)
|
|
14792
|
+
except Exception: # noqa: BLE001 — left the allowed folders: start over
|
|
14793
|
+
wd = _wd_before
|
|
14581
14794
|
w = _ws_for_path(wd)
|
|
14582
14795
|
if w is None or not w.get("allowRun"):
|
|
14583
14796
|
# Contextual nudge (task #102): the #1 reason the model shells out here is
|
|
@@ -14601,7 +14814,12 @@ def run_agent_chat(cfg):
|
|
|
14601
14814
|
+ (_HEREDOC_HINT if "quote" in str(_scan["error"]) else ""))
|
|
14602
14815
|
_last_obs["text"] = msg
|
|
14603
14816
|
return msg
|
|
14604
|
-
|
|
14817
|
+
# Once this turn has shell state (a cd, an activated venv), every line goes through
|
|
14818
|
+
# the shell so it starts from that state — not only lines with shell syntax.
|
|
14819
|
+
_words = [c["argv"][0] for c in _shell_commands(_scan["tokens"]) if c["argv"]] if _scan else []
|
|
14820
|
+
_builtin = sorted({w for w in _words if w in _SHELL_STATE_BUILTINS})
|
|
14821
|
+
_use_shell = bool(_scan) and (_shell_needed(_scan) or bool(_builtin) or bool(
|
|
14822
|
+
_RUN_STATE["path"] and os.path.exists(_RUN_STATE["path"])))
|
|
14605
14823
|
_bg_err = _shell_background_error(_scan) if _use_shell else None
|
|
14606
14824
|
if _bg_err:
|
|
14607
14825
|
_last_obs["text"] = _bg_err
|
|
@@ -14614,7 +14832,10 @@ def run_agent_chat(cfg):
|
|
|
14614
14832
|
# AN `&&` CHAIN IS ONE STEP (task #274): each part runs through THIS function —
|
|
14615
14833
|
# allowlist, risk gate, approval, server detection, all of it — in order, and
|
|
14616
14834
|
# the first failure stops the rest. `cd DIR` sets the folder for what follows.
|
|
14617
|
-
|
|
14835
|
+
# A chain runs each part as a program; `export`/`source` in it would run nothing,
|
|
14836
|
+
# so with a shell such a line goes to bash whole. (`cd` the chain does itself.)
|
|
14837
|
+
_chain = None if (_use_shell and [b for b in _builtin if b != "cd"]) \
|
|
14838
|
+
else _and_chain_parts(command)
|
|
14618
14839
|
if _chain:
|
|
14619
14840
|
_chain_start = _t.time()
|
|
14620
14841
|
_cur_wd = wd
|
|
@@ -14658,6 +14879,10 @@ def run_agent_chat(cfg):
|
|
|
14658
14879
|
+ (": " + " && ".join(_rest) if _rest else "")
|
|
14659
14880
|
if _stopped else f"CHAIN DONE — all {len(_chain)} parts ran")
|
|
14660
14881
|
result = _summary + "\n" + "\n".join(_outs)
|
|
14882
|
+
if _shell_bin and _cur_wd != wd:
|
|
14883
|
+
_run_state_set_cwd(_cur_wd) # the next call starts where the chain ended
|
|
14884
|
+
result += (f"\n(the shell is now in {_cur_wd} — the next run_command "
|
|
14885
|
+
"without a workdir starts there)")
|
|
14661
14886
|
_last_obs["text"] = result
|
|
14662
14887
|
return result
|
|
14663
14888
|
# REFUSE SHELL SYNTAX BEFORE SPAWNING (task #204). Without this, `cd X && Y`
|
|
@@ -14677,8 +14902,11 @@ def run_agent_chat(cfg):
|
|
|
14677
14902
|
# Every simple command in the line, including those inside $( ), gets the
|
|
14678
14903
|
# checks an argv command gets — allowlist, privilege, denylist, deletes —
|
|
14679
14904
|
# plus the shell-only ones (redirect outside the project, `| sh`).
|
|
14905
|
+
_resolved = _shell_resolve_runner_vars(command, _scan)
|
|
14906
|
+
_risk_scan = _shell_scan(_resolved) if _resolved else _scan
|
|
14680
14907
|
_refuse, _hard_reason, _ask_reason = _ws_shell_command_risk(
|
|
14681
|
-
|
|
14908
|
+
_risk_scan if not _risk_scan.get("error") else _scan,
|
|
14909
|
+
_WS_RUN_DENYLIST, wd, _WS_RUN_ALLOWLIST)
|
|
14682
14910
|
if _refuse:
|
|
14683
14911
|
_last_obs["text"] = _refuse
|
|
14684
14912
|
return _refuse
|
|
@@ -14775,12 +15003,13 @@ def run_agent_chat(cfg):
|
|
|
14775
15003
|
if _use_shell:
|
|
14776
15004
|
_cmd_text, _attrib_note = _with_attribution_text(
|
|
14777
15005
|
command, _scan, _attribution_settings(wd))
|
|
14778
|
-
# The exit code is written to a file so it outlives this turn's process
|
|
14779
|
-
# the
|
|
15006
|
+
# The exit code is written to a file so it outlives this turn's process, and
|
|
15007
|
+
# the folder + exports are kept for the next call — the line itself runs
|
|
15008
|
+
# untouched as $1 (see BACKGROUND SHELLS, _run_state_wrapper).
|
|
14780
15009
|
# Git Bash reads C:/x, not C:\x, inside the line it runs.
|
|
14781
15010
|
_fs = (lambda x: x.replace("\\", "/")) if _IS_WINDOWS else (lambda x: x)
|
|
14782
|
-
argv = [_shell_bin, "-c",
|
|
14783
|
-
_fs(_shell_bin), _cmd_text, _fs(exit_path)]
|
|
15011
|
+
argv = [_shell_bin, "-c", _run_state_wrapper(),
|
|
15012
|
+
_fs(_shell_bin), _cmd_text, _fs(exit_path), _fs(_run_state_path())]
|
|
14784
15013
|
else:
|
|
14785
15014
|
argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
|
|
14786
15015
|
if _attrib_note:
|
|
@@ -14830,7 +15059,11 @@ def run_agent_chat(cfg):
|
|
|
14830
15059
|
while True:
|
|
14831
15060
|
rc = proc.poll()
|
|
14832
15061
|
if rc is not None:
|
|
14833
|
-
|
|
15062
|
+
_raw = _read_log()
|
|
15063
|
+
out = _ws_distill_output(_raw)
|
|
15064
|
+
# The result FIRST (#362): counts and each failure with file:line, read
|
|
15065
|
+
# from the WHOLE output — the observation below is capped, this is not.
|
|
15066
|
+
_digest = _test_digest(_raw, wd)
|
|
14834
15067
|
# Task #90 Phase 1 — truthful perception: never claim success the
|
|
14835
15068
|
# tool didn't observe. exit 0 is a FACT about the process, not
|
|
14836
15069
|
# proof of the GOAL (e.g. `npm start` exits 0 when its port is
|
|
@@ -14853,7 +15086,7 @@ def run_agent_chat(cfg):
|
|
|
14853
15086
|
# what `cd X && Y` used to do — and the model read the silence as a
|
|
14854
15087
|
# clean pass. An absence cannot be reasoned about; a stated fact can.
|
|
14855
15088
|
result = (
|
|
14856
|
-
f"{status}\n{
|
|
15089
|
+
f"{status}\n" + (f"{_digest}\n\n" if _digest else "") + out if out.strip()
|
|
14857
15090
|
else f"{status}\nNO OUTPUT: the command printed nothing on stdout or "
|
|
14858
15091
|
"stderr. If you expected output, it probably did not do what "
|
|
14859
15092
|
"you intended — check the command before repeating it."
|
|
@@ -14873,6 +15106,11 @@ def run_agent_chat(cfg):
|
|
|
14873
15106
|
f"Command failed behind a pipe → {_trim_words(_hidden)}"})
|
|
14874
15107
|
if rc != 0 and _use_shell and _SHELL_QUOTE_ERR_RE.search(out or ""):
|
|
14875
15108
|
result += _HEREDOC_HINT
|
|
15109
|
+
# Say where the shell now is, so a next `cd sub` is not done twice (#362).
|
|
15110
|
+
_now = _run_state_cwd() if _use_shell else ""
|
|
15111
|
+
if _now and os.path.realpath(_now) != os.path.realpath(wd):
|
|
15112
|
+
result += (f"\n(the shell is now in {_now} — the next run_command "
|
|
15113
|
+
"without a workdir starts there)")
|
|
14876
15114
|
if rc != 0 and _WS_PERM_ERR_RE.search(out or ""):
|
|
14877
15115
|
_f = _ws_access_facts()
|
|
14878
15116
|
result = f"{result}\n\n{_ws_access_report(_f, _ws_paths_in(command))}"
|
|
@@ -15088,8 +15326,23 @@ def run_agent_chat(cfg):
|
|
|
15088
15326
|
if st["state"] == "exited":
|
|
15089
15327
|
head += ("\nexit 0 alone does NOT prove the intended effect happened — check "
|
|
15090
15328
|
"the output and VERIFY.")
|
|
15329
|
+
if st["state"] != "running":
|
|
15330
|
+
try:
|
|
15331
|
+
with open(sh.get("log") or "", encoding="utf-8", errors="replace") as fh:
|
|
15332
|
+
_dg = _test_digest(fh.read(), str(sh.get("workdir") or ""))
|
|
15333
|
+
except OSError:
|
|
15334
|
+
_dg = ""
|
|
15335
|
+
if _dg:
|
|
15336
|
+
head += "\n" + _dg
|
|
15337
|
+
tail_note = ""
|
|
15338
|
+
if not new.strip() and int(sh.get("read_offset") or 0) == 0:
|
|
15339
|
+
lf = _shell_log_file(str(sh.get("command") or ""), str(sh.get("workdir") or ""))
|
|
15340
|
+
lt = _file_tail(lf).strip() if lf else ""
|
|
15341
|
+
if lt:
|
|
15342
|
+
tail_note = (f"\nIt prints nothing itself; the end of the log it writes "
|
|
15343
|
+
f"({lf}):\n{lt}")
|
|
15091
15344
|
msg = head + "\n" + (f"New output:\n{new}" if new.strip()
|
|
15092
|
-
else "No new output since the last read.")
|
|
15345
|
+
else "No new output since the last read.") + tail_note
|
|
15093
15346
|
_emit({"type": "step", "text":
|
|
15094
15347
|
f"Shell {sh.get('id')} {_shell_status_text(st)} → {_trim_words(str(sh.get('command')))}"})
|
|
15095
15348
|
_last_obs["text"] = msg
|
package/gu-repl.mjs
CHANGED
|
@@ -139,7 +139,7 @@ import {
|
|
|
139
139
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
140
140
|
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
141
141
|
import { clearLogsNoticeText } from "./clear-logs.mjs";
|
|
142
|
-
import { killShell, logTail, readShells, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
142
|
+
import { commandLogFile, killShell, logTail, readShells, renderShellDetails, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
|
|
143
143
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
144
144
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
145
145
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
@@ -9510,8 +9510,18 @@ function printShellDetails(rec) {
|
|
|
9510
9510
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
9511
9511
|
const st = shellState(rec);
|
|
9512
9512
|
shellsViewed.add(String(rec.id));
|
|
9513
|
+
// Nothing printed of its own (Unity -batchmode -logFile …)? Show the log its command writes.
|
|
9514
|
+
let output = logTail(rec.log);
|
|
9515
|
+
let outputFrom = "";
|
|
9516
|
+
if (!output.length) {
|
|
9517
|
+
const lf = commandLogFile(rec.command, rec.workdir);
|
|
9518
|
+
if (lf) {
|
|
9519
|
+
output = logTail(lf);
|
|
9520
|
+
outputFrom = lf.startsWith(String(rec.workdir || "\0") + "/") ? lf.slice(String(rec.workdir).length + 1) : lf;
|
|
9521
|
+
}
|
|
9522
|
+
}
|
|
9513
9523
|
const rows = renderShellDetails({
|
|
9514
|
-
rec, st, output
|
|
9524
|
+
rec, st, output, outputFrom, width: width - 1, gutter: GUTTER,
|
|
9515
9525
|
style: { title: (t) => bold(accent(t)), label: bold, running: accent, ok: accent, bad: red, dim },
|
|
9516
9526
|
});
|
|
9517
9527
|
console.log("\n" + GUTTER + accent("─".repeat(Math.max(10, width - 1 - GUTTER.length))));
|
|
@@ -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.833",
|
|
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",
|
|
@@ -135,6 +135,7 @@
|
|
|
135
135
|
"gonext_agent_chat.py",
|
|
136
136
|
"gu_mcp.py",
|
|
137
137
|
"gu_skills.py",
|
|
138
|
+
"gu_test_digest.py",
|
|
138
139
|
"gu_hooks.py",
|
|
139
140
|
"prompt_scaffold_check.py",
|
|
140
141
|
"gonext_transcribe.py",
|
package/shell-details.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
16
|
+
import { 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");
|
|
@@ -135,6 +135,38 @@ export function logTail(path, { lines = 15, maxBytes = 64 * 1024 } = {}) {
|
|
|
135
135
|
return out.slice(-lines);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
const unq = (w) => w.replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
|
|
139
|
+
const WORD = String.raw`("[^"]*"|'[^']*'|[^\s;&|<>]+)`;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The log file a command writes to, when the shell itself prints nothing (#362: Unity's
|
|
143
|
+
* `-logFile Logs/all.log` left Shell details on "No output available" for a 5-minute run).
|
|
144
|
+
* An explicit log flag first, then `tee`, then the last `>`/`>>` redirect — the same order as
|
|
145
|
+
* the agent's _shell_log_file. → an absolute path that exists, or "".
|
|
146
|
+
*/
|
|
147
|
+
export function commandLogFile(command, workdir = "") {
|
|
148
|
+
const cmd = String(command ?? "");
|
|
149
|
+
const last = (re) => {
|
|
150
|
+
let m, v = "";
|
|
151
|
+
for (const r = new RegExp(re, "gi"); (m = r.exec(cmd)); ) v = m[1] ?? m[2] ?? v;
|
|
152
|
+
return v;
|
|
153
|
+
};
|
|
154
|
+
const flag = last(String.raw`(?:^|\s)--?(?:log-?file|logfile|log-path|log)(?:=` + WORD + String.raw`|\s+` + WORD + ")");
|
|
155
|
+
const tee = last(String.raw`\btee\s+(?:-a\s+)?` + WORD);
|
|
156
|
+
const redir = last(String.raw`(?:^|[^0-9&>])(?:1|&)?>>?\s*(?!&)` + WORD);
|
|
157
|
+
for (const raw of [flag, tee, redir]) {
|
|
158
|
+
if (!raw) continue;
|
|
159
|
+
let p = unq(raw).replace(/\$\{?PWD\}?/g, workdir || "");
|
|
160
|
+
if (p === "/dev/null" || p === "-") continue;
|
|
161
|
+
if (p.startsWith("~/")) p = join(homedir(), p.slice(2));
|
|
162
|
+
if (!isAbsolute(p)) p = join(workdir || process.cwd(), p);
|
|
163
|
+
try {
|
|
164
|
+
if (statSync(p).isFile()) return normalize(p);
|
|
165
|
+
} catch { /* not there (yet) */ }
|
|
166
|
+
}
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
|
|
138
170
|
/** Word-wrap `text` to `width`; at most `maxRows`, the last ending in "…" when cut. */
|
|
139
171
|
export function wrapClip(text, width, maxRows = 4) {
|
|
140
172
|
const w = Math.max(10, width);
|
|
@@ -166,7 +198,7 @@ const plain = (s) => s;
|
|
|
166
198
|
* The Shell details block, one string per row, no row wider than `width`.
|
|
167
199
|
* `style`: {title, label, running, ok, bad, dim} paint functions (identity in tests).
|
|
168
200
|
*/
|
|
169
|
-
export function renderShellDetails({ rec, st, output = [], width = 80, gutter = " ", style = {} }) {
|
|
201
|
+
export function renderShellDetails({ rec, st, output = [], outputFrom = "", width = 80, gutter = " ", style = {} }) {
|
|
170
202
|
const p = { title: plain, label: plain, running: plain, ok: plain, bad: plain, dim: plain, ...style };
|
|
171
203
|
const inner = Math.max(20, width - gutter.length);
|
|
172
204
|
const valW = inner - LABEL_W;
|
|
@@ -183,7 +215,8 @@ export function renderShellDetails({ rec, st, output = [], width = 80, gutter =
|
|
|
183
215
|
...cmd.slice(1).map((c) => gutter + pad + c),
|
|
184
216
|
];
|
|
185
217
|
if (rec?.workdir) rows.push(row("Folder:", p.dim(wrapClip(rec.workdir, valW, 1)[0])));
|
|
186
|
-
|
|
218
|
+
// From the log file the command writes, when the shell printed nothing itself — say which.
|
|
219
|
+
rows.push("", gutter + p.label("Output:") + (outputFrom ? p.dim(` (from ${wrapClip(outputFrom, Math.max(10, inner - 20), 1)[0]})`) : ""));
|
|
187
220
|
if (!output.length) rows.push(gutter + p.dim("No output available"));
|
|
188
221
|
else for (const l of output) rows.push(gutter + (l.length > inner ? l.slice(0, inner - 1) + "…" : l));
|
|
189
222
|
return rows;
|