Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d70a901b3b | |||
| 24f4ccc1be | |||
| 18e8e7a66c | |||
| ed7ea61b44 | |||
| c3f46eaef7 | |||
| 5315349c83 | |||
| b3e220ba97 | |||
| e8313ac5d0 | |||
| 5508060e99 | |||
| 2a1d793815 | |||
| e5bd7cc0f3 | |||
| 427c3b4441 | |||
| 6e8fc19663 | |||
| 9125532066 |
@@ -210,16 +210,15 @@ module.exports = async ({ github, context, core }) => {
|
||||
}
|
||||
const loadOf = (u) => load.get(u.toLowerCase()) || 0;
|
||||
|
||||
// Helper: take the N most-preferred from a list. Sort key is (rank, load,
|
||||
// random): LLM area-fit rank first (lower = better; Infinity for unranked, so
|
||||
// an all-unranked list -- no rank file -- sorts purely by load, i.e. today's
|
||||
// behavior), then fewest open review requests, then a pre-rolled random value
|
||||
// to break any remaining same-rank-same-load tie. The `!==` guards avoid
|
||||
// subtracting two Infinities (which would be NaN).
|
||||
// Helper: take the N most-preferred from a list. Sort key is (load, rank,
|
||||
// random): fewest open review requests first so workload stays balanced;
|
||||
// LLM area-fit rank breaks ties within the same load bucket; a pre-rolled
|
||||
// random value breaks any remaining tie. The `!==` guards avoid subtracting
|
||||
// two Infinities (which would be NaN).
|
||||
const takeLowest = (list, n) => {
|
||||
const keyed = list.map((u) => ({ u, r: rankOf(u), l: loadOf(u), j: Math.random() }));
|
||||
keyed.sort((a, b) =>
|
||||
a.r !== b.r ? a.r - b.r : a.l !== b.l ? a.l - b.l : a.j - b.j
|
||||
a.l !== b.l ? a.l - b.l : a.r !== b.r ? a.r - b.r : a.j - b.j
|
||||
);
|
||||
return keyed.slice(0, n).map((x) => x.u);
|
||||
};
|
||||
|
||||
@@ -285,41 +285,39 @@ function assert(name, cond, detail) {
|
||||
assert("capped overflow is warned",
|
||||
r.warnings.some((w) => /capping push-down/.test(w)), JSON.stringify(r.warnings));
|
||||
|
||||
// 17. LLM ranking overrides load within the candidate pool: dhruv0811 has the
|
||||
// lowest load (would win on load alone), but the rank prefers dbczumar, an
|
||||
// inner owner -- so dbczumar is chosen.
|
||||
// 17. Load beats LLM rank: dhruv0811 has the lowest load (0) and wins even
|
||||
// though the rank prefers dbczumar (rank 0 but load 1).
|
||||
r = await run({
|
||||
files: ["omnigent/inner/foo.py"],
|
||||
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
|
||||
rank: ["dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"],
|
||||
});
|
||||
assert("LLM rank beats load within the area pool",
|
||||
JSON.stringify(r.added) === JSON.stringify(["dbczumar"]), JSON.stringify(r));
|
||||
assert("load beats LLM rank within the area pool",
|
||||
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
|
||||
|
||||
// 18. Allowlist enforcement: a rank naming someone who does NOT own the touched
|
||||
// area (PattaraS is a maintainer + pool member, but not an inner owner) is
|
||||
// ignored for that entry; the ranking only reorders actual candidates, so
|
||||
// the next ranked inner owner (dbczumar) wins -- never PattaraS.
|
||||
// ignored; the ranking only reorders actual candidates. Load is primary, so
|
||||
// dhruv0811 (load 0) wins over dbczumar (load 1) -- never PattaraS.
|
||||
r = await run({
|
||||
files: ["omnigent/inner/foo.py"],
|
||||
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1, PattaraS: 0 },
|
||||
rank: ["PattaraS", "dbczumar", "TomeHirata", "SabhyaC26", "dhruv0811"],
|
||||
});
|
||||
assert("LLM rank cannot route outside the area owners",
|
||||
JSON.stringify(r.added) === JSON.stringify(["dbczumar"]) && !r.added.includes("PattaraS"),
|
||||
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]) && !r.added.includes("PattaraS"),
|
||||
JSON.stringify(r));
|
||||
|
||||
// 19. Unranked candidates (rank omits them) sort after ranked ones but still by
|
||||
// load: rank lists only SabhyaC26 (highest load); the rest are unranked, so
|
||||
// SabhyaC26 -- despite load 5 -- is preferred because a finite rank beats
|
||||
// Infinity. Confirms the rank-primary / load-secondary ordering.
|
||||
// 19. Load is primary even when only one candidate is ranked: rank lists only
|
||||
// SabhyaC26 (load 5); dhruv0811 is unranked but has load 0, so dhruv0811
|
||||
// wins. Confirms the load-primary / rank-secondary ordering.
|
||||
r = await run({
|
||||
files: ["omnigent/inner/foo.py"],
|
||||
load: { SabhyaC26: 5, TomeHirata: 4, dhruv0811: 0, dbczumar: 1 },
|
||||
rank: ["SabhyaC26"],
|
||||
});
|
||||
assert("a ranked high-load owner beats unranked low-load owners",
|
||||
JSON.stringify(r.added) === JSON.stringify(["SabhyaC26"]), JSON.stringify(r));
|
||||
assert("unranked low-load owner beats ranked high-load owner",
|
||||
JSON.stringify(r.added) === JSON.stringify(["dhruv0811"]), JSON.stringify(r));
|
||||
|
||||
// 20. Adoption still overrides the LLM rank: a linked-issue maintainer assignee
|
||||
// (TomeHirata) is adopted as reviewer even when the rank prefers someone
|
||||
|
||||
@@ -201,25 +201,30 @@ jobs:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
# Derive the per-minor docs staging branch from the runtime version. main
|
||||
# carries X.Y.Z.dev0, so 0.5.0.dev0 → "0.5-docs". All docs for the 0.5 line
|
||||
# (incl. patches) stage on this one branch until release publishes it.
|
||||
# Derive the per-minor docs staging branch and the release version from the
|
||||
# runtime version. main carries X.Y.Z.dev0, so 0.5.0.dev0 → branch "0.5-docs"
|
||||
# and label "v0.5.0". All docs for the 0.5 line (incl. patches) stage on the
|
||||
# one branch until release publishes it; the vX.Y.Z label lets maintainers
|
||||
# filter the staged PRs by the release they'll ship in.
|
||||
- name: Resolve docs branch
|
||||
id: docsbranch
|
||||
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
minor="$(python3 - <<'PYEOF'
|
||||
import pathlib, re
|
||||
python3 - <<'PYEOF'
|
||||
import os, pathlib, re
|
||||
text = pathlib.Path("omnigent/version.py").read_text()
|
||||
m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)', text)
|
||||
m = re.search(r'VERSION\s*=\s*["\']([0-9]+)\.([0-9]+)\.([0-9]+)', text)
|
||||
if not m:
|
||||
raise SystemExit("could not parse X.Y from omnigent/version.py")
|
||||
print(f"{m.group(1)}.{m.group(2)}")
|
||||
raise SystemExit("could not parse X.Y.Z from omnigent/version.py")
|
||||
major, minor, patch = m.groups()
|
||||
branch = f"{major}.{minor}-docs"
|
||||
version = f"v{major}.{minor}.{patch}"
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
|
||||
fh.write(f"branch={branch}\n")
|
||||
fh.write(f"version={version}\n")
|
||||
print(f"::notice::Docs stage on branch {branch} (release {version})")
|
||||
PYEOF
|
||||
)"
|
||||
echo "branch=${minor}-docs" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Docs stage on branch ${minor}-docs"
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
@@ -635,6 +640,7 @@ jobs:
|
||||
PR_NUMBER: ${{ steps.plan.outputs.pr }}
|
||||
REVIEWER: ${{ steps.sitepr.outputs.reviewer }}
|
||||
DOCS_BRANCH: ${{ steps.docsbranch.outputs.branch }}
|
||||
VERSION_LABEL: ${{ steps.docsbranch.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BRANCH="auto/docs/pr-${PR_NUMBER}"
|
||||
@@ -685,17 +691,27 @@ jobs:
|
||||
# bot commits.
|
||||
git push --force "$PUSH_URL" "$BRANCH"
|
||||
|
||||
# The vX.Y.Z label marks which release the staged docs will ship in, so
|
||||
# maintainers can filter the site PRs by release. Ensure it exists (with
|
||||
# automated-docs) before applying it below.
|
||||
gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \
|
||||
--description "Automated documentation update" 2>/dev/null || true
|
||||
gh label create "$VERSION_LABEL" --repo "$SITE_REPO_SLUG" --color FBCA04 \
|
||||
--description "Docs staged for the ${VERSION_LABEL} release" 2>/dev/null || true
|
||||
|
||||
EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \
|
||||
--json number --jq '.[0].number // empty' 2>/dev/null || true)"
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --body-file /tmp/site_pr_body.md || true
|
||||
# --add-label backfills PRs opened before the label existed; it's a no-op
|
||||
# when already present.
|
||||
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" \
|
||||
--add-label "automated-docs" --add-label "$VERSION_LABEL" \
|
||||
--body-file /tmp/site_pr_body.md || true
|
||||
echo "Updated site PR #$EXISTING."
|
||||
else
|
||||
gh label create automated-docs --repo "$SITE_REPO_SLUG" --color 0E8A16 \
|
||||
--description "Automated documentation update" 2>/dev/null || true
|
||||
if gh pr create --repo "$SITE_REPO_SLUG" --base "$DOCS_BRANCH" --head "$BRANCH" \
|
||||
--title "docs: document ${CODE_REPO}#${PR_NUMBER}" \
|
||||
--label automated-docs --body-file /tmp/site_pr_body.md; then
|
||||
--label automated-docs --label "$VERSION_LABEL" --body-file /tmp/site_pr_body.md; then
|
||||
EXISTING="$(gh pr list --repo "$SITE_REPO_SLUG" --head "$BRANCH" --state open \
|
||||
--json number --jq '.[0].number // empty' 2>/dev/null || true)"
|
||||
echo "Opened site PR for $BRANCH."
|
||||
@@ -704,13 +720,17 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# Always attempt the review request, decoupled from PR creation so a
|
||||
# non-addable reviewer can't fail the open. GitHub returns 422 for users it
|
||||
# can't add (non-collaborators / concealed org members); tolerate it — the
|
||||
# reviewer is also @-mentioned in the body as a durable fallback ping.
|
||||
# Always attempt the review request + assignment, decoupled from PR creation
|
||||
# so a non-addable reviewer can't fail the open. GitHub returns 422 for users
|
||||
# it can't add (non-collaborators / concealed org members); tolerate it — the
|
||||
# reviewer is also @-mentioned in the body as a durable fallback ping. The two
|
||||
# calls are independent so one failing doesn't skip the other. Assigning makes
|
||||
# the PR filterable by assignee from the site's PR list.
|
||||
if [ -n "${REVIEWER}" ] && [ -n "${EXISTING}" ]; then
|
||||
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-reviewer "${REVIEWER}" \
|
||||
|| echo "::notice::Could not request review from ${REVIEWER} (not addable); they're @-mentioned in the PR body."
|
||||
gh pr edit "$EXISTING" --repo "$SITE_REPO_SLUG" --add-assignee "${REVIEWER}" \
|
||||
|| echo "::notice::Could not assign ${REVIEWER} (not addable); they're @-mentioned in the PR body."
|
||||
fi
|
||||
|
||||
- name: Note draft skipped (no site token)
|
||||
|
||||
@@ -19,6 +19,9 @@ on:
|
||||
schedule:
|
||||
- cron: "0 9 * * *"
|
||||
pull_request:
|
||||
# labeled/unlabeled: kept for the skip-security-scan recovery path
|
||||
# (rerun-security-gate-run.yml falls back to this trigger). The concurrency
|
||||
# group key isolates label events so they never cancel a code-push run.
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
|
||||
paths-ignore: ['web/**', 'tests/e2e_ui/**']
|
||||
workflow_dispatch:
|
||||
@@ -34,8 +37,9 @@ on:
|
||||
|
||||
concurrency:
|
||||
# PRs key by number, dispatch by branch (so re-runs cancel); schedule keys
|
||||
# by SHA so each merge to `main` gets its own run.
|
||||
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }}
|
||||
# by SHA so each merge to `main` gets its own run. Label events append the
|
||||
# label name so they get an isolated slot and never cancel a code-push run.
|
||||
group: e2e-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.branch || github.sha }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && github.event.label.name || 'run' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -54,11 +58,14 @@ env:
|
||||
jobs:
|
||||
# Security gate: untrusted PRs wait on the deterministic scan
|
||||
# (security-gate.yml); trusted authors and non-PR events pass instantly.
|
||||
# Skip when the automerge label is applied/removed -- safe to short-circuit
|
||||
# here because every non-gate job is transitively downstream of gate, so
|
||||
# no skipped check-run can overwrite an existing result on this SHA.
|
||||
# Short-circuit for label events that aren't skip-security-scan (e.g.
|
||||
# automerge): those run in their own isolated concurrency slot (above) and
|
||||
# don't need the full suite — just exit fast.
|
||||
gate:
|
||||
if: github.event.label.name != 'automerge'
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
|
||||
github.event.label.name == 'skip-security-scan'
|
||||
uses: ./.github/workflows/security-gate.yml
|
||||
|
||||
# Shard matrix (e2e-shard-matrix.sh, shared with e2e-ui.yml). Fork PRs run by
|
||||
|
||||
@@ -503,10 +503,10 @@ jobs:
|
||||
maintainer_assigned=true
|
||||
fi
|
||||
|
||||
# Otherwise, assign an owner for P0/P1 issues: the LLM's top-ranked area
|
||||
# owner, breaking ties by open-assigned-issue load (fairness). Symmetric
|
||||
# with the PR reviewer path (rank primary, load secondary). Skipped if
|
||||
# the maintainer-author was already assigned above.
|
||||
# Otherwise, assign an owner for P0/P1 issues: the least-loaded area
|
||||
# owner, with LLM rank as a tiebreaker (load primary, rank secondary).
|
||||
# Symmetric with the PR reviewer path. Skipped if the maintainer-author
|
||||
# was already assigned above.
|
||||
priority=$(jq -r '.priority // empty' /tmp/triage_result.json)
|
||||
if [ "$maintainer_assigned" = "false" ] && { [ "$priority" = "P0-critical" ] || [ "$priority" = "P1-high" ]; }; then
|
||||
# Open-issue load per candidate (fewest assigned open issues wins ties).
|
||||
@@ -533,12 +533,12 @@ jobs:
|
||||
if a.get("login"):
|
||||
load[a["login"]] += 1
|
||||
|
||||
# Sort by (rank, load, login): LLM rank first, then fewest open issues,
|
||||
# then a stable alphabetical tie-break (deterministic, unlike a random
|
||||
# one — matches the previous round-robin's determinism guarantee).
|
||||
# Sort by (load, rank, login): fewest open assigned issues first so
|
||||
# the workload stays balanced; LLM rank breaks ties within the same
|
||||
# load bucket; alphabetical login is the final deterministic tiebreak.
|
||||
candidates = sorted(
|
||||
candidates,
|
||||
key=lambda u: (rank_of.get(u, float("inf")), load[u], u),
|
||||
key=lambda u: (load[u], rank_of.get(u, float("inf")), u),
|
||||
)
|
||||
assignee = candidates[0] if candidates else ""
|
||||
if assignee:
|
||||
|
||||
@@ -5,6 +5,10 @@ generated at release time from each PR's `## Changelog` section, tagged by the
|
||||
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
|
||||
website under `/releases`.
|
||||
|
||||
## [v0.4.0] — 2026-07-03
|
||||
|
||||
Highlights and full notes: <https://github.com/omnigent-ai/omnigent/releases/tag/v0.4.0>
|
||||
|
||||
## [v0.3.0] — 2026-06-26
|
||||
|
||||
Highlights and full notes: <https://github.com/omnigent-ai/omnigent/releases/tag/v0.3.0>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Queue + steer design
|
||||
|
||||
Client-side message queue with edit / delete / steer / reorder, for both SDK and
|
||||
native harnesses.
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
Today every message is **POSTed the moment the user hits send** — including
|
||||
follow-ups typed while the agent is still working — and rendered immediately as an
|
||||
optimistic bubble. The runner buffers a mid-turn message behind the active turn
|
||||
and delivers it later, but the UI has already committed it. Problems:
|
||||
|
||||
- **No edit / delete / reorder.** Once POSTed the message is server-owned, so the
|
||||
user can't take back or fix a follow-up they queued in a hurry.
|
||||
- **No queued-vs-sent visibility.** A follow-up sent mid-turn looks identical to a
|
||||
normal send — the user can't tell it's waiting behind the active turn, or when
|
||||
it will be picked up.
|
||||
- **Silent cross-harness inconsistency.** The *same* action — "send a follow-up
|
||||
while the agent is working" — behaves differently per harness (mid-turn steer
|
||||
for live-queue SDKs, next-turn for everyone else) with no signal telling the
|
||||
user which they'll get.
|
||||
|
||||
The redesign fixes all three by holding the message in a **client-side queue
|
||||
before it is POSTed**: the user can edit / delete / reorder while it waits, sees
|
||||
it explicitly as "queued", and controls when it's sent (auto-flush on idle, or
|
||||
steer now).
|
||||
|
||||
## 2. Proposal
|
||||
|
||||
Move the queue **client-side**. The strip becomes a pre-POST draft buffer; a
|
||||
message is only sent to the server when it's flushed or steered.
|
||||
|
||||
```
|
||||
type → client queue "⏱ Queued" (NOT posted) → flush/steer → POST → bubble
|
||||
(strip = "not yet sent, still editable"; bubble = "sent, in flight")
|
||||
```
|
||||
|
||||
### Queue behavior
|
||||
|
||||
- **Show as queued** when the agent is **not idle** (`sessionStatus` busy) — same
|
||||
signal for SDK and native.
|
||||
- **Auto-flush head on idle (FIFO):** when the agent goes idle, send the head of
|
||||
the queue as the next turn. Type-ahead "just works" without any click.
|
||||
- Persist the queue in `localStorage` (keyed by session) so it survives a hard
|
||||
refresh. (Trade-off: no cross-device sync — acceptable for unsent drafts.)
|
||||
|
||||
### Per-message actions
|
||||
|
||||
| Action | Behavior |
|
||||
|--------|----------|
|
||||
| **Edit** | pull the message back into the composer, purely client-side; persists across navigation/refresh |
|
||||
| **Delete** | drop the message from the queue |
|
||||
| **Steer** | POST it now (jump the queue) — deliver mid-turn where the harness supports it |
|
||||
| **Reorder** *(optional, follow-up)* | client-side drag to reorder the queue |
|
||||
|
||||
### Promote-to-bubble rule
|
||||
|
||||
Promote a message from the strip into a normal chat bubble **as soon as it is
|
||||
POSTed** (on flush or steer) — *not* when the agent consumes it. Once it's sent
|
||||
there's no longer anything to edit / delete / steer / reorder, so the strip has
|
||||
no reason to hold it.
|
||||
|
||||
The gap between (a) sent to server and (b) consumed by the agent becomes an
|
||||
**implementation detail** the user need not see — because the strip no longer
|
||||
represents server state, only the still-editable client buffer. This removes the
|
||||
consume-timing dependency entirely.
|
||||
|
||||
### What "steer" means per harness
|
||||
|
||||
Steer always POSTs immediately; how it lands depends on the harness:
|
||||
|
||||
| Harness | Steer delivery | Mid-turn? |
|
||||
|---------|----------------|-----------|
|
||||
| claude-sdk / codex-sdk / pi-sdk | runner **live injection** (`_live_response_id` gate) | ✅ deterministic |
|
||||
| cursor-sdk / copilot-sdk | buffer & drain | ❌ next turn |
|
||||
| **codex-native** | explicit **`turn/steer`** RPC when a turn is active | ✅ deterministic |
|
||||
| **claude-native** (and paste-based natives) | runner drains → `send-keys` into the **live pane**; the app treats the paste as a steer | ⚠️ best-effort (drain-vs-response race) |
|
||||
|
||||
> **TODO:** sanity-check the remaining harnesses (cursor-native, pi-native,
|
||||
> qwen-native, opencode-native, goose-native, hermes-native, kimi-native,
|
||||
> antigravity-native, kiro-native, …) — confirm whether each is deterministic
|
||||
> (`turn/steer`-style RPC) or best-effort (paste into live pane) before relying on
|
||||
> steer behavior.
|
||||
|
||||
**No runner change is required for native steer** — native `run_turn` clears the
|
||||
turn right after the paste, so the drain fires the next message quickly and it
|
||||
lands in the live pane, where the native app does its own steering. Frame the UX
|
||||
honestly: *"send now; the agent folds it into current work if it can"* — which is
|
||||
exactly how native type-ahead already feels. Do **not** promise deterministic
|
||||
mid-turn for paste-based natives.
|
||||
|
||||
**Steer is not interrupt.** In every case above, steer *does not cancel* the
|
||||
running turn — the message is folded in at the agent's next natural breakpoint
|
||||
(after the current tool/step completes), the same feel as steering native Claude
|
||||
by typing while it works. For SDK, `enqueue_session_message` adds the message to
|
||||
the running session's queue; the SDK surfaces it at its next turn-boundary — no
|
||||
teardown. This is distinct from the **Interrupt** button, which really does
|
||||
cancel the turn (`turn.cancel()`).
|
||||
|
||||
### Edges to handle
|
||||
|
||||
| Edge | Rule |
|
||||
|------|------|
|
||||
| POST fails after promote | revert the bubble to the queue (or error-badge it) |
|
||||
| Agent goes idle mid-edit | editing pins the message out of auto-flush until re-committed |
|
||||
| Native mirror-back | consume/mirror still needed as a **reconcile** signal (id-match the optimistic bubble to the real transcript item) so native round-trips don't double-render |
|
||||
|
||||
## 3. Appendix — lifecycle & topology
|
||||
|
||||
### Component topology
|
||||
|
||||
```
|
||||
┌──────────┐ HTTPS+SSE ┌──────────────┐ HTTP ┌──────────┐ HTTP/UNIX socket ┌─────────────────┐
|
||||
│ CLIENT │◄───────────►│ AP SERVER │◄──────►│ RUNNER │◄──────────────────►│ HARNESS SUBPROC │
|
||||
│ (browser)│ │ persist+relay│ │ buffer + │ (1 per conv) │ EXECUTOR=agent │
|
||||
└──────────┘ └──────────────┘ │ schedule │ │ SDK: in-process │
|
||||
└──────────┘ │ native: →app ───┼─► tmux / RPC
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
The agent runs **inside the harness subprocess** (SDK loop) or is **bridged out**
|
||||
of it to a real app (native). It does **not** live in the runner process.
|
||||
|
||||
### Busy/idle signal (drives the queue)
|
||||
|
||||
| Harness | "running" from | "idle" from |
|
||||
|---------|----------------|-------------|
|
||||
| SDK | `response.created` → `_live_response_id` set | `response.completed` / stream-end |
|
||||
| native | `UserPromptSubmit` hook | `Stop` / `StopFailure` hook (relayed by the transcript forwarder) |
|
||||
|
||||
Both surface to the client as the same `sessionStatus` field, seeded from the
|
||||
snapshot on bind (correct after refresh, across tabs).
|
||||
|
||||
### Live-injection gate (SDK steer)
|
||||
|
||||
```python
|
||||
_can_forward = (
|
||||
not _native # native uses paste / turn-steer, not this path
|
||||
and not _awaiting_approval # don't steer a turn parked on a human gate
|
||||
and conversation_id in _live_response_id # a response is actually streaming
|
||||
)
|
||||
```
|
||||
|
||||
### Native decoupling (why paste-steer works)
|
||||
|
||||
Native `run_turn` returns as soon as `send-keys` finishes pasting (not when the
|
||||
agent finishes). `_active_turns` clears immediately, so the buffer drains the
|
||||
next message quickly and it pastes into the still-live pane — the native app then
|
||||
decides to steer it. `_native_pane_status` is the reliable liveness signal for a
|
||||
long autonomous native turn (since `_active_turns` clears early).
|
||||
@@ -311,6 +311,39 @@ deltas as `UNSUPPORTED`, not `PARTIAL`. This bit the transcript-mirror natives
|
||||
message rather than streaming deltas: they declare `streaming=False` →
|
||||
`UNSUPPORTED`, matching what the probe observes.
|
||||
|
||||
## Which transport exercises which dimension
|
||||
|
||||
Not every dimension is observable on every transport, so a `·` (SKIPPED) in a
|
||||
default run often means "this transport can't exercise it here," not "the
|
||||
harness lacks it." Two dimensions in particular only get a real verdict on the
|
||||
`full-server` transport:
|
||||
|
||||
| Dimension | sdk-inproc | full-server | native-tui |
|
||||
|---|---|---|---|
|
||||
| Basic turn, Streaming, Model override, Interrupt | ✓ | ✓ | ✓ |
|
||||
| **Tool calling** | · (harness dispatches tools internally) | ✓ (server-dispatched builtin) | · (not yet wired) |
|
||||
| **Policy DENY** | · (wrap-direct: no tool-call policy hook) | ✓ (spec-baked deny, enforced) | · (not yet wired) |
|
||||
|
||||
So to see Tool calling and Policy DENY actually proven, run the SDK harnesses
|
||||
over `full-server`:
|
||||
|
||||
```
|
||||
python -m tests.harness_bench --harness claude-sdk --profile oss --transport full-server
|
||||
```
|
||||
|
||||
Live-verified: `claude-sdk` completes the full matrix on `full-server` —
|
||||
Tool calling `✓` and Policy DENY `✓` (the deny is delivered and the blocked
|
||||
call does not stall the turn). The default `--profile oss` run shows `·` for
|
||||
those two columns only because it uses `sdk-inproc` (for SDK harnesses) and
|
||||
`native-tui` (for natives), neither of which routes a tool call through a
|
||||
server policy evaluation.
|
||||
|
||||
`full-server` covers **SDK harnesses only** — it registers the harness via an
|
||||
agent bundle, which is the SDK-wrap path; native harnesses need the host-daemon
|
||||
provisioning the `native-tui` driver owns. So Tool calling / Policy DENY for
|
||||
native harnesses remain genuinely unwired (a follow-up), distinct from the
|
||||
sdk-inproc `·` which is a transport limitation with `full-server` as the answer.
|
||||
|
||||
## Open items
|
||||
|
||||
- Exact `BenchProfile` field set and whether it subsumes `HarnessProbe` or wraps
|
||||
@@ -319,3 +352,12 @@ message rather than streaming deltas: they declare `streaming=False` →
|
||||
an exported CSV so the sheet stays canonical during transition.
|
||||
- Native transport drivers are the larger half of the work; sequence them by
|
||||
which harnesses matter most for the matrix.
|
||||
- `full-server` cannot yet provision codex / pi gateway auth (their basic turn
|
||||
fails with an empty/absent gateway token), so Tool calling / Policy DENY are
|
||||
only live-proven on `claude-sdk` today; wiring codex/pi full-server auth would
|
||||
extend that coverage. (The token-provisioning failure is classified as a SKIP,
|
||||
not a false capability drift.)
|
||||
- Tool calling / Policy DENY on the `native-tui` transport are unwired — native
|
||||
tool calls are the vendor's own and a native deny is a vendor permission
|
||||
decision, not a server-dispatched `function_call_output`; observing them needs
|
||||
new driver work.
|
||||
|
||||
@@ -124,6 +124,10 @@ _TMUX_SEND_TIMEOUT_S = 5.0
|
||||
# The glyph persists while Claude is busy responding, so its presence
|
||||
# means "input box mounted" (not "idle"), which is what injection needs.
|
||||
_CLAUDE_PROMPT_GLYPH = "❯"
|
||||
# Box-drawing glyphs Claude Code's input-box frame is made of. A line of
|
||||
# these below ``❯`` marks the live input box (see ``_is_box_rule``),
|
||||
# distinguishing it from a bare prompt echoed into scrollback.
|
||||
_BOX_RULE_CHARS = frozenset("─━╭╮╰╯│┃╌╍")
|
||||
# How many trailing non-empty lines to scan for the prompt glyph. The
|
||||
# input box sits near the bottom of the pane; scanning only the tail
|
||||
# avoids false positives from the glyph appearing in scrollback output.
|
||||
@@ -131,6 +135,12 @@ _CLAUDE_PROMPT_GLYPH = "❯"
|
||||
# people's statuslines run ~3 lines — so the ``❯`` row isn't the last
|
||||
# non-empty line.
|
||||
_PROMPT_SCAN_TAIL_LINES = 5
|
||||
# Injecting a message mid-turn grows the footer with running-state rows
|
||||
# (a ``○ Explore …`` subagent line, extra spinners) that push ``❯`` above
|
||||
# the window above. We trust a glyph this deep only when it's framed by a
|
||||
# box rule (the live input box), so this wider window can't false-match a
|
||||
# bare ``❯`` echoed into scrollback output.
|
||||
_PROMPT_SCAN_TAIL_LINES_FRAMED = 8
|
||||
_CLAUDE_READY_POLL_INTERVAL_S = 0.15
|
||||
_PASTE_SETTLE_S = 0.1 # let the TUI commit a paste before the separate submit Enter
|
||||
# How long to wait for the pasted draft to visibly land in Claude's
|
||||
@@ -2831,11 +2841,43 @@ def _claude_prompt_rendered(pane: str) -> bool:
|
||||
positives from the glyph appearing in scrollback (e.g. echoed in a
|
||||
prior response), since the live input box always sits at the bottom.
|
||||
|
||||
A mid-turn injection grows the footer with running-state rows (a
|
||||
``○ Explore …`` subagent line, extra spinners) that can push ``❯``
|
||||
past that window. To reach it without also matching a scrollback
|
||||
echo, a glyph in the wider :data:`_PROMPT_SCAN_TAIL_LINES_FRAMED`
|
||||
window counts only when it's framed by a box rule — the ``────``
|
||||
closing line the live input box always renders below ``❯`` but a
|
||||
bare echoed prompt never has.
|
||||
|
||||
:param pane: Captured pane text from :func:`_capture_pane`.
|
||||
:returns: ``True`` when the input box appears mounted.
|
||||
"""
|
||||
non_empty = [line for line in pane.splitlines() if line.strip()]
|
||||
return any(_CLAUDE_PROMPT_GLYPH in line for line in non_empty[-_PROMPT_SCAN_TAIL_LINES:])
|
||||
if any(_CLAUDE_PROMPT_GLYPH in line for line in non_empty[-_PROMPT_SCAN_TAIL_LINES:]):
|
||||
return True
|
||||
# Deeper in the tail, trust the glyph only when a box rule sits below
|
||||
# it — the live input box's closing frame, absent from scrollback.
|
||||
tail = non_empty[-_PROMPT_SCAN_TAIL_LINES_FRAMED:]
|
||||
for idx, line in enumerate(tail):
|
||||
if _CLAUDE_PROMPT_GLYPH in line and any(_is_box_rule(rule) for rule in tail[idx + 1 :]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_box_rule(line: str) -> bool:
|
||||
"""
|
||||
Return whether a line is a TUI box-drawing horizontal rule.
|
||||
|
||||
Claude Code frames its input box with rows of ``─`` (plus corner
|
||||
glyphs). Such a rule below ``❯`` marks the live input box, letting
|
||||
the readiness scan reach a prompt buried under a tall running-turn
|
||||
footer without matching a bare ``❯`` echoed into scrollback.
|
||||
|
||||
:param line: A single pane line, e.g. ``"──────────"``.
|
||||
:returns: ``True`` when the line is predominantly box-rule glyphs.
|
||||
"""
|
||||
stripped = line.strip()
|
||||
return len(stripped) >= 3 and all(ch in _BOX_RULE_CHARS for ch in stripped)
|
||||
|
||||
|
||||
def _submit_needle(content: str) -> str:
|
||||
|
||||
+178
-30
@@ -233,11 +233,14 @@ _DAEMON_RECONNECT_GRACE_S = 5.0
|
||||
_DAEMON_REUSE_MIN_AGE_S = 6.0
|
||||
|
||||
# How long uvicorn waits for active connections (WebSocket, SSE) after
|
||||
# SIGTERM before force-closing them. 30 s gives in-flight responses time
|
||||
# to drain while still guaranteeing the port is released promptly.
|
||||
# SIGTERM before force-closing them. SSE streams signal themselves via
|
||||
# session_stream.shutdown_all() in _ShutdownSignalingServer.shutdown(),
|
||||
# so the main remaining consumers of this window are WebSocket tunnels
|
||||
# that need a moment to drain. 5 s is enough for a clean tunnel teardown
|
||||
# while keeping Ctrl-C feeling instant.
|
||||
# Overridable via OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S for deployments that
|
||||
# need a longer drain window (e.g. large file uploads).
|
||||
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 30
|
||||
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S_DEFAULT = 5
|
||||
_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S = int(
|
||||
os.environ.get(
|
||||
"OMNIGENT_SERVER_SHUTDOWN_TIMEOUT_S",
|
||||
@@ -1196,6 +1199,7 @@ _CLICK_SUBCOMMANDS: frozenset[str] = frozenset(
|
||||
"qwen",
|
||||
"resume",
|
||||
"run",
|
||||
"session",
|
||||
"sandbox",
|
||||
"server",
|
||||
"setup",
|
||||
@@ -2972,6 +2976,7 @@ def server(
|
||||
port = _picked
|
||||
|
||||
import uvicorn
|
||||
import uvicorn.server
|
||||
|
||||
from omnigent.runner.transports.ws_tunnel.limits import (
|
||||
RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||||
@@ -3220,34 +3225,71 @@ def server(
|
||||
# this foreground server instead of tearing it down on a spurious
|
||||
# sig mismatch.
|
||||
register_local_server(port)
|
||||
|
||||
class _ShutdownSignalingServer(uvicorn.server.Server):
|
||||
"""uvicorn.Server that signals active SSE subscribers before the
|
||||
graceful-shutdown wait starts.
|
||||
|
||||
uvicorn calls ``Server.shutdown()`` in this order:
|
||||
1. close listening sockets / call connection.shutdown()
|
||||
2. ``asyncio.wait_for(_wait_tasks_to_complete(), timeout=…)``
|
||||
3. force-cancel remaining tasks on timeout
|
||||
4. run the ASGI lifespan shutdown handler
|
||||
|
||||
The ASGI lifespan ``finally`` block runs at step 4 — too late. SSE
|
||||
generators waiting on a heartbeat tick are already force-cancelled by
|
||||
step 3, which produces spurious ``CancelledError`` tracebacks.
|
||||
Overriding here lets us drain SSE streams before step 2 so they exit
|
||||
cleanly within the graceful window.
|
||||
"""
|
||||
|
||||
async def shutdown(self, sockets=None) -> None: # type: ignore[override]
|
||||
import asyncio as _asyncio
|
||||
|
||||
from omnigent.runtime import session_stream as _session_stream
|
||||
|
||||
_session_stream.shutdown_all()
|
||||
# Yield to the event loop so generators can consume _DONE,
|
||||
# flush their final "data: [DONE]\n\n" chunk, and exit before
|
||||
# super().shutdown() calls connection.shutdown() / transport.close().
|
||||
# Without this pause the generators write to an already-closing
|
||||
# transport, leaving connections open past the graceful window.
|
||||
await _asyncio.sleep(0)
|
||||
await super().shutdown(sockets)
|
||||
|
||||
_config = uvicorn.Config(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_config=_server_uvicorn_log_config(),
|
||||
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||||
# Server side of the runner/host tunnels' protocol keepalive, aligned
|
||||
# to the 90 s app-level budget instead of uvicorn's 20 s default that
|
||||
# drops a busy-but-healthy tunnel with 1011 — issue #1116.
|
||||
#
|
||||
# uvicorn's ws_ping_* is server-global (no per-route override), so this
|
||||
# 30 s/90 s budget also applies to the app's other WebSocket routes —
|
||||
# /v1/sessions/updates (browser stream) and .../terminals/{id}/attach.
|
||||
# Deliberate and acceptable: for an IDLE such socket the protocol
|
||||
# PING/PONG is the only half-open detector (the sessions-updates
|
||||
# heartbeat is a server->client send, and an idle terminal has no
|
||||
# traffic), so widening it means a dead idle browser/terminal socket is
|
||||
# reaped at worst ~120 s (30 s interval + 90 s timeout) instead of
|
||||
# ~40 s — a slightly later half-open cleanup (e.g. the out-of-process
|
||||
# terminal-attach proxy holds its runner socket + tmux child ~80 s
|
||||
# longer), bounded and eventually reaped, not a leak or correctness
|
||||
# change. The tunnels are the sockets that actually need the looser
|
||||
# budget (issue #1116).
|
||||
ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
|
||||
ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
|
||||
timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
|
||||
)
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_config=_server_uvicorn_log_config(),
|
||||
ws_max_size=RUNNER_TUNNEL_MAX_MESSAGE_BYTES,
|
||||
# Server side of the runner/host tunnels' protocol keepalive, aligned
|
||||
# to the 90 s app-level budget instead of uvicorn's 20 s default that
|
||||
# drops a busy-but-healthy tunnel with 1011 — issue #1116.
|
||||
#
|
||||
# uvicorn's ws_ping_* is server-global (no per-route override), so this
|
||||
# 30 s/90 s budget also applies to the app's other WebSocket routes —
|
||||
# /v1/sessions/updates (browser stream) and .../terminals/{id}/attach.
|
||||
# Deliberate and acceptable: for an IDLE such socket the protocol
|
||||
# PING/PONG is the only half-open detector (the sessions-updates
|
||||
# heartbeat is a server->client send, and an idle terminal has no
|
||||
# traffic), so widening it means a dead idle browser/terminal socket is
|
||||
# reaped at worst ~120 s (30 s interval + 90 s timeout) instead of
|
||||
# ~40 s — a slightly later half-open cleanup (e.g. the out-of-process
|
||||
# terminal-attach proxy holds its runner socket + tmux child ~80 s
|
||||
# longer), bounded and eventually reaped, not a leak or correctness
|
||||
# change. The tunnels are the sockets that actually need the looser
|
||||
# budget (issue #1116).
|
||||
ws_ping_interval=TUNNEL_KEEPALIVE_PING_INTERVAL_S,
|
||||
ws_ping_timeout=TUNNEL_KEEPALIVE_PING_TIMEOUT_S,
|
||||
timeout_graceful_shutdown=_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
|
||||
)
|
||||
_ShutdownSignalingServer(_config).run()
|
||||
except KeyboardInterrupt:
|
||||
# uvicorn.run() swallows KeyboardInterrupt; match that behaviour so
|
||||
# a Ctrl-C exit doesn't print Click's "Aborted!" or exit non-zero.
|
||||
pass
|
||||
finally:
|
||||
if _is_canonical_local_server:
|
||||
clear_local_server_record()
|
||||
@@ -5501,6 +5543,112 @@ def resume(
|
||||
)
|
||||
|
||||
|
||||
@cli.group("session", invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def session(ctx: click.Context) -> None:
|
||||
"""Manage Omnigent sessions.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
omnigent session export --id conv_abc123
|
||||
omnigent session export --id conv_abc123 --output transcript.jsonl
|
||||
omnigent session export --id conv_abc123 --server https://myserver.com
|
||||
"""
|
||||
if ctx.invoked_subcommand is None:
|
||||
click.echo(ctx.get_help())
|
||||
|
||||
|
||||
@session.command("export")
|
||||
@click.option(
|
||||
"--id",
|
||||
"session_id",
|
||||
required=True,
|
||||
metavar="SESSION_ID",
|
||||
help="Session ID to export, e.g. conv_abc123.",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
"output",
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="Output file path. Defaults to <SESSION_ID>.jsonl in the current directory.",
|
||||
)
|
||||
@click.option(
|
||||
"--server",
|
||||
default=None,
|
||||
help=(
|
||||
"Omnigent server URL. "
|
||||
"Defaults to the configured server, or a local server already running."
|
||||
),
|
||||
)
|
||||
def session_export(session_id: str, output: str | None, server: str | None) -> None:
|
||||
"""Export a session transcript to a portable JSONL file.
|
||||
|
||||
Each line of the output is a JSON object. The first line carries
|
||||
the session metadata (``"record_type": "session_meta"``); every
|
||||
subsequent line is one conversation item
|
||||
(``"record_type": "item"``). The file preserves full turn order
|
||||
and can be re-imported with a future ``omnigent session import``.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
omnigent session export --id conv_abc123
|
||||
omnigent session export --id conv_abc123 --output my_session.jsonl
|
||||
omnigent session export --id conv_abc123 --server https://myserver.com
|
||||
"""
|
||||
import httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
|
||||
cfg = _load_effective_config()
|
||||
base_url = _resolve_attach_server(server, cfg.get("server"))
|
||||
if base_url is None:
|
||||
startup = ensure_local_omnigent_server()
|
||||
base_url = startup.url
|
||||
|
||||
base_url = base_url.rstrip("/")
|
||||
out_path = Path(output) if output else Path(f"{session_id}.jsonl")
|
||||
|
||||
with httpx.Client(
|
||||
base_url=base_url, headers=_remote_headers(server_url=base_url), timeout=30.0
|
||||
) as client:
|
||||
# Fetch session metadata (items fetched separately via pagination).
|
||||
resp = client.get(
|
||||
f"/v1/sessions/{session_id}",
|
||||
params={"include_items": "false", "include_liveness": "false"},
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise click.ClickException(f"Session {session_id!r} not found.")
|
||||
resp.raise_for_status()
|
||||
session_data = resp.json()
|
||||
|
||||
n_items = 0
|
||||
with out_path.open("w", encoding="utf-8") as fh:
|
||||
# First line: session metadata.
|
||||
meta_record = {"record_type": "session_meta", **session_data}
|
||||
fh.write(json.dumps(meta_record) + "\n")
|
||||
|
||||
# Remaining lines: items in ascending order, paginated.
|
||||
after: str | None = None
|
||||
while True:
|
||||
params: dict[str, str | int] = {"limit": 500, "order": "asc"}
|
||||
if after:
|
||||
params["after"] = after
|
||||
items_resp = client.get(f"/v1/sessions/{session_id}/items", params=params)
|
||||
items_resp.raise_for_status()
|
||||
page = items_resp.json()
|
||||
for item in page["data"]:
|
||||
item_record = {"record_type": "item", **item}
|
||||
fh.write(json.dumps(item_record) + "\n")
|
||||
n_items += 1
|
||||
if not page.get("has_more"):
|
||||
break
|
||||
after = page.get("last_id")
|
||||
|
||||
click.echo(f"Exported {n_items} item(s) from {session_id} to {out_path}")
|
||||
|
||||
|
||||
# Shared option help for ``run`` and the harness commands. These are the same
|
||||
# flags the legacy argparse CLI exposed — keeping them on the unified
|
||||
# click CLI so users don't regress when a YAML declares no executor
|
||||
|
||||
@@ -708,6 +708,11 @@ def pick_local_port(preferred: int = _DEFAULT_LOCAL_PORT) -> int:
|
||||
import socket
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
# SO_REUSEADDR mirrors what uvicorn sets when it binds. Without
|
||||
# it, a fast server restart sees EADDRINUSE on macOS/BSD because
|
||||
# recently closed connections are still in TIME_WAIT even though
|
||||
# the listening socket is gone and uvicorn could successfully bind.
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
s.bind(("127.0.0.1", preferred))
|
||||
except OSError:
|
||||
|
||||
@@ -125,6 +125,23 @@ def close(conversation_id: str) -> None:
|
||||
loop.call_soon_threadsafe(queue.put_nowait, _DONE)
|
||||
|
||||
|
||||
def shutdown_all() -> None:
|
||||
"""Signal all active subscribers across every conversation to exit.
|
||||
|
||||
Broadcasts the end-of-stream sentinel to every queued subscriber so
|
||||
SSE generators return at their next iteration without waiting for a
|
||||
heartbeat timeout or forced task cancellation. Called from the asyncio
|
||||
event loop (``_ShutdownSignalingServer.shutdown`` in ``cli.py``) before
|
||||
uvicorn's graceful-shutdown wait starts, so streams drain within the
|
||||
window rather than being force-cancelled. Sync callers should use
|
||||
:func:`close` per-conversation instead.
|
||||
"""
|
||||
with _lock:
|
||||
all_subs = [entry for subs in _subscribers.values() for entry in subs]
|
||||
for queue, _ in all_subs:
|
||||
queue.put_nowait(_DONE)
|
||||
|
||||
|
||||
async def subscribe(
|
||||
conversation_id: str,
|
||||
*,
|
||||
|
||||
+28
-25
@@ -1151,6 +1151,7 @@ def test_server_command_reads_tunnel_token_and_does_not_spawn_runner(
|
||||
:returns: None.
|
||||
"""
|
||||
import uvicorn
|
||||
import uvicorn.server
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
@@ -1165,22 +1166,27 @@ def test_server_command_reads_tunnel_token_and_does_not_spawn_runner(
|
||||
captured["create_app_kwargs"] = kwargs
|
||||
return _original_create_app(**kwargs)
|
||||
|
||||
def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None:
|
||||
"""Skip the blocking server loop.
|
||||
def _fake_server_run(self: Any) -> None:
|
||||
"""Skip the blocking server loop; capture config as flat kwargs dict.
|
||||
|
||||
:param app: FastAPI app instance built by ``create_app``.
|
||||
:param kwargs: Uvicorn options (host, port).
|
||||
:param self: The uvicorn Server instance whose config holds all options.
|
||||
:returns: None.
|
||||
"""
|
||||
del app
|
||||
captured["uvicorn_kwargs"] = kwargs
|
||||
captured["uvicorn_kwargs"] = {
|
||||
"ws_max_size": self.config.ws_max_size,
|
||||
"ws_ping_interval": self.config.ws_ping_interval,
|
||||
"ws_ping_timeout": self.config.ws_ping_timeout,
|
||||
"log_config": self.config.log_config,
|
||||
"port": self.config.port,
|
||||
"host": self.config.host,
|
||||
}
|
||||
captured["uvicorn_called"] = True
|
||||
|
||||
from omnigent.server import app as app_module
|
||||
|
||||
_original_create_app = app_module.create_app
|
||||
monkeypatch.setattr(app_module, "create_app", _spy_create_app)
|
||||
monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run)
|
||||
monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run)
|
||||
monkeypatch.setenv("OMNIGENT_RUNNER_TUNNEL_TOKEN", "test-tunnel-token-abc")
|
||||
|
||||
# On a loopback bind the `server` command reuses an already-running
|
||||
@@ -1248,6 +1254,7 @@ def test_server_with_explicit_db_does_not_reuse_canonical_server(
|
||||
shared pidfile.
|
||||
"""
|
||||
import uvicorn
|
||||
import uvicorn.server
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
_original_create_app = None
|
||||
@@ -1261,22 +1268,20 @@ def test_server_with_explicit_db_does_not_reuse_canonical_server(
|
||||
captured["create_app_kwargs"] = kwargs
|
||||
return _original_create_app(**kwargs)
|
||||
|
||||
def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None:
|
||||
def _fake_server_run(self: Any) -> None:
|
||||
"""Skip the blocking server loop, record that it was called.
|
||||
|
||||
:param app: FastAPI app built by ``create_app``.
|
||||
:param kwargs: Uvicorn options (host, port, ...).
|
||||
:param self: The uvicorn Server instance.
|
||||
:returns: None.
|
||||
"""
|
||||
del app
|
||||
captured["uvicorn_kwargs"] = kwargs
|
||||
captured["uvicorn_kwargs"] = {"port": self.config.port}
|
||||
captured["uvicorn_called"] = True
|
||||
|
||||
from omnigent.server import app as app_module
|
||||
|
||||
_original_create_app = app_module.create_app
|
||||
monkeypatch.setattr(app_module, "create_app", _spy_create_app)
|
||||
monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run)
|
||||
monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run)
|
||||
|
||||
# A healthy canonical server EXISTS. A bare `omnigent server` would
|
||||
# reuse it; an explicit-DB server must ignore it. register/clear must
|
||||
@@ -1334,19 +1339,18 @@ def test_server_with_explicit_port_does_not_check_canonical_server(
|
||||
:returns: None.
|
||||
"""
|
||||
import uvicorn
|
||||
import uvicorn.server
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None:
|
||||
def _fake_server_run(self: Any) -> None:
|
||||
"""
|
||||
Skip the blocking server loop.
|
||||
|
||||
:param app: FastAPI app instance built by ``create_app``.
|
||||
:param kwargs: Uvicorn options (host, port).
|
||||
:param self: The uvicorn Server instance.
|
||||
:returns: None.
|
||||
"""
|
||||
del app
|
||||
captured["uvicorn_kwargs"] = kwargs
|
||||
captured["uvicorn_kwargs"] = {"port": self.config.port}
|
||||
|
||||
def _must_not_check_existing() -> str | None:
|
||||
"""
|
||||
@@ -1367,7 +1371,7 @@ def test_server_with_explicit_port_does_not_check_canonical_server(
|
||||
|
||||
from omnigent.host import local_server as _local_server_mod
|
||||
|
||||
monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run)
|
||||
monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run)
|
||||
monkeypatch.setattr(_local_server_mod, "local_server_url_if_healthy", _must_not_check_existing)
|
||||
monkeypatch.setattr(_local_server_mod, "register_local_server", _must_not_touch_pidfile)
|
||||
monkeypatch.setattr(_local_server_mod, "clear_local_server_record", _must_not_touch_pidfile)
|
||||
@@ -1447,19 +1451,18 @@ def test_server_command_explicit_port_uses_bind_probe_not_connect_probe(
|
||||
import socket
|
||||
|
||||
import uvicorn
|
||||
import uvicorn.server
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake_uvicorn_run(app: Any, **kwargs: Any) -> None:
|
||||
def _fake_server_run(self: Any) -> None:
|
||||
"""
|
||||
Skip the blocking server loop.
|
||||
|
||||
:param app: FastAPI app instance built by ``create_app``.
|
||||
:param kwargs: Uvicorn options (host, port).
|
||||
:param self: The uvicorn Server instance.
|
||||
:returns: None.
|
||||
"""
|
||||
del app
|
||||
captured["uvicorn_kwargs"] = kwargs
|
||||
captured["uvicorn_kwargs"] = {"port": self.config.port}
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
@@ -1468,7 +1471,7 @@ def test_server_command_explicit_port_uses_bind_probe_not_connect_probe(
|
||||
with pytest.raises(OSError):
|
||||
socket.create_connection(("127.0.0.1", port), timeout=0.01)
|
||||
|
||||
monkeypatch.setattr(uvicorn, "run", _fake_uvicorn_run)
|
||||
monkeypatch.setattr(uvicorn.server.Server, "run", _fake_server_run)
|
||||
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", "0")
|
||||
|
||||
db_path = tmp_path / "chat.db"
|
||||
|
||||
@@ -91,6 +91,15 @@ _INFRA_ERROR_MARKERS: tuple[str, ...] = (
|
||||
# Sequencing, not capability: a prior turn on the shared session had not
|
||||
# fully settled. Reported SKIPPED so it never reads as a capability gap.
|
||||
"already processing",
|
||||
# Token provisioning failed before the harness could reach the model — an
|
||||
# environment/auth gap (a missing/empty gateway token, a provider auth
|
||||
# command that produced nothing), not a capability the harness lacks.
|
||||
# Seen on full-server for codex ("provider auth command ... empty token")
|
||||
# and pi ("could not fetch a gateway token").
|
||||
"could not fetch a gateway token",
|
||||
"provider auth command",
|
||||
"empty token",
|
||||
"Failed to resolve external API key auth",
|
||||
)
|
||||
|
||||
|
||||
@@ -125,6 +134,19 @@ def infra_failure_reason(result: TurnResult) -> str | None:
|
||||
)
|
||||
if "already processing" in text:
|
||||
return "session busy from a prior turn (sequencing, not a capability gap)"
|
||||
if any(
|
||||
marker in text
|
||||
for marker in (
|
||||
"could not fetch a gateway token",
|
||||
"provider auth command",
|
||||
"empty token",
|
||||
"Failed to resolve external API key auth",
|
||||
)
|
||||
):
|
||||
return (
|
||||
"gateway/provider token could not be provisioned for this transport "
|
||||
"(environment/auth gap, not a capability the harness lacks)"
|
||||
)
|
||||
if "unexpected status" in text:
|
||||
return "gateway returned an unexpected status (environment/auth issue)"
|
||||
return "environment/connectivity error reaching the gateway"
|
||||
|
||||
@@ -173,16 +173,29 @@ class FullServerDriver:
|
||||
@staticmethod
|
||||
def unavailable(profile: BenchProfile, *, databricks_profile: str | None) -> str | None:
|
||||
"""Return a skip reason if this driver cannot run *profile*, else ``None``."""
|
||||
# full-server registers the harness via an agent bundle (the SDK-wrap
|
||||
# path); a native harness needs the host-daemon/tmux provisioning only
|
||||
# the native-tui driver does, so it cannot run here even under an
|
||||
# explicit --transport full-server override.
|
||||
if profile.transport == "native-tui":
|
||||
return (
|
||||
f"{profile.harness!r} is a native-tui harness; the full-server transport "
|
||||
"registers via an agent bundle and cannot drive it (use --transport native-tui)"
|
||||
)
|
||||
if not databricks_profile:
|
||||
return "no --profile / databricks profile provided; full-server needs a gateway route"
|
||||
if lookup_databricks_host(databricks_profile) is None:
|
||||
return (
|
||||
f"databricks profile {databricks_profile!r} missing/hostless in ~/.databrickscfg"
|
||||
)
|
||||
# Reuse the wrap driver's CLI gate (same binary requirement).
|
||||
from tests.harness_bench.driver import SdkInprocDriver
|
||||
# Same CLI gate as the wrap driver (same binary requirement), but skip
|
||||
# its transport check — that is sdk-inproc-specific and would misreport
|
||||
# the driver name; the native case is already handled above.
|
||||
from tests.e2e._harness_probes import cli_unavailable_reason
|
||||
|
||||
return SdkInprocDriver.unavailable(profile, databricks_profile=databricks_profile)
|
||||
if profile.cli_binary is not None:
|
||||
return cli_unavailable_reason(profile.cli_binary)
|
||||
return None
|
||||
|
||||
def __enter__(self) -> FullServerDriver:
|
||||
self._tmp.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
|
||||
@@ -125,6 +125,17 @@ def test_infra_failure_reason_classifies_auth_and_ignores_capability_gaps() -> N
|
||||
# A successful turn is never an infra failure.
|
||||
assert infra_failure_reason(TurnResult(completed=True, text="ok")) is None
|
||||
|
||||
# Token-provisioning failures on full-server (codex/pi) are env/auth gaps,
|
||||
# not capability gaps -> must yield a skip reason, never a false UNSUPPORTED
|
||||
# that drifts against a SUPPORTED declaration.
|
||||
for msg in (
|
||||
"inner executor error: provider auth command `sh` produced an empty token",
|
||||
"PiExecutor(gateway=True) could not fetch a gateway token for the workspace host.",
|
||||
"Failed to resolve external API key auth",
|
||||
):
|
||||
result = TurnResult(failed=True, error={"message": msg})
|
||||
assert infra_failure_reason(result) is not None, msg
|
||||
|
||||
|
||||
async def test_offline_render_produces_matrix() -> None:
|
||||
matrix = await run_bench(_OFFICIAL, live=False)
|
||||
@@ -298,3 +309,26 @@ def test_native_tui_registered_and_gates() -> None:
|
||||
|
||||
# No profile → the same capability-neutral skip contract as other drivers.
|
||||
assert NativeTuiDriver.unavailable(claude_native, databricks_profile=None) is not None
|
||||
|
||||
|
||||
def test_full_server_skips_native_with_accurate_message() -> None:
|
||||
"""full-server rejects a native profile by naming the native transport.
|
||||
|
||||
A native harness forced onto full-server (via --transport) cannot run
|
||||
there (bundle registration, not host-daemon provisioning). The skip must
|
||||
name native-tui as the answer, not misreport the 'sdk-inproc' driver.
|
||||
"""
|
||||
from tests.harness_bench.full_server_driver import FullServerDriver
|
||||
|
||||
# Real native profiles carry transport="native-tui" (set in the manifest);
|
||||
# that is what the full-server gate keys on.
|
||||
claude_native = BenchProfile(
|
||||
harness="claude-native",
|
||||
model="m",
|
||||
env_prefix="HARNESS_CLAUDE_NATIVE_",
|
||||
marker="X",
|
||||
transport="native-tui",
|
||||
)
|
||||
reason = FullServerDriver.unavailable(claude_native, databricks_profile="oss")
|
||||
assert reason is not None
|
||||
assert "native-tui" in reason and "sdk-inproc" not in reason
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Unit tests for ``omnigent session export``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
from click.testing import CliRunner
|
||||
|
||||
from omnigent.cli import cli
|
||||
|
||||
_BASE = "http://localhost:6767"
|
||||
|
||||
_SESSION_META = {
|
||||
"id": "conv_abc123",
|
||||
"title": "test session",
|
||||
"status": "idle",
|
||||
"created_at": 1700000000,
|
||||
"updated_at": 1700000001,
|
||||
"agent_id": None,
|
||||
"agent_name": None,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
_ITEMS_PAGE = {
|
||||
"data": [
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"response_id": "resp_1",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}],
|
||||
},
|
||||
{
|
||||
"id": "msg_2",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"response_id": "resp_1",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "hi there"}],
|
||||
"model": "my-agent",
|
||||
},
|
||||
],
|
||||
"first_id": "msg_1",
|
||||
"last_id": "msg_2",
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
|
||||
def _patch_server(base_url: str = _BASE) -> Any:
|
||||
"""Patch the CLI so it uses *base_url* without spawning a real server."""
|
||||
return patch("omnigent.cli._resolve_attach_server", return_value=base_url)
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_session_export_writes_jsonl(tmp_path: Path) -> None:
|
||||
"""Export writes one session_meta line then one item line per item."""
|
||||
respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock(
|
||||
return_value=httpx.Response(200, json=_SESSION_META)
|
||||
)
|
||||
respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock(
|
||||
return_value=httpx.Response(200, json=_ITEMS_PAGE)
|
||||
)
|
||||
|
||||
out_file = tmp_path / "out.jsonl"
|
||||
runner = CliRunner()
|
||||
with _patch_server():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["session", "export", "--id", "conv_abc123", "--output", str(out_file)],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert out_file.exists()
|
||||
|
||||
lines = [json.loads(line) for line in out_file.read_text().splitlines() if line]
|
||||
assert len(lines) == 3 # 1 meta + 2 items
|
||||
|
||||
meta = lines[0]
|
||||
assert meta["record_type"] == "session_meta"
|
||||
assert meta["id"] == "conv_abc123"
|
||||
assert meta["title"] == "test session"
|
||||
|
||||
item_lines = lines[1:]
|
||||
assert all(r["record_type"] == "item" for r in item_lines)
|
||||
assert [r["role"] for r in item_lines] == ["user", "assistant"]
|
||||
assert item_lines[1]["content"] == [{"type": "output_text", "text": "hi there"}]
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_session_export_default_filename(tmp_path: Path) -> None:
|
||||
"""Without --output, the file is named <session_id>.jsonl in cwd."""
|
||||
respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock(
|
||||
return_value=httpx.Response(200, json=_SESSION_META)
|
||||
)
|
||||
respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock(
|
||||
return_value=httpx.Response(200, json={**_ITEMS_PAGE, "data": [], "has_more": False})
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem(temp_dir=tmp_path), _patch_server():
|
||||
result = runner.invoke(cli, ["session", "export", "--id", "conv_abc123"])
|
||||
assert result.exit_code == 0, result.output
|
||||
default_path = Path("conv_abc123.jsonl")
|
||||
assert default_path.exists()
|
||||
lines = [json.loads(line) for line in default_path.read_text().splitlines() if line]
|
||||
|
||||
assert len(lines) == 1
|
||||
assert lines[0]["record_type"] == "session_meta"
|
||||
assert lines[0]["id"] == "conv_abc123"
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_session_export_missing_session_errors(tmp_path: Path) -> None:
|
||||
"""Export of an unknown session id exits non-zero with a clear message."""
|
||||
respx.get(f"{_BASE}/v1/sessions/conv_doesnotexist").mock(
|
||||
return_value=httpx.Response(404, json={"error": "not found"})
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
with _patch_server():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"session",
|
||||
"export",
|
||||
"--id",
|
||||
"conv_doesnotexist",
|
||||
"--output",
|
||||
str(tmp_path / "out.jsonl"),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "conv_doesnotexist" in result.output
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_session_export_items_ordered_ascending(tmp_path: Path) -> None:
|
||||
"""Items in the JSONL appear in ascending position order (user then assistant)."""
|
||||
respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock(
|
||||
return_value=httpx.Response(200, json=_SESSION_META)
|
||||
)
|
||||
respx.get(f"{_BASE}/v1/sessions/conv_abc123/items").mock(
|
||||
return_value=httpx.Response(200, json=_ITEMS_PAGE)
|
||||
)
|
||||
|
||||
out_file = tmp_path / "ordered.jsonl"
|
||||
runner = CliRunner()
|
||||
with _patch_server():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["session", "export", "--id", "conv_abc123", "--output", str(out_file)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
records = [json.loads(line) for line in out_file.read_text().splitlines() if line]
|
||||
item_records = [r for r in records if r["record_type"] == "item"]
|
||||
assert len(item_records) == 2
|
||||
assert item_records[0]["role"] == "user"
|
||||
assert item_records[1]["role"] == "assistant"
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_session_export_pagination(tmp_path: Path) -> None:
|
||||
"""Export follows has_more cursors to fetch all pages."""
|
||||
page1 = {
|
||||
"data": [
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"response_id": "r1",
|
||||
"role": "user",
|
||||
"content": [],
|
||||
}
|
||||
],
|
||||
"first_id": "msg_1",
|
||||
"last_id": "msg_1",
|
||||
"has_more": True,
|
||||
}
|
||||
page2 = {
|
||||
"data": [
|
||||
{
|
||||
"id": "msg_2",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"response_id": "r1",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"model": "ag",
|
||||
}
|
||||
],
|
||||
"first_id": "msg_2",
|
||||
"last_id": "msg_2",
|
||||
"has_more": False,
|
||||
}
|
||||
respx.get(f"{_BASE}/v1/sessions/conv_abc123").mock(
|
||||
return_value=httpx.Response(200, json=_SESSION_META)
|
||||
)
|
||||
# First call (no after param) → page1; second call (after=msg_1) → page2.
|
||||
items_route = respx.get(f"{_BASE}/v1/sessions/conv_abc123/items")
|
||||
items_route.side_effect = [
|
||||
httpx.Response(200, json=page1),
|
||||
httpx.Response(200, json=page2),
|
||||
]
|
||||
|
||||
out_file = tmp_path / "paged.jsonl"
|
||||
runner = CliRunner()
|
||||
with _patch_server():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["session", "export", "--id", "conv_abc123", "--output", str(out_file)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
records = [json.loads(line) for line in out_file.read_text().splitlines() if line]
|
||||
item_records = [r for r in records if r["record_type"] == "item"]
|
||||
assert len(item_records) == 2
|
||||
assert [r["id"] for r in item_records] == ["msg_1", "msg_2"]
|
||||
@@ -4852,6 +4852,55 @@ def test_claude_prompt_rendered_sees_prompt_above_default_footer() -> None:
|
||||
assert _claude_prompt_rendered(pane) is True
|
||||
|
||||
|
||||
def test_claude_prompt_rendered_sees_prompt_above_running_turn_footer() -> None:
|
||||
"""
|
||||
The readiness scan reaches the prompt above a tall running-turn footer.
|
||||
|
||||
When a web-UI message is injected while Claude is mid-turn, the footer
|
||||
grows extra status rows below the input box — a running-subagent line
|
||||
(``○ Explore …``) on top of the usual box rule, model, auto-mode, and
|
||||
branch rows. That pushes the live ``❯`` row to the 6th non-empty line
|
||||
from the bottom, one past the old 5-line window, so the readiness gate
|
||||
timed out and the web UI rendered a spurious "did not become ready"
|
||||
runtime-error card even though the terminal was healthy.
|
||||
"""
|
||||
pane = "\n".join(
|
||||
[
|
||||
"────────────────────────────────────────", # input box top rule
|
||||
"❯ ", # the live prompt row (6th non-empty line from bottom)
|
||||
"────────────────────────────────────────", # box closing rule
|
||||
" Opus 4.8 (1M context) | thinking medium", # model + effort line
|
||||
" ⏵⏵ auto mode on (shift+tab to cycle)", # permission-mode hint
|
||||
" main", # branch label
|
||||
" ○ Explore Find session sidebar state… 1m 4s", # subagent status
|
||||
]
|
||||
)
|
||||
assert _claude_prompt_rendered(pane) is True
|
||||
|
||||
|
||||
def test_claude_prompt_rendered_ignores_unframed_glyph_deep_in_tail() -> None:
|
||||
"""
|
||||
A glyph in the wider window without a box rule below is not trusted.
|
||||
|
||||
The framed window that lets the scan reach a prompt under a tall
|
||||
running-turn footer must not resurrect the scrollback false positive:
|
||||
a ``❯`` echoed into prior output sits in the wider window too, but
|
||||
without the input box's closing ``────`` rule beneath it. Only plain
|
||||
output follows here, so the gate must still report "not ready".
|
||||
"""
|
||||
pane = "\n".join(
|
||||
[
|
||||
"❯ old prompt echo", # 6th non-empty line from bottom, no rule below
|
||||
"output line 1",
|
||||
"output line 2",
|
||||
"output line 3",
|
||||
"output line 4",
|
||||
"output line 5",
|
||||
]
|
||||
)
|
||||
assert _claude_prompt_rendered(pane) is False
|
||||
|
||||
|
||||
def _write_deltas_lines(bridge_dir: Path, lines: list[str]) -> None:
|
||||
"""
|
||||
Append raw JSONL lines to the bridge deltas file.
|
||||
|
||||
@@ -15,10 +15,18 @@ import type { AccountListEntry } from "@/lib/accountsApi";
|
||||
import * as accountsApi from "@/lib/accountsApi";
|
||||
import * as identity from "@/lib/identity";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ accountsEnabled: true }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accountsEnabled: true,
|
||||
loginUrl: null as string | null,
|
||||
serverVersion: "0.3.0.dev0" as string | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/CapabilitiesContext", () => ({
|
||||
useServerInfo: () => ({ accounts_enabled: mocks.accountsEnabled }),
|
||||
useServerInfo: () => ({
|
||||
accounts_enabled: mocks.accountsEnabled,
|
||||
login_url: mocks.loginUrl,
|
||||
server_version: mocks.serverVersion,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/identity", () => ({
|
||||
resolveIdentity: vi.fn(),
|
||||
@@ -52,6 +60,8 @@ function renderPage() {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.accountsEnabled = true;
|
||||
mocks.loginUrl = null;
|
||||
mocks.serverVersion = "0.3.0.dev0";
|
||||
vi.mocked(identity.resolveIdentity).mockResolvedValue("admin");
|
||||
vi.mocked(identity.getCurrentIsAdmin).mockReturnValue(true);
|
||||
vi.mocked(accountsApi.listUsers).mockResolvedValue([]);
|
||||
@@ -183,12 +193,32 @@ describe("MembersPage actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MembersPage in plain header/single-user mode", () => {
|
||||
beforeEach(() => {
|
||||
// Single-user mode: no accounts, no IdP (login_url is null). The
|
||||
// /auth/users endpoint does not exist, so the page must skip the fetch
|
||||
// and show a "not available" message instead.
|
||||
mocks.accountsEnabled = false;
|
||||
mocks.loginUrl = null;
|
||||
mocks.serverVersion = "0.3.0.dev0";
|
||||
});
|
||||
|
||||
it("shows a not-available message and never calls listUsers", async () => {
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText("Member management is not available in single-user mode."),
|
||||
).toBeInTheDocument();
|
||||
expect(accountsApi.listUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MembersPage under OIDC (read-only)", () => {
|
||||
beforeEach(() => {
|
||||
// OIDC: accounts disabled → no password-based management. The list still
|
||||
// renders (admins can see who's provisioned), but every management
|
||||
// affordance is gone.
|
||||
// OIDC: accounts disabled but login_url is non-null (IdP present).
|
||||
// The list still renders (admins can see who's provisioned), but every
|
||||
// management affordance is gone.
|
||||
mocks.accountsEnabled = false;
|
||||
mocks.loginUrl = "/auth/login";
|
||||
});
|
||||
|
||||
it("lists users but offers no management actions", async () => {
|
||||
|
||||
@@ -55,6 +55,14 @@ export function MembersPage() {
|
||||
// accounts mode — OIDC identities are owned by the IdP, so under OIDC
|
||||
// this page is a read-only user list (no action column, no modals).
|
||||
const manageable = info !== "loading" && info.accounts_enabled;
|
||||
// Plain header/single-user mode: no auth endpoints exist. server_version
|
||||
// distinguishes a live single-user server from a failed /v1/info probe
|
||||
// (which uses the same accounts_enabled:false / login_url:null sentinel).
|
||||
const isSingleUser =
|
||||
info !== "loading" &&
|
||||
!info.accounts_enabled &&
|
||||
info.login_url === null &&
|
||||
info.server_version !== null;
|
||||
const [meIsAdmin, setMeIsAdmin] = useState<boolean | null>(null);
|
||||
const [meId, setMeId] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<AccountListEntry[] | null>(null);
|
||||
@@ -82,12 +90,11 @@ export function MembersPage() {
|
||||
setUsers(list);
|
||||
}, []);
|
||||
|
||||
// Initial load: identity probe + members list. The identity probe
|
||||
// gates the UI (non-admins see "no access"); the list is what we
|
||||
// render the table from. Uses the mode-agnostic `/v1/me` identity
|
||||
// (via resolveIdentity) rather than the accounts-only `/auth/me`, so
|
||||
// the page also works under OIDC where `/auth/me` doesn't exist.
|
||||
// Initial load: identity probe + members list. Skipped in single-user
|
||||
// mode since no auth endpoints exist. isSingleUser is a stable boolean
|
||||
// so it is safe as a dep without risking infinite re-renders.
|
||||
useEffect(() => {
|
||||
if (isSingleUser) return;
|
||||
void (async () => {
|
||||
const userId = await resolveIdentity();
|
||||
if (userId === null) {
|
||||
@@ -101,10 +108,23 @@ export function MembersPage() {
|
||||
setMeIsAdmin(isAdmin);
|
||||
if (isAdmin) await refresh();
|
||||
})();
|
||||
}, [refresh]);
|
||||
}, [refresh, isSingleUser]);
|
||||
|
||||
if (isSingleUser) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-6 py-12">
|
||||
<h1 className="mb-2 text-2xl font-semibold">Members</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Member management is not available in single-user mode.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-admin-check render: blank loading state. min-h-full so the
|
||||
// AppShell's outlet container governs height — we're a child view,
|
||||
// not a full-page replacement. min-h-full so the
|
||||
// AppShell's outlet container governs height — we're a child view,
|
||||
// not a full-page replacement.
|
||||
if (meIsAdmin === null) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user