v0.8.4: WebUI sessions, diff, settings, slash commands and extensions
Sessions: rename, delete and search; /api/retry regenerates the last answer, editing a prompt resends from there; /api/export downloads the transcript as Markdown (cst_session_export_md). Session messages carry note/tool_result markers, so a reload rebuilds the tool steps with their arguments, output and exit status; subagent progress is shown. Diff and settings: /api/diff shows the real diff; /api/settings covers protocol, max_steps, tool switches and confirm; /api/permissions edits the rules; sandbox status is reported. Session rename no longer reads past a short title. Commands: slash commands with completion in the composer (/model, /provider, /new, /retry, /undo, /compact, /export, /diff, /init, /skill and more), a Ctrl+P palette, Shift+Tab cycles the approval mode, F1 or ? shows the keys. "Thinking expanded" is saved in the settings (show_thinking), "expand all tool output" in the browser. Extensions: GET /api/extensions lists MCP servers, plugins, skills, agent roles and subagent runs; POST /api/extensions/reload and /api/extensions/trust; a workspace switch reloads them. /api/init and /api/skill run on the server (cst_init_prompt shared with the TUI). Errors, statuses and [cst] notes are shown in Chinese with a readable reason instead of rc=; a failed turn's error survives the reload. Tests: scripts/e2e_webui_sessions.py, e2e_webui_workspace.py and e2e_webui_ext.py, run from e2e_webui.sh.
This commit is contained in:
@@ -285,6 +285,15 @@ if command -v python3 > /dev/null; then
|
||||
echo "== settings and streaming"
|
||||
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_settings.py" > "$WORK/settings.txt" 2>&1 ||
|
||||
{ cat "$WORK/settings.txt"; fail "the settings checks"; }
|
||||
echo "== sessions, retry and export"
|
||||
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_sessions.py" > "$WORK/sessions.txt" 2>&1 ||
|
||||
{ cat "$WORK/sessions.txt"; fail "the session checks"; }
|
||||
echo "== diff, run settings and permission rules"
|
||||
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_workspace.py" > "$WORK/workspace.txt" 2>&1 ||
|
||||
{ cat "$WORK/workspace.txt"; fail "the workspace checks"; }
|
||||
echo "== /init, /skill, extensions and the thinking switch"
|
||||
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_ext.py" > "$WORK/ext.txt" 2>&1 ||
|
||||
{ cat "$WORK/ext.txt"; fail "the extension checks"; }
|
||||
fi
|
||||
|
||||
echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))"
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""WebUI /init, /skill, extensions and the thinking switch against a fake API.
|
||||
|
||||
python3 scripts/e2e_webui_ext.py (CST_AGENT= picks the binary)
|
||||
|
||||
Checks: show_thinking is validated, lands in the state and in settings.json;
|
||||
/api/skill inlines the skill and 404s an unknown one; /api/init sends the
|
||||
AGENTS.md prompt; /api/extensions lists skills and the built-in roles;
|
||||
trust and reload work and write settings.json; the changing calls are a 409
|
||||
while a turn runs, the listing is not. HOME points at a temp dir.
|
||||
"""
|
||||
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
|
||||
SEEN = [] # the full last user message of every request
|
||||
|
||||
|
||||
class Api(BaseHTTPRequestHandler):
|
||||
"""Answers "好的"; a prompt with "slow" takes 2 s."""
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
||||
users = [m.get("content") for m in req.get("messages", []) if m.get("role") == "user"]
|
||||
last = users[-1] if users and isinstance(users[-1], str) else ""
|
||||
SEEN.append(last)
|
||||
if "slow" in last:
|
||||
time.sleep(2)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.end_headers()
|
||||
for o in ({"delta": {"role": "assistant", "content": "好的"}},
|
||||
{"delta": {}, "finish_reason": "stop"}):
|
||||
chunk = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash",
|
||||
"choices": [dict(index=0, **o)]}
|
||||
self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
|
||||
self.wfile.flush()
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
fails = 0
|
||||
|
||||
|
||||
def check(ok, msg):
|
||||
global fails
|
||||
print(("PASS " if ok else "FAIL ") + msg)
|
||||
fails += 0 if ok else 1
|
||||
|
||||
|
||||
def wait(fn, t=15):
|
||||
end = time.time() + t
|
||||
while time.time() < end:
|
||||
v = fn()
|
||||
if v:
|
||||
return v
|
||||
time.sleep(0.1)
|
||||
return None
|
||||
|
||||
|
||||
def call(base, path, obj=None, method=None):
|
||||
data = None if obj is None else json.dumps(obj).encode()
|
||||
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return r.status, json.loads(r.read() or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read() or b"{}")
|
||||
except ValueError:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def skill(ws, name, desc, body):
|
||||
d = os.path.join(ws, ".agent", "skills", name)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(os.path.join(d, "SKILL.md"), "w", encoding="utf-8") as f:
|
||||
f.write("---\nname: %s\ndescription: %s\n---\n\n%s\n" % (name, desc, body))
|
||||
|
||||
|
||||
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
|
||||
threading.Thread(target=api.serve_forever, daemon=True).start()
|
||||
ws = tempfile.mkdtemp(prefix="cst-ext-")
|
||||
home = tempfile.mkdtemp(prefix="cst-home-")
|
||||
skill(ws, "demo", "演示用的 skill", "DEMO-INSTRUCTIONS: 先说你好。")
|
||||
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
|
||||
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache")
|
||||
for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
|
||||
env.pop(k, None)
|
||||
log = open(ws + "/server.log", "w+")
|
||||
proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x",
|
||||
"--provider", "deepseek", "--model", "deepseek-flash",
|
||||
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
|
||||
"--protocol", "native-eos", "--approval", "auto"],
|
||||
stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||
|
||||
|
||||
def port():
|
||||
log.seek(0)
|
||||
for l in log.read().splitlines():
|
||||
if l.startswith("webui: http://127.0.0.1:"):
|
||||
return l.split(":")[3].split("/")[0]
|
||||
|
||||
|
||||
def settings():
|
||||
try:
|
||||
with open(home + "/.config/cst-agent/settings.json", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
|
||||
def busy(base):
|
||||
return call(base, "/api/state")[1].get("status_busy")
|
||||
|
||||
|
||||
def turn(base, path, body):
|
||||
code, d = call(base, path, body)
|
||||
if code == 202:
|
||||
time.sleep(0.3)
|
||||
wait(lambda: not busy(base), 20)
|
||||
return code, d
|
||||
|
||||
|
||||
try:
|
||||
base = "http://127.0.0.1:%s" % wait(port)
|
||||
|
||||
# 1. the thinking switch
|
||||
st = call(base, "/api/state")[1]
|
||||
check(st.get("show_thinking") is False, "show_thinking starts off")
|
||||
check(call(base, "/api/config", {"show_thinking": "yes"})[0] == 400, "show_thinking must be a bool")
|
||||
code, _ = call(base, "/api/config", {"show_thinking": True})
|
||||
check(code == 200 and call(base, "/api/state")[1].get("show_thinking") is True, "show_thinking on")
|
||||
check(settings().get("show_thinking") is True, "show_thinking is saved in settings.json")
|
||||
call(base, "/api/config", {"show_thinking": False})
|
||||
check(settings() and not settings().get("show_thinking"), "and cleared again (%s)" % settings())
|
||||
|
||||
# 2. the extensions listing
|
||||
code, ex = call(base, "/api/extensions")
|
||||
check(code == 200 and ex.get("enabled") is True and ex.get("trusted") is False,
|
||||
"extensions: enabled, not trusted (%s)" % code)
|
||||
names = [s["name"] for s in ex.get("skills", [])]
|
||||
check(names == ["demo"] and ex["skills"][0].get("desc") == "演示用的 skill"
|
||||
and ex["skills"][0].get("source") == "workspace", "the workspace skill is listed (%s)" % ex.get("skills"))
|
||||
roles = [r["name"] for r in ex.get("roles", [])]
|
||||
check("explore" in roles and all(r.get("source") for r in ex["roles"]), "built-in roles are listed (%s)" % roles)
|
||||
check(ex.get("mcp") == [] and ex.get("plugins") == [] and ex.get("runs") == [], "no MCP, plugins or runs")
|
||||
check(ex.get("task_tool") is True and "1 skill" in ex.get("report", ""), "task tool on, report %r" % ex.get("report"))
|
||||
|
||||
# 3. /skill
|
||||
code, d = turn(base, "/api/skill", {"name": "demo", "task": "做点事"})
|
||||
check(code == 202 and "DEMO-INSTRUCTIONS" in d.get("text", "") and d["text"].endswith("做点事"),
|
||||
"skill turn accepted (%s)" % code)
|
||||
check(SEEN and "DEMO-INSTRUCTIONS" in SEEN[-1] and "做点事" in SEEN[-1], "the model saw the skill and the task")
|
||||
code, d = turn(base, "/api/skill", {"name": "demo"})
|
||||
check(code == 202 and SEEN[-1].endswith("Apply this skill now."), "a skill without a task")
|
||||
check(call(base, "/api/skill", {"name": "nope"})[0] == 404, "unknown skill: 404")
|
||||
check(call(base, "/api/skill", {"name": "../demo"})[0] == 404, "skill ../demo: 404")
|
||||
check(call(base, "/api/skill", {})[0] == 400, "skill without a name: 400")
|
||||
check(call(base, "/api/skill", {"name": "x" * 100})[0] == 400, "a long skill name: 400")
|
||||
check(call(base, "/api/skill", {"name": "demo", "task": 5})[0] == 400, "a non-string task: 400")
|
||||
|
||||
# 4. /init
|
||||
code, d = turn(base, "/api/init", {})
|
||||
check(code == 202 and "AGENTS.md" in d.get("text", ""), "init accepted (%s)" % code)
|
||||
check("AGENTS.md" in SEEN[-1], "the model saw the AGENTS.md prompt")
|
||||
|
||||
# 5. trust and reload
|
||||
code, d = call(base, "/api/extensions/trust", {"on": True})
|
||||
check(code == 200 and d.get("trusted") is True, "trust the workspace (%s %s)" % (code, d))
|
||||
tw = settings().get("trusted_workspaces", [])
|
||||
check(any(os.path.realpath(ws) == os.path.realpath(p) for p in tw), "settings.json lists it (%s)" % tw)
|
||||
check(call(base, "/api/extensions")[1].get("trusted") is True, "the listing says trusted")
|
||||
code, d = call(base, "/api/extensions/trust", {"on": False})
|
||||
check(code == 200 and d.get("trusted") is False and not settings().get("trusted_workspaces"),
|
||||
"untrust (%s)" % settings().get("trusted_workspaces"))
|
||||
check(call(base, "/api/extensions/trust", {"on": "yes"})[0] == 400, "trust needs a bool")
|
||||
skill(ws, "zeta", "第二个", "ZETA")
|
||||
code, d = call(base, "/api/extensions/reload", {})
|
||||
check(code == 200 and "2 skills" in d.get("report", ""), "reload sees the new skill (%s)" % d)
|
||||
check([s["name"] for s in call(base, "/api/extensions")[1]["skills"]] == ["demo", "zeta"], "both listed")
|
||||
|
||||
# 6. while a turn runs
|
||||
call(base, "/api/turn", {"text": "slow 慢一点"})
|
||||
time.sleep(0.4)
|
||||
check(call(base, "/api/extensions/reload", {})[0] == 409, "reload during a turn: 409")
|
||||
check(call(base, "/api/extensions/trust", {"on": True})[0] == 409, "trust during a turn: 409")
|
||||
check(call(base, "/api/skill", {"name": "demo"})[0] == 409, "skill during a turn: 409")
|
||||
check(call(base, "/api/init", {})[0] == 409, "init during a turn: 409")
|
||||
check(call(base, "/api/extensions")[0] == 200, "the listing works during a turn")
|
||||
check(call(base, "/api/config", {"show_thinking": True})[0] == 200, "the thinking switch works during a turn")
|
||||
wait(lambda: not busy(base), 20)
|
||||
check(not settings().get("trusted_workspaces"), "the refused trust changed nothing")
|
||||
|
||||
# 7. another workspace brings its own skills
|
||||
ws2 = tempfile.mkdtemp(prefix="cst-ext2-")
|
||||
skill(ws2, "other", "另一个工作区的", "OTHER")
|
||||
code, _ = call(base, "/api/workspace", {"path": ws2})
|
||||
ex = call(base, "/api/extensions")[1]
|
||||
check(code == 200 and [s["name"] for s in ex.get("skills", [])] == ["other"]
|
||||
and ex.get("report") == "1 skill", "a workspace switch reloads the extensions (%s)" % ex.get("report"))
|
||||
code, d = turn(base, "/api/skill", {"name": "other"})
|
||||
check(code == 202 and "OTHER" in SEEN[-1], "and its skill runs")
|
||||
shutil.rmtree(ws2, ignore_errors=True)
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(20)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
check(False, "server hung on SIGTERM")
|
||||
log.close()
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
shutil.rmtree(home, ignore_errors=True)
|
||||
api.shutdown()
|
||||
print("FAILED:", fails)
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -0,0 +1,327 @@
|
||||
"""WebUI session management against a fake OpenAI-compatible API.
|
||||
|
||||
python3 scripts/e2e_webui_sessions.py (CST_AGENT= picks the binary)
|
||||
|
||||
Checks: retry runs the last prompt again and edit-and-resend replaces it
|
||||
(the model sees the new text, the session keeps one copy); retry is a 409
|
||||
while a turn runs and when there is nothing to retry; export returns the
|
||||
transcript as Markdown; rename, clear and delete work on the store, and
|
||||
deleting the open session moves to a new one; history messages carry the
|
||||
note / tool_result flags. HOME points at a temp dir.
|
||||
"""
|
||||
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
|
||||
SEEN = []
|
||||
|
||||
|
||||
def last_prompt(msgs):
|
||||
"""The prompts are one line; a note before one shares its user message."""
|
||||
for m in reversed(msgs):
|
||||
c = m.get("content")
|
||||
if m.get("role") == "user" and isinstance(c, str):
|
||||
return c.rstrip("\n").split("\n")[-1]
|
||||
return ""
|
||||
|
||||
|
||||
class Api(BaseHTTPRequestHandler):
|
||||
"""Answers "回答:<last prompt>"; a prompt with "slow" takes 2 s."""
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
||||
msgs = req.get("messages", [])
|
||||
p = last_prompt(msgs)
|
||||
tail = msgs[-1] if msgs else {}
|
||||
tc = tail.get("content") if isinstance(tail.get("content"), str) else ""
|
||||
answered = tail.get("role") == "tool" or tc.startswith("result ")
|
||||
if not answered:
|
||||
SEEN.append(p)
|
||||
if "slow" in p:
|
||||
time.sleep(2)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.end_headers()
|
||||
if answered:
|
||||
chunks = ({"delta": {"role": "assistant", "content": "读完了"}},
|
||||
{"delta": {}, "finish_reason": "stop"})
|
||||
elif p.startswith("native"):
|
||||
args = json.dumps({"path": "hello.txt"})
|
||||
chunks = ({"delta": {"role": "assistant", "tool_calls": [
|
||||
{"index": 0, "id": "c1", "type": "function",
|
||||
"function": {"name": "read_file", "arguments": args}}]}},
|
||||
{"delta": {}, "finish_reason": "tool_calls"})
|
||||
elif p.startswith("ctl"):
|
||||
chunks = ({"delta": {"role": "assistant",
|
||||
"content": "我来读。\ncall t1 read_file\narg path 9\nhello.txtend\n"}},
|
||||
{"delta": {}, "finish_reason": "stop"})
|
||||
else:
|
||||
chunks = ({"delta": {"role": "assistant", "content": "回答:" + p}},
|
||||
{"delta": {}, "finish_reason": "stop"})
|
||||
for o in chunks:
|
||||
chunk = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash",
|
||||
"choices": [dict(index=0, **o)]}
|
||||
self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
|
||||
self.wfile.flush()
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
fails = 0
|
||||
|
||||
|
||||
def check(ok, msg):
|
||||
global fails
|
||||
print(("PASS " if ok else "FAIL ") + msg)
|
||||
fails += 0 if ok else 1
|
||||
|
||||
|
||||
def wait(fn, t=15):
|
||||
end = time.time() + t
|
||||
while time.time() < end:
|
||||
v = fn()
|
||||
if v:
|
||||
return v
|
||||
time.sleep(0.1)
|
||||
return None
|
||||
|
||||
|
||||
def call(base, path, obj=None, method=None, raw_reply=False):
|
||||
data = None if obj is None else json.dumps(obj).encode()
|
||||
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
raw = r.read()
|
||||
if raw_reply:
|
||||
return r.status, r.headers.get("Content-Type", ""), raw
|
||||
return r.status, json.loads(raw or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
if raw_reply:
|
||||
return e.code, e.headers.get("Content-Type", ""), raw
|
||||
try:
|
||||
return e.code, json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
class Sse:
|
||||
def __init__(self, base):
|
||||
self.lines = []
|
||||
self.r = urllib.request.urlopen(base + "/api/events", timeout=60)
|
||||
threading.Thread(target=self.pump, daemon=True).start()
|
||||
|
||||
def pump(self):
|
||||
try:
|
||||
for raw in self.r:
|
||||
self.lines.append(raw)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def count(self, name):
|
||||
return sum(1 for l in self.lines if l.strip() == b"event: " + name.encode())
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.r.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
|
||||
threading.Thread(target=api.serve_forever, daemon=True).start()
|
||||
ws = tempfile.mkdtemp(prefix="cst-sess-")
|
||||
home = tempfile.mkdtemp(prefix="cst-home-")
|
||||
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
|
||||
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache")
|
||||
for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
|
||||
env.pop(k, None)
|
||||
log = open(ws + "/server.log", "w+")
|
||||
proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x",
|
||||
"--provider", "deepseek", "--model", "deepseek-flash",
|
||||
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
|
||||
"--protocol", "native-eos", "--approval", "auto"],
|
||||
stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||
|
||||
|
||||
def port():
|
||||
log.seek(0)
|
||||
for l in log.read().splitlines():
|
||||
if l.startswith("webui: http://127.0.0.1:"):
|
||||
return l.split(":")[3].split("/")[0]
|
||||
|
||||
|
||||
def messages(base):
|
||||
return call(base, "/api/session")[1].get("messages", [])
|
||||
|
||||
|
||||
def prompts(base):
|
||||
return [m["content"] for m in messages(base)
|
||||
if m["role"] == "user" and not m.get("note") and not m.get("tool_result")]
|
||||
|
||||
|
||||
def answers(base):
|
||||
return [m["content"] for m in messages(base) if m["role"] == "assistant"]
|
||||
|
||||
|
||||
def turn(base, sse, path, body, want=202):
|
||||
n = sse.count("turn_end")
|
||||
code, d = call(base, path, body)
|
||||
if code == want == 202:
|
||||
wait(lambda: sse.count("turn_end") > n, 20)
|
||||
return code, d
|
||||
|
||||
|
||||
try:
|
||||
base = "http://127.0.0.1:%s" % wait(port)
|
||||
sse = Sse(base)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 1. retry with nothing to retry, then a first turn
|
||||
code, d = call(base, "/api/retry", {})
|
||||
check(code == 409 and "nothing to retry" in d.get("error", ""), "retry on an empty session: 409 (%s)" % code)
|
||||
turn(base, sse, "/api/turn", {"text": "第一句"})
|
||||
check(prompts(base) == ["第一句"] and answers(base) == ["回答:第一句"], "first turn stored")
|
||||
|
||||
# 2. retry: same prompt again, one copy in the session
|
||||
code, d = turn(base, sse, "/api/retry", {})
|
||||
check(code == 202 and d.get("text") == "第一句", "retry accepted (%s %s)" % (code, d))
|
||||
check(SEEN[-1] == "第一句", "the model saw the same prompt again")
|
||||
check(prompts(base) == ["第一句"] and answers(base) == ["回答:第一句"],
|
||||
"after retry: one prompt, one answer (%s)" % prompts(base))
|
||||
|
||||
# 3. edit and resend: the new text replaces the old
|
||||
code, d = turn(base, sse, "/api/retry", {"text": "改过的问题"})
|
||||
check(code == 202 and d.get("text") == "改过的问题", "edit-and-resend accepted")
|
||||
check(SEEN[-1] == "改过的问题", "the model saw the edited prompt")
|
||||
check(prompts(base) == ["改过的问题"] and answers(base) == ["回答:改过的问题"],
|
||||
"after edit: %s / %s" % (prompts(base), answers(base)))
|
||||
check(call(base, "/api/retry", {"text": ""})[0] == 400, "an empty edit is a 400")
|
||||
check(call(base, "/api/retry", {"text": 5})[0] == 400, "a non-string edit is a 400")
|
||||
|
||||
# 4. a second turn, then retry only touches the last one
|
||||
turn(base, sse, "/api/turn", {"text": "第二句"})
|
||||
turn(base, sse, "/api/retry", {})
|
||||
check(prompts(base) == ["改过的问题", "第二句"] and len(answers(base)) == 2,
|
||||
"retry keeps earlier turns: %s" % prompts(base))
|
||||
|
||||
# 5. retry while a turn runs is a 409
|
||||
n = sse.count("turn_end")
|
||||
call(base, "/api/turn", {"text": "slow 请慢一点"})
|
||||
code, d = call(base, "/api/retry", {})
|
||||
check(code == 409, "retry during a turn: 409 (%s)" % code)
|
||||
wait(lambda: sse.count("turn_end") > n, 20)
|
||||
|
||||
# 6. a note (plan mode changed mid-session) is flagged, and retry skips it
|
||||
call(base, "/api/config", {"plan_mode": True})
|
||||
turn(base, sse, "/api/turn", {"text": "计划一下"})
|
||||
ms = messages(base)
|
||||
notes = [m for m in ms if m.get("note")]
|
||||
check(len(notes) >= 1 and all(m["role"] == "user" for m in notes),
|
||||
"a mid-session note is flagged note:true (%d)" % len(notes))
|
||||
turn(base, sse, "/api/retry", {})
|
||||
check(SEEN[-1] == "计划一下" and prompts(base)[-1] == "计划一下", "retry after a note reruns the prompt")
|
||||
call(base, "/api/config", {"plan_mode": False})
|
||||
|
||||
# 7. export
|
||||
code, ctype, raw = call(base, "/api/export", raw_reply=True)
|
||||
md = raw.decode("utf-8", "replace")
|
||||
check(code == 200 and ctype.startswith("text/markdown"), "export: %s %s" % (code, ctype))
|
||||
check(md.startswith("# ") and "## User" in md and "改过的问题" in md and "## Assistant" in md
|
||||
and "回答:第二句" in md and "第一句" not in md, "export has the transcript")
|
||||
|
||||
# 8. rename and clear
|
||||
st = call(base, "/api/state")[1]
|
||||
cur = st.get("session")
|
||||
code, _ = call(base, "/api/sessions/rename", {"title": "我的会话"})
|
||||
check(code == 200, "rename the open session")
|
||||
ss = call(base, "/api/sessions")[1]
|
||||
check(any(s["id"] == cur and s["title"] == "我的会话" for s in ss.get("sessions", [])),
|
||||
"the list shows the new title")
|
||||
code, _, raw = call(base, "/api/export", raw_reply=True)
|
||||
check(raw.decode("utf-8", "replace").startswith("# 我的会话"), "export uses the title")
|
||||
long = "长" * 100
|
||||
call(base, "/api/sessions/rename", {"id": cur, "title": long})
|
||||
t = [s["title"] for s in call(base, "/api/sessions")[1]["sessions"] if s["id"] == cur][0]
|
||||
check(0 < len(t.encode()) <= 120 and t == "长" * len(t), "a long title is cut on a character (%d bytes)"
|
||||
% len(t.encode()))
|
||||
call(base, "/api/sessions/rename", {"title": ""})
|
||||
t = [s["title"] for s in call(base, "/api/sessions")[1]["sessions"] if s["id"] == cur][0]
|
||||
check(t == "改过的问题", "an empty title falls back to the first prompt (%r)" % t)
|
||||
check(call(base, "/api/sessions/rename", {"id": "nope", "title": "x"})[0] == 404, "rename unknown: 404")
|
||||
check(call(base, "/api/sessions/rename", {"id": "../x", "title": "x"})[0] == 404, "rename ../x: 404")
|
||||
check(call(base, "/api/sessions/rename", {})[0] == 400, "rename without a title: 400")
|
||||
|
||||
# 9. delete another session, then the open one
|
||||
code, d = call(base, "/api/sessions/new", {})
|
||||
second = d.get("id")
|
||||
check(code == 200 and second and second != cur, "a second session")
|
||||
code, d = call(base, "/api/sessions/delete", {"id": cur})
|
||||
check(code == 200 and d.get("current") == second, "delete a closed session (%s %s)" % (code, d))
|
||||
ids = [s["id"] for s in call(base, "/api/sessions")[1]["sessions"]]
|
||||
check(cur not in ids and not os.path.exists(os.path.join(ws, ".agent", "sessions", cur + ".jsonl")),
|
||||
"it is gone from the list and the disk")
|
||||
n = sse.count("session")
|
||||
code, d = call(base, "/api/sessions/delete", {"id": second})
|
||||
check(code == 200 and d.get("current") and d["current"] != second, "delete the open session (%s)" % d)
|
||||
check(call(base, "/api/state")[1].get("session") == d.get("current"), "the state moved to the new one")
|
||||
check(wait(lambda: sse.count("session") > n, 5), "a session event was sent")
|
||||
check(call(base, "/api/sessions/delete", {"id": "nope"})[0] == 404, "delete unknown: 404")
|
||||
check(call(base, "/api/sessions/delete", {})[0] == 400, "delete without an id: 400")
|
||||
|
||||
# 10. history: calls and results come back apart, for native and ctl
|
||||
with open(os.path.join(ws, "hello.txt"), "w", encoding="utf-8") as f:
|
||||
f.write("你好世界\n")
|
||||
turn(base, sse, "/api/turn", {"text": "native 读文件"})
|
||||
ms = messages(base)
|
||||
a = [m for m in ms if m["role"] == "assistant" and m.get("calls")]
|
||||
r = [m for m in ms if m.get("tool_result")]
|
||||
check(len(a) == 1 and a[0]["calls"][0].get("name") == "read_file"
|
||||
and a[0]["calls"][0].get("args", {}).get("path") == "hello.txt" and a[0]["calls"][0].get("id") == "c1",
|
||||
"native: the call is listed with its args (%s)" % (a[0].get("calls") if a else ms))
|
||||
check(len(r) == 1 and r[0]["role"] == "tool" and r[0].get("tool_call_id") == "c1"
|
||||
and "你好世界" in r[0].get("out", "") and not r[0]["out"].startswith("[tool ")
|
||||
and r[0].get("exit") == 0 and r[0].get("truncated") is False,
|
||||
"native: the result carries out/exit without the header (%s)" % r)
|
||||
check(answers(base)[-1] == "读完了", "native: the turn finished")
|
||||
|
||||
check(call(base, "/api/settings", {"protocol": "ctl"})[0] == 200, "switch to the ctl protocol")
|
||||
turn(base, sse, "/api/turn", {"text": "ctl 读文件"})
|
||||
ms = messages(base)
|
||||
a = [m for m in ms if m["role"] == "assistant" and m.get("calls") and m["calls"][0].get("id") == "t1"]
|
||||
r = [m for m in ms if m.get("tool_result") and m.get("tool_call_id") == "t1"]
|
||||
check(len(a) == 1 and a[0].get("text", "").strip() == "我来读。" and "call t1" in a[0]["content"]
|
||||
and a[0]["calls"][0].get("args", {}).get("path") == "hello.txt"
|
||||
and "call t1" not in a[0].get("html", ""),
|
||||
"ctl: prose and calls are split (%s)" % (a[0] if a else ms[-4:]))
|
||||
check(len(r) == 1 and r[0]["role"] == "user" and "你好世界" in r[0].get("out", "")
|
||||
and not r[0]["out"].startswith("result ") and not r[0]["out"].endswith("end")
|
||||
and r[0].get("exit") == 0,
|
||||
"ctl: the result is flagged with out/exit (%s)" % r)
|
||||
check(r and r[0]["content"] not in prompts(base), "ctl: the result is not a prompt")
|
||||
call(base, "/api/settings", {"protocol": "native-eos"})
|
||||
sse.close()
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(20)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
check(False, "server hung on SIGTERM")
|
||||
log.close()
|
||||
shutil.rmtree(ws, ignore_errors=True)
|
||||
shutil.rmtree(home, ignore_errors=True)
|
||||
api.shutdown()
|
||||
print("FAILED:", fails)
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""WebUI workspace views: git diff, run settings, saved permission rules.
|
||||
|
||||
python3 scripts/e2e_webui_workspace.py (CST_AGENT= picks the binary)
|
||||
|
||||
Checks: /api/diff lists git status and the working-tree diff (per file too),
|
||||
an option-looking path is only ever a path, and a directory without git
|
||||
says so; /api/settings reports protocol, tools, confirmations and the
|
||||
sandbox, rejects a bad body without applying any of it, applies a good one,
|
||||
and is a 409 while a turn runs; /api/permissions shows the saved rules and
|
||||
DELETE clears them. HOME points at a temp dir.
|
||||
"""
|
||||
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
|
||||
|
||||
|
||||
class Api(BaseHTTPRequestHandler):
|
||||
"""A slow plain answer, so a turn is running for a while."""
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
time.sleep(2)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.end_headers()
|
||||
for o in ({"delta": {"role": "assistant", "content": "好"}}, {"delta": {}, "finish_reason": "stop"}):
|
||||
chunk = {"id": "x", "object": "chat.completion.chunk", "model": "m", "choices": [dict(index=0, **o)]}
|
||||
self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
|
||||
|
||||
fails = 0
|
||||
|
||||
|
||||
def check(ok, msg):
|
||||
global fails
|
||||
print(("PASS " if ok else "FAIL ") + msg)
|
||||
fails += 0 if ok else 1
|
||||
|
||||
|
||||
def wait(fn, t=15):
|
||||
end = time.time() + t
|
||||
while time.time() < end:
|
||||
v = fn()
|
||||
if v:
|
||||
return v
|
||||
time.sleep(0.1)
|
||||
return None
|
||||
|
||||
|
||||
def call(base, path, obj=None, method=None):
|
||||
data = None if obj is None else json.dumps(obj).encode()
|
||||
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return r.status, json.loads(r.read() or b"{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
return e.code, json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(["git", "-C", ws] + list(args), capture_output=True, text=True)
|
||||
|
||||
|
||||
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
|
||||
threading.Thread(target=api.serve_forever, daemon=True).start()
|
||||
ws = tempfile.mkdtemp(prefix="cst-ws-")
|
||||
plain = tempfile.mkdtemp(prefix="cst-nogit-")
|
||||
home = tempfile.mkdtemp(prefix="cst-home-")
|
||||
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
|
||||
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache",
|
||||
GIT_CONFIG_NOSYSTEM="1")
|
||||
for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
|
||||
env.pop(k, None)
|
||||
have_git = shutil.which("git") is not None
|
||||
if have_git:
|
||||
git("init", "-q")
|
||||
git("config", "user.email", "e2e@example.com")
|
||||
git("config", "user.name", "e2e")
|
||||
open(ws + "/a.txt", "w").write("one\n")
|
||||
git("add", "a.txt")
|
||||
git("commit", "-q", "-m", "first")
|
||||
open(ws + "/a.txt", "w").write("one\n第二行\n")
|
||||
open(ws + "/new.txt", "w").write("new\n")
|
||||
os.makedirs(ws + "/.agent", exist_ok=True)
|
||||
json.dump({"tools": ["write_file"], "shell_prefixes": ["make"]}, open(ws + "/.agent/permissions.json", "w"))
|
||||
|
||||
log = open(home + "/server.log", "w+")
|
||||
proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x",
|
||||
"--provider", "deepseek", "--model", "deepseek-flash",
|
||||
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
|
||||
"--protocol", "native-eos", "--approval", "auto"],
|
||||
stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||
|
||||
|
||||
def port():
|
||||
log.seek(0)
|
||||
for l in log.read().splitlines():
|
||||
if l.startswith("webui: http://127.0.0.1:"):
|
||||
return l.split(":")[3].split("/")[0]
|
||||
|
||||
|
||||
try:
|
||||
base = "http://127.0.0.1:%s" % wait(port)
|
||||
|
||||
# 1. diff
|
||||
if have_git:
|
||||
code, d = call(base, "/api/diff")
|
||||
files = {f["path"]: f["status"] for f in d.get("files", [])}
|
||||
check(code == 200 and d.get("git") is True, "diff: git repo seen")
|
||||
check(files.get("a.txt") == " M" and files.get("new.txt") == "??", "status lists both files: %s" % files)
|
||||
check("+第二行" in d.get("diff", "") and "a.txt" in d.get("diff", ""), "the diff has the change")
|
||||
code, d = call(base, "/api/diff?path=a.txt")
|
||||
check(code == 200 and "+第二行" in d.get("diff", "") and d.get("path") == "a.txt", "diff of one file")
|
||||
code, d = call(base, "/api/diff?path=new.txt")
|
||||
check(code == 200 and d.get("diff") == "", "an untracked file has no diff")
|
||||
evil = ws + "/pwned"
|
||||
code, d = call(base, "/api/diff?path=" + urllib.request.quote("--output=" + evil))
|
||||
check(code == 200 and not os.path.exists(evil), "an option-looking path stays a path")
|
||||
else:
|
||||
print("SKIP diff checks: no git")
|
||||
|
||||
# 2. settings: what is there
|
||||
code, s = call(base, "/api/settings")
|
||||
names = [t["name"] for t in s.get("tools", [])]
|
||||
check(code == 200 and s.get("protocol") == "native-eos" and len(s.get("protocols", [])) == 5,
|
||||
"settings: protocol %s of %s" % (s.get("protocol"), s.get("protocols")))
|
||||
check("read_file" in names and all("perm" in t and "on" in t for t in s["tools"]), "tool list: %d tools" % len(names))
|
||||
check(set(s.get("confirm", {})) == {"shell", "write", "net"}, "confirm flags present")
|
||||
check(isinstance(s.get("sandbox", {}).get("kind"), str), "sandbox kind: %s" % s.get("sandbox"))
|
||||
victim = next((t["name"] for t in s["tools"] if t["perm"] == "net"), names[-1])
|
||||
|
||||
# 3. a bad body changes nothing
|
||||
for body, why in (({"protocol": "nope"}, "unknown protocol"), ({"max_steps": 0}, "max_steps 0"),
|
||||
({"max_steps": "5"}, "max_steps as text"), ({"tool_profile": "huge"}, "unknown profile"),
|
||||
({"tools": {"nope": False}}, "unknown tool"), ({"tools": {victim: "off"}}, "tool value not bool"),
|
||||
({"confirm": {"disk": True}}, "unknown confirm key"),
|
||||
({"max_steps": 9, "protocol": "nope"}, "mixed good and bad")):
|
||||
check(call(base, "/api/settings", body)[0] == 400, "400 for %s" % why)
|
||||
check(call(base, "/api/settings")[1].get("max_steps") == s.get("max_steps"), "nothing applied from a bad body")
|
||||
|
||||
# 4. a good body applies
|
||||
code, d = call(base, "/api/settings", {"protocol": "ctl", "max_steps": 7, "tool_profile": "minimal",
|
||||
"tools": {victim: False}, "confirm": {"shell": True, "net": False}})
|
||||
check(code == 200 and d.get("ok"), "settings accepted (%s %s)" % (code, d))
|
||||
code, s2 = call(base, "/api/settings")
|
||||
t2 = {t["name"]: t for t in s2.get("tools", [])}
|
||||
check(s2.get("protocol") == "ctl" and s2.get("max_steps") == 7 and s2.get("tool_profile") == "minimal",
|
||||
"protocol/steps/profile applied")
|
||||
check(t2.get(victim, {}).get("off") is True and t2[victim]["on"] is False, "%s switched off" % victim)
|
||||
check(s2["confirm"]["shell"] is True and s2["confirm"]["net"] is False, "confirm flags applied")
|
||||
st = call(base, "/api/state")[1]
|
||||
check(st.get("protocol_tools") == "ctl" and st.get("max_steps") == 7 and st.get("tool_profile") == "minimal",
|
||||
"the state shows them")
|
||||
call(base, "/api/settings", {"protocol": "native-eos", "tool_profile": "default", "tools": {victim: True}})
|
||||
t3 = {t["name"]: t for t in call(base, "/api/settings")[1]["tools"]}
|
||||
check(t3[victim]["off"] is False and t3[victim]["on"] is True, "%s back on" % victim)
|
||||
|
||||
# 5. permissions
|
||||
code, p = call(base, "/api/permissions")
|
||||
check(code == 200 and p.get("tools") == ["write_file"] and p.get("shell_prefixes") == ["make"]
|
||||
and "write_file" in p.get("rules", ""), "saved rules: %s %s" % (p.get("tools"), p.get("shell_prefixes")))
|
||||
code, _ = call(base, "/api/permissions", method="DELETE")
|
||||
check(code == 200, "DELETE /api/permissions")
|
||||
p = call(base, "/api/permissions")[1]
|
||||
check(p.get("tools") == [] and p.get("shell_prefixes") == [], "the rules are gone")
|
||||
saved = json.load(open(ws + "/.agent/permissions.json"))
|
||||
check(saved.get("tools") == [] and saved.get("shell_prefixes") == [], "and gone from the file")
|
||||
|
||||
# 6. while a turn runs: settings and the rule reset are 409, reads still work
|
||||
code, _ = call(base, "/api/turn", {"text": "慢慢来"})
|
||||
check(code == 202, "a slow turn started")
|
||||
check(call(base, "/api/settings", {"max_steps": 3})[0] == 409, "settings during a turn: 409")
|
||||
check(call(base, "/api/permissions", method="DELETE")[0] == 409, "rule reset during a turn: 409")
|
||||
check(call(base, "/api/settings")[0] == 200 and call(base, "/api/permissions")[0] == 200,
|
||||
"reads during a turn: 200")
|
||||
if have_git:
|
||||
check(call(base, "/api/diff")[0] == 200, "diff during a turn: 200")
|
||||
wait(lambda: not call(base, "/api/state")[1].get("status_busy"), 20)
|
||||
|
||||
# 7. a directory without git
|
||||
if have_git:
|
||||
code, _ = call(base, "/api/workspace", {"path": plain})
|
||||
code, d = call(base, "/api/diff")
|
||||
check(code == 200 and d.get("git") is False and "diff" not in d, "no git repo: git=false")
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(20)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
check(False, "server hung on SIGTERM")
|
||||
log.close()
|
||||
for d in (ws, plain, home):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
api.shutdown()
|
||||
print("FAILED:", fails)
|
||||
sys.exit(1 if fails else 0)
|
||||
Reference in New Issue
Block a user