Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df413e8e41 | ||
|
|
c8b12cc496 | ||
|
|
ec2ee953c8 | ||
|
|
e39ea6ac36 | ||
|
|
885332a8ed | ||
|
|
914910c006 | ||
|
|
48d85e169c | ||
|
|
f869fa407b | ||
|
|
71fd16a36b | ||
|
|
06302b2472 | ||
|
|
704b4488e6 | ||
|
|
6aa271c2d4 | ||
|
|
d054ecb1c6 | ||
|
|
1bbd48fbe5 | ||
|
|
7a2a499ce7 | ||
|
|
3ea85744e9 | ||
|
|
00d55b4383 | ||
|
|
0d0a76c1c5 | ||
|
|
fa7304af55 | ||
|
|
99c8d5850b | ||
|
|
2ad9c65721 | ||
|
|
da8a342886 | ||
|
|
aeebfee796 | ||
|
|
0498b00c02 | ||
|
|
2cc88f7f94 | ||
|
|
6a91563f42 | ||
|
|
809181943c | ||
|
|
4d7144eb61 | ||
|
|
c3439acc3c | ||
|
|
c05f18e293 | ||
|
|
737a4ad804 | ||
|
|
5b8b07a506 | ||
|
|
f884a7cd91 | ||
|
|
d471767325 | ||
|
|
0581d909f3 | ||
|
|
89617a62ba | ||
|
|
f81385fce9 | ||
|
|
a48037e091 | ||
|
|
d31359f3a5 |
No files matched your search
@@ -1,9 +1,9 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.338.0**
|
||||
**Current release: v0.346.0**
|
||||
|
||||
<!-- highlights:start -->
|
||||
## Highlights — from the latest release (v0.338.0)
|
||||
## Highlights — from the latest release (v0.346.0)
|
||||
|
||||
- Plain-English jargon dictionary covers the v0.329 vocabulary, plus catch-all sentence simplification pass.
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Spec: Pinned Work in the Tasks Pane (D50 residual 4, #2560)
|
||||
|
||||
Status: landed (wave D50-residuals). Supersedes the D50 draft with the
|
||||
owner's D51 upgrades.
|
||||
|
||||
## The surface
|
||||
|
||||
A reserved PINNED WORK area at the TOP of the tasks pane's region
|
||||
view — the pane's first content, above the scaffolding status line,
|
||||
the work stream, and the queue. It accumulates pinned work native to
|
||||
Rune (pin action on work blocks and goals).
|
||||
|
||||
- Header: the pin icon + `pinned (N) — outstanding work` label.
|
||||
- Each pin renders ONE line: `◆ <plain-english summary>` — every pin
|
||||
carries a plain-english summary of what is pinned (D51 upgrade).
|
||||
- Click on the pinned area opens the OUTSTANDING PINNED WORK view
|
||||
(D51 upgrade); click again closes it. In the open view each pin
|
||||
names its id and the controls; clicking a pin (or `x`, which
|
||||
releases the newest) releases it; `esc` backs out to the pane.
|
||||
- No pins, no area: the tasks pane renders blank otherwise (D51
|
||||
upgrade), with NO selectable rows anywhere (D51 — the D50 residual 3
|
||||
removal already took the row walker out).
|
||||
|
||||
## Keyboard parity (the pin verb)
|
||||
|
||||
`ctrl+b` pins the top live work block (the newest running work
|
||||
stream block) into the pinned area. Chosen for the control-key idiom
|
||||
family — `ctrl+q` steers the queue, `ctrl+g` raises the gauges,
|
||||
`ctrl+p` is the palette, `ctrl+a` the activity window — control keys
|
||||
own the chrome verbs; plain letters always type (the D36 letter law).
|
||||
`ctrl+b` is reachable while composing.
|
||||
|
||||
## Persistence
|
||||
|
||||
Pins persist across boots through the hot-state store pattern (the
|
||||
task_state/history-depth pattern): one JSON document at
|
||||
`$RUNE_HOME/tasks_pins.json` (beside `history_depth.json`), written
|
||||
atomically (write-beside, rename over) on every pin change; absent or
|
||||
corrupt records answer an empty pin set, never a guess. The layer in
|
||||
`tui/tasks/pinned.go` stays pure view-model; the shell owns the
|
||||
record.
|
||||
|
||||
## Tests
|
||||
|
||||
`tui/tasks/pinned_test.go` pins the area's placement (first content),
|
||||
the no-pins-no-area law, the view open/release cycle with keyboard
|
||||
parity, and clone-carry. `tui/cmd/rune-tui/pins_test.go` pins the
|
||||
ctrl+b verb and the boot leg (a fresh model loads the pins and renders
|
||||
the area).
|
||||
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.338.0"
|
||||
version = "0.346.0"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# D53 wave report — pane polish + the long-answer format break (Gitea #2568)
|
||||
|
||||
Admission: `scripts/admit_wave_d53_pane_polish.py` (classified
|
||||
EXISTING_PROJECT_CHANGE, activated, contract
|
||||
ADMIT-wave-d53-pane-polish minted — note: the classify core treats
|
||||
"listing" as a reading verb; the objective says "showing").
|
||||
|
||||
## Per-item outcome
|
||||
|
||||
1. **LONG-ANSWER FORMAT BREAK** — reproduced: a 24-block mixed answer
|
||||
at width 24 rendered a table edge row 30 columns wide. Root cause:
|
||||
`tableLines` (tui/chatpane/blocks.go) built edge rows wider than
|
||||
body rows by the separator columns, and the width budget ignored
|
||||
them. Fixed: rows and edges share one geometry, honest
|
||||
too-narrow drop (`total < cols*3` → nil). Summary-first asserted
|
||||
at any length; append-never-reflows asserted on the long answer.
|
||||
Commit 00d55b43 (rode to gitea/master with #2556's push).
|
||||
2. **SCROLLBAR** — root cause found: `overlayDisplayCol`
|
||||
(tui/lanes/render.go) wrote the thumb glyph into the left half
|
||||
ONLY when a content rune sat at column x — a short styled row (the
|
||||
common transcript shape) lost the glyph entirely, so no bar ever
|
||||
rendered. Fixed: the glyph lands at the column even past the
|
||||
line's end; thumb styled with a new `RoleScroll` token (vivid
|
||||
#B87C99 / calm #C790AC, floors ≥4.5:1 asserted); chat AND tasks
|
||||
rows render styled-or-empty; wheel step math pinned (one notch =
|
||||
exactly its 3-line step, exact accumulation). Commit 3ea85744.
|
||||
3. **QUIT WARNING** — first ctrl+c swaps the composer key hint for
|
||||
the literal `press ctrl+c again to quit`; any other key or the 1s
|
||||
timeout restores the live key hint. Found + fixed a D46 latent
|
||||
defect: the arm-reset check was unreachable dead code inside the
|
||||
KeyPressMsg case — the reset now has its own top-level switch
|
||||
case. The second press quits, never shows the warning. Face chain
|
||||
gained a `Hint()` read-back seam. Commit 7a2a499c.
|
||||
4. **DYNAMIC REFLOW** — squash test frozen: a WindowSizeMsg through
|
||||
the face chain re-wraps every block at 44 and 100 columns, no line
|
||||
exceeds the pane, the scroll offset normalizes after the line-count
|
||||
change. The render-path rewrap already held; the test pins it.
|
||||
Commit 6aa271c2.
|
||||
5. **INFO BAR** — `context 4.2k/32k (13%)` via `ctxPercent`
|
||||
(clamped 0-100, still silent when the engine never reported).
|
||||
Commit 704b4488.
|
||||
6. **COMPOSER STYLING** — key hints render in the new `RoleHintPink`
|
||||
token (vivid #C9739D / calm #D083AC); the activity pane's idle
|
||||
state renders in the new `RoleStateIdle` yellow token (vivid
|
||||
#D9B45B / calm #C9B06E) — the D52 frozen idle test superseded with
|
||||
the ruling recorded. Commits 06302b24 + 71fd16a3.
|
||||
7. **SUB-AGENTS** — the activity pane's "sub agents being processed
|
||||
now" section renders ONLY while the syncWaves flight observation
|
||||
reports agents in flight (one poller — the section can never
|
||||
disagree with the rail marker or the wave tab); clickable
|
||||
(mouse.activity.agents region) with ctrl+w keyboard parity,
|
||||
opening the CENTERED agents window (the D52 picker pattern)
|
||||
listing all in-flight agents; the selected row's thought process /
|
||||
recent activity (the factory journal + observations detail
|
||||
`waveRows` already derives) renders beneath; the window is modal
|
||||
(esc/up/down, any other key closes). Plain-value `AgentView` seam
|
||||
keeps lanes un-importable from tasks. Commit f869fa40.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go test -race -short ./...` green (tui module; the documented
|
||||
pre-existing boot-smoke PTY family aside did not fire this run).
|
||||
- gofmt clean; `go vet ./...` clean.
|
||||
- PTY proof: the deployed binary boots to CHAT/TASKS panes with the
|
||||
live "enter send" hint (bubbletea coords 0-based-normalized per the
|
||||
D52 finding; click tests drive the region rects directly).
|
||||
- Rebased onto gitea/master d054ecb1 (#2556 streaming wave landed
|
||||
mid-flight); items 1-3 rode up with that push; items 4-7 pushed as
|
||||
48d85e16.
|
||||
- Deploy: `scripts/build_paired.sh` attested
|
||||
`48d85e16...+20261024T032845Z`; binary installed to
|
||||
`~/.local/share/rune/bin/rune-tui`; `ensure_core` reports real and
|
||||
healthy (identity 'Rune', 13 models).
|
||||
|
||||
## Concurrency note
|
||||
|
||||
A streaming wave (#2556) edited tui/ concurrently mid-wave. One
|
||||
commit initially swept its WIP; it was split back out (the commit was
|
||||
mine, unpushed) and item 3 was re-landed from HEAD blobs. The sweep
|
||||
race guard caught nothing (staged-tree check passed) — the guard
|
||||
checks contract coverage, not authorship. Consider a per-hunk
|
||||
authorship check for shared surfaces.
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Admission dispatch for wave D49 — capability everywhere (owner doctrine).
|
||||
|
||||
The owner's D49 ruling (verbatim, 2026-10-22): "ive asked rune if she
|
||||
can alter her code she said she needs shell access, and the ability to
|
||||
read and write i want that ability to be in every pane, but when
|
||||
something gets to become work it gets promoted into the task pane. she
|
||||
also needs tools to be able to connect to git projects. and make that
|
||||
native." Doctrine: capability is INHERENT in every pane — the write
|
||||
family (write_file, edit_file, run_command) and the new native git
|
||||
family ride the wire in CHAT/PLAN/TASK alike; the consent card remains
|
||||
the work-promotion mechanism (organization, not permission); the real
|
||||
safety layer is the scaffolding authority + contract gates at mutation
|
||||
doors. Build: (1) ToolsetFor exposes the full suite in every mode; (2)
|
||||
ExecuteGatedTool's write-refusal-by-mode retires, the dispatch-gate
|
||||
seam and the genuinely-unavailable refusal stay; (3) the wire tool
|
||||
descriptions state the write availability (the model asked for shell
|
||||
access because the wire showed read-only — fix the wire); (4) a native
|
||||
git family (git_status, git_diff, git_log, git_branch, git_commit,
|
||||
git_add) shelling out to git in the repo root (DefaultRepoRoot seam),
|
||||
porcelain-parsed honestly, typed JSON results, landing-law banned-path
|
||||
refusals on git_add/git_commit; (5) the mode/toolset tests re-pinned
|
||||
to CHAT=full-suite, red-first throughout.
|
||||
approval_ref 'user:D49 owner ruling 2026-10-22 (capability in every
|
||||
pane; native git tools)'.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"Build the owner's D49 doctrine — capability in every pane: (1) "
|
||||
"make ToolsetFor expose the full tool suite (read family + write "
|
||||
"family) in every mode so CHAT and PLAN carry write_file, "
|
||||
"edit_file and run_command like TASK does; (2) retire "
|
||||
"ExecuteGatedTool's write-refusal-by-mode so writes execute in any "
|
||||
"mode while keeping the dispatch-gate seam and the typed refusal "
|
||||
"for genuinely-unavailable tools; (3) state the write availability "
|
||||
"in the tool descriptions the wire carries so the model knows its "
|
||||
"shell and file abilities without asking; (4) add a native git "
|
||||
"tool family — git_status, git_diff, git_log, git_branch, "
|
||||
"git_commit, git_add — implemented by shelling out to git in the "
|
||||
"repo root through the DefaultRepoRoot seam, parsing porcelain "
|
||||
"output honestly, returning typed JSON, and refusing banned "
|
||||
"landing-law paths on git_add and git_commit; (5) re-pin the "
|
||||
"mode/toolset tests to the new doctrine (CHAT carries the full "
|
||||
"suite) red-first and keep the runecore package -race green"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/go/rune-core/",
|
||||
"scripts/admit_wave_d49_capability_everywhere.py",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-d49-capability-everywhere",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-d49-capability-everywhere",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"owner ruling D49 2026-10-22 (verbatim in session notes): "
|
||||
"capability in every pane, work promotes to the task pane, "
|
||||
"native git tools",
|
||||
"src/go/rune-core/internal/runecore/tools.go (the "
|
||||
"mode-gated toolset this wave supersedes)",
|
||||
"commit b35078bb (task channel inherits TASK semantics)",
|
||||
"scripts/check_landing.py rule 4 SWEEP_RACE_BANNED_PATHS "
|
||||
"(the landing-law banned-path set git_add/git_commit "
|
||||
"refuse)",
|
||||
"scripts/admit_wave_presentation.py (the admission "
|
||||
"pattern)",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-d49-capability-everywhere")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-d49-capability-everywhere",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-d49-capability-everywhere",
|
||||
expected_new_entities=[
|
||||
"src/go/rune-core/internal/runecore (ToolsetFor full "
|
||||
"suite in every mode; ExecuteGatedTool write-refusal "
|
||||
"retired with the dispatch-gate seam kept; wire tool "
|
||||
"descriptions state write availability)",
|
||||
"src/go/rune-core/internal/runecore/gittools.go (the "
|
||||
"native git family: git_status, git_diff, git_log, "
|
||||
"git_branch, git_commit, git_add — stdlib git subprocess "
|
||||
"in the repo root, porcelain parsed honestly, typed JSON "
|
||||
"results, landing-law banned-path refusals)",
|
||||
"tests: red-first re-pins of the mode/toolset suites to "
|
||||
"CHAT=full-suite + per-tool git fixture tests, -race "
|
||||
"green",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"every pane's model sees and can execute the write family "
|
||||
"and the git family; work-shaped activity still promotes "
|
||||
"to the tasks pane via the consent card (organization, "
|
||||
"not permission)",
|
||||
"the model can connect to git projects natively: status, "
|
||||
"diff, log, branch, add and commit as first-class typed "
|
||||
"tool results",
|
||||
"the landing-law banned paths never enter a git tool "
|
||||
"staged tree or commit",
|
||||
],
|
||||
verification_obligations=[
|
||||
"go test -race green for the whole runecore package "
|
||||
"(including the cloud wires); go vet + gofmt clean",
|
||||
"red-first evidence: the re-pinned suites fail against "
|
||||
"the pre-wave toolset before the implementation lands",
|
||||
"commit(s) name this contract; pre-commit staged gate "
|
||||
"green via this open contract",
|
||||
],
|
||||
approval_ref="user:D49 owner ruling 2026-10-22 (capability in "
|
||||
"every pane; native git tools)",
|
||||
contract_id="ADMIT-wave-d49-capability-everywhere",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Admission dispatch for wave D50-residuals (land the four items the
|
||||
presentation wave left out, Gitea #2560).
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law): the
|
||||
D50-residuals build on the existing rune project, user-dispatch approved.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"land the D50-residual wave (Gitea #2560, D51 round folded): (1) "
|
||||
"msg_id linkage — tasks-pane work-stream records carry the "
|
||||
"spawning chat msg_id (msg ids exist core-side per newMsgID), "
|
||||
"rendered as a dim ↩ reference in the work block; (2) phantom-row "
|
||||
"filter — filter stale queue rows by the freshness law (the "
|
||||
"30-min occupancy pattern) in the tasks pane, read-side in the "
|
||||
"TUI; (3) enter-steal removal — remove the tasks-pane "
|
||||
"row-selection walker entirely; enter on tasks-pane focus starts "
|
||||
"direct task execution (D44 doctrine); (4) pinned work — reserved "
|
||||
"pinned area at the top of the tasks pane with a pin icon and "
|
||||
"'pinned' label, each pin carrying a plain-english summary, click "
|
||||
"opens the outstanding pinned work view, pins persist across "
|
||||
"boots via the hot-state store pattern; the tasks pane renders "
|
||||
"blank otherwise with no selectable rows; (5) dynamic reflow — "
|
||||
"bubbles/boxes re-wrap on window resize (nothing clips)"
|
||||
)
|
||||
AFFECTED = [
|
||||
"tui/",
|
||||
"src/go/rune-core/internal/runecore/record.go",
|
||||
"src/go/rune-core/internal/runecore/store.go",
|
||||
"scripts/admit_wave_d50_residuals.py",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-d50-residuals",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-d50-residuals",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"store/scaffold/docs/ADMIT-wave-d46-live-defects.md",
|
||||
"tui/lanes/activity.go",
|
||||
"tui/chatpane/blocks.go",
|
||||
"tui/tasks/render.go",
|
||||
"tui/cmd/rune-tui/selection.go",
|
||||
"scripts/admit_wave_rebase_tui_wiring.py",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-d50-residuals")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-d50-residuals",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-d50-residuals",
|
||||
expected_new_entities=[
|
||||
"the D50-residuals wave report (reports/)",
|
||||
"the pinned-work hot-state store + tests",
|
||||
"red-first enter-dispatch and phantom-filter tests",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"tasks-pane work blocks show a dim ↩ msg_id reference; "
|
||||
"phantom queue rows are filtered; tasks-focus enter "
|
||||
"dispatches directly; pinned work persists across boots",
|
||||
],
|
||||
verification_obligations=[
|
||||
"go test -race ./... in tui/ green",
|
||||
"build/vet/gofmt clean",
|
||||
"PTY proofs for enter-dispatch and pin persistence",
|
||||
],
|
||||
approval_ref="user:D50-residual directive 2026-10-22",
|
||||
contract_id="ADMIT-wave-d50-residuals",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Admission dispatch for wave D52 (Gitea #2566 + #2567 — the
|
||||
seed-substrate leak, the wave animation, the dead + click, and the
|
||||
model-token picker window).
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law): the
|
||||
D52 wave builds on the existing rune project, user-dispatch approved.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"land the D52 wave (Gitea #2566 + #2567): (1) seed-substrate leak "
|
||||
"— production TUI faces boot EMPTY: tasks.New/NewPromote start "
|
||||
"with no lab tree/suggestion fixtures and an honest idle render "
|
||||
"('no outstanding work'); SetLiveTree/Offer fill them when real "
|
||||
"data arrives; the seeds stay confined to the lab harness; (2) "
|
||||
"wave animation — the activity word animates per-letter as a WAVE "
|
||||
"(rise, settle, sequential to the last letter, then reverse, "
|
||||
"looping), smooth multi-frame rise, vibrant colours + rainbow "
|
||||
"sweep on the moving letter, per-activity palette kept; (3) the "
|
||||
"+ affordance click fix — the styled + padded by byte length so "
|
||||
"the glyph rendered left of its declared region; region and "
|
||||
"render share one width constant, plus a debuggable region→glyph "
|
||||
"assertion; (4) the model-token click opens the centered "
|
||||
"searchable picker window split CLOUD/LOCAL, no crossfire with "
|
||||
"the activity window; ctrl+m parity kept"
|
||||
)
|
||||
AFFECTED = [
|
||||
"tui/",
|
||||
"scripts/admit_wave_d52_seedleak_animation.py",
|
||||
]
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-d52-seedleak-animation",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-d52-seedleak-animation",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"tui/tasks/tasks.go",
|
||||
"tui/tasks/face.go",
|
||||
"tui/promote/promote.go",
|
||||
"tui/lanes/activity.go",
|
||||
"tui/lanes/render.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"scripts/admit_wave_d50_residuals.py",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-d52-seedleak-animation")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-d52-seedleak-animation",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-d52-seedleak-animation",
|
||||
expected_new_entities=[
|
||||
"the D52 wave report (reports/)",
|
||||
"the red-first seed-leak/wave/click-map/picker-window tests",
|
||||
"the honest-empty boot chain (semantic.Empty → lanes/rail/"
|
||||
"promote/tasks empty constructors)",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"production faces boot empty ('no outstanding work'), the "
|
||||
"activity word waves per-letter with reverse loop and "
|
||||
"vibrant mover, the + click lands on the rendered glyph, "
|
||||
"the model-token click opens the CLOUD/LOCAL picker window",
|
||||
],
|
||||
verification_obligations=[
|
||||
"go test -race ./... in tui/ green (documented pre-existing "
|
||||
"boot-smoke PTY family aside)",
|
||||
"build/vet/gofmt clean",
|
||||
"PTY proofs: empty boot, + glyph click opens the window, "
|
||||
"model-token click opens the picker (no crossfire)",
|
||||
],
|
||||
approval_ref="user:D52 directive 2026-10-23 (Gitea #2566, #2567)",
|
||||
contract_id="ADMIT-wave-d52-seedleak-animation",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Admission dispatch for wave D54 neurodivergent-presentation.
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law,
|
||||
scripts/admit_wave_rebase_tui_wiring.py pattern): the D54
|
||||
neurodivergent-presentation build on the existing rune project,
|
||||
user-dispatch approved.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"implement the neurodivergent-presentation mechanical map "
|
||||
"(research/factory-nuerodivergent-presentation/synthesis.md, "
|
||||
"Gitea #2571): the motion law (motionAllowed gate on the activity "
|
||||
"wave, never-animate law on the chat pane, one-animating-region-"
|
||||
"per-frame in the face chain), the colour-token law (role enum in "
|
||||
"tui/themes, one hex per role, contrast >=4.5:1 unit test, Faint-"
|
||||
"on-dim fix), the spacing law (one blank line between blocks, "
|
||||
"blocks never merge), summary-first rendering (one-line plain-"
|
||||
"english summary box for long answers), the calm-mode sensory "
|
||||
"setting (vivid|calm, default vivid — calm mutes chroma at the "
|
||||
"token layer and sets motionAllowed false), and the literal-"
|
||||
"language audit of face strings"
|
||||
)
|
||||
AFFECTED = [
|
||||
"tui/lanes/activity.go",
|
||||
"tui/chatpane/blocks.go",
|
||||
"tui/chatpane/chatpane.go",
|
||||
"tui/chatpane/scroll_test.go",
|
||||
"tui/settings/config.go",
|
||||
"tui/settings/settings.go",
|
||||
"tui/themes/theme.go",
|
||||
"tui/themes/roles.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"scripts/admit_wave_d54_nd_presentation.py",
|
||||
]
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-d54-nd-presentation",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-d54-nd-presentation",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"research/factory-nuerodivergent-presentation/synthesis.md",
|
||||
"tui/lanes/activity.go",
|
||||
"tui/chatpane/blocks.go",
|
||||
"tui/chatpane/chatpane.go",
|
||||
"tui/settings/config.go",
|
||||
"tui/themes/theme.go",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-d54-nd-presentation")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-d54-nd-presentation",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-d54-nd-presentation",
|
||||
expected_new_entities=[
|
||||
"the colour-role enum in tui/themes (one hex per role)",
|
||||
"the sensory (vivid|calm) setting key in the settings store",
|
||||
"the motion-allowed gate on the activity wave",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"calm mode mutes chroma tokens and stops the activity wave; "
|
||||
"long answers open with a one-line plain-english summary; "
|
||||
"face strings read literally",
|
||||
],
|
||||
verification_obligations=[
|
||||
"go test -race -short ./... in tui/ green (documented "
|
||||
"pre-existing PTY boot family aside)",
|
||||
"go vet + gofmt clean in tui/",
|
||||
"contrast unit test >=4.5:1 body text, dim tier floor "
|
||||
"verified (Faint-on-dim breach fixed)",
|
||||
],
|
||||
approval_ref="user:D54 dispatch 2026-10-23 (Gitea #2571)",
|
||||
contract_id="ADMIT-wave-d54-nd-presentation",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Admission dispatch for wave retire-reconcile-tools (Gitea #2574
|
||||
rebalanced overnight queue, wave A; retirement-map blocker closure from
|
||||
wave 181's model-routing-integrated.json blocks_full_switchover note).
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law,
|
||||
scripts/admit_wave_memory_continuity_go.py pattern): port the
|
||||
capability-floor drift reconciliation (reconcile_tools_support,
|
||||
src/rune/model_routing.py — the R2 I-R2-022 truth_drift seam whose
|
||||
missing Go surface blocked local_tool_runtime.py from dropping its
|
||||
Python import) into the existing Go engine src/go/model_routing (new
|
||||
ReconcileToolsSupport + `eval reconcile` surface), keep the Python body
|
||||
as oracle/escape hatch (RUNE_MODEL_ROUTING_ENGINE=python, NO Python
|
||||
deletion — PORT → INTEGRATE → REMOVE), switch the function to a
|
||||
fail-loud delegation facade (typed RuntimeError when the engine binary
|
||||
is missing), and pin the port with a red-first parity battery
|
||||
(tests/test_reconcile_tools_support_port.py) against the Python oracle.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"Close the wave-181 full-switchover blocker: Go port of the "
|
||||
"capability-floor drift reconciliation (reconcile_tools_support, "
|
||||
"R2 I-R2-022 truth_drift) absorbed into the existing "
|
||||
"src/go/model_routing engine (ReconcileToolsSupport + `eval "
|
||||
"reconcile`); a provider tool-support claim contradicting "
|
||||
"CAPABILITY_FLOOR surfaces as unverified drift, never silently "
|
||||
"flipping eligibility; non-floor models are never invented a floor "
|
||||
"verdict. Python body stays as oracle/escape hatch "
|
||||
"(RUNE_MODEL_ROUTING_ENGINE=python) — no Python deletion; the "
|
||||
"function becomes a fail-loud delegation facade (typed RuntimeError "
|
||||
"when the engine binary is missing, RUNE_MODEL_ROUTING_BIN "
|
||||
"override); red-first parity battery "
|
||||
"tests/test_reconcile_tools_support_port.py pins Go to the Python "
|
||||
"oracle byte-for-byte; local_tool_runtime consumer keeps its seam "
|
||||
"but now resolves through Go at runtime"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/go/model_routing/",
|
||||
"src/rune/model_routing.py",
|
||||
"src/rune/local_tool_runtime.py",
|
||||
"tests/test_reconcile_tools_support_port.py",
|
||||
"verification/integrated/model-routing-integrated.json",
|
||||
"verification/integrated/reconcile-tools-integrated.json",
|
||||
"scripts/admit_wave_retire_reconcile_tools.py",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-retire-reconcile-tools",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-retire-reconcile-tools",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"verification/integrated/model-routing-integrated.json",
|
||||
"src/rune/model_routing.py",
|
||||
"src/rune/local_tool_runtime.py",
|
||||
"src/go/model_routing/eval.go",
|
||||
"tests/test_p1_06_model_routing.py",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-retire-reconcile-tools")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-retire-reconcile-tools",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-retire-reconcile-tools",
|
||||
expected_new_entities=[
|
||||
"src/go/model_routing/reconcile.go (Go port of "
|
||||
"reconcile_tools_support over CapabilityFloor)",
|
||||
"src/go/model_routing/reconcile_test.go (red-first Go parity "
|
||||
"battery mirrored from the Python oracle corpus)",
|
||||
"src/go/model_routing `eval reconcile` surface (eval.go)",
|
||||
"tests/test_reconcile_tools_support_port.py (Python-side "
|
||||
"parity battery: go delegation == python oracle "
|
||||
"byte-for-byte; fail-loud binary-missing refusal; "
|
||||
"local_tool_runtime consumer receipt)",
|
||||
"verification/integrated/reconcile-tools-integrated.json "
|
||||
"(integrated receipt)",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"a provider supports_tools claim contradicting "
|
||||
"CAPABILITY_FLOOR surfaces as an unverified drift marker "
|
||||
"and never silently flips eligibility",
|
||||
"reconcile_tools_support resolves through the Go engine by "
|
||||
"default; RUNE_MODEL_ROUTING_ENGINE=python pins the oracle "
|
||||
"body; a missing engine binary raises a typed RuntimeError "
|
||||
"(no silent Python fallback)",
|
||||
"local_tool_runtime's capability-drift seam now executes Go "
|
||||
"at runtime while the Python module stays as oracle",
|
||||
],
|
||||
verification_obligations=[
|
||||
"go test -race -short ./... in src/go/model_routing green",
|
||||
"pytest tests/test_reconcile_tools_support_port.py "
|
||||
"tests/test_p1_06_model_routing.py "
|
||||
"tests/test_local_tool_runtime.py green",
|
||||
],
|
||||
approval_ref="user:gitea #2574 rebalanced overnight queue, wave A "
|
||||
"(majority-rebase) 2026-10-24",
|
||||
contract_id="ADMIT-wave-retire-reconcile-tools",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Admission dispatch for wave retire-tool-executor (Gitea #2575
|
||||
rebalanced overnight queue, wave B): wire local_tool_runtime.py to the
|
||||
Go tool-executor engine.
|
||||
|
||||
classify + activate + admit (headless scaffolding entry law,
|
||||
scripts/admit_wave_retire_reconcile_tools.py pattern): the Go executor
|
||||
exists (src/go/tool-executor, full CLI surface read_file/list_directory/
|
||||
glob_files/grep_files/run_command, JSON envelope {success,result,error})
|
||||
but src/rune/local_tool_runtime.py never delegates to it. This wave wires
|
||||
the fail-loud delegation facade (LocalToolRuntime's executor resolves the
|
||||
four READ-ONLY ops through the Go binary by default; run_command stays
|
||||
behind the Python approval door — mutating, never silently executed),
|
||||
keeps the Python bodies as oracle/escape hatch
|
||||
(RUNE_TOOL_EXECUTOR_ENGINE=python, NO Python deletion — PORT → INTEGRATE
|
||||
→ REMOVE), and pins the port with a red-first parity battery
|
||||
(tests/test_tool_executor_delegation.py) against the Python oracle.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"Close the wave-181 full-switchover blocker: Go port of the local "
|
||||
"tool execution — wire local_tool_runtime.py to the existing "
|
||||
"src/go/tool-executor engine. LocalToolRuntime executor becomes "
|
||||
"a fail-loud delegation facade: the four read-only file ops "
|
||||
"(read_file, directory enumeration, glob, grep) resolve through "
|
||||
"the Go binary by default, JSON envelope preserved exactly; "
|
||||
"run_command stays behind the approval door (mutating, never "
|
||||
"silently executed). Python bodies stay as oracle escape hatch "
|
||||
"(RUNE_TOOL_EXECUTOR_ENGINE=python, RUNE_TOOL_EXECUTOR_BIN "
|
||||
"override, typed RuntimeError when the binary is missing — no "
|
||||
"silent fallback, no Python deletion; PORT → INTEGRATE → REMOVE). "
|
||||
"Red-first parity battery tests/test_tool_executor_delegation.py "
|
||||
"pins Go to the Python oracle envelope-for-envelope"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/go/tool-executor/",
|
||||
"src/rune/local_tool_runtime.py",
|
||||
"tests/test_tool_executor_delegation.py",
|
||||
"tests/test_local_tool_runtime.py",
|
||||
"tests/test_p0_05_native_tools.py",
|
||||
"tests/test_mode_dispatch_gate.py",
|
||||
"verification/integrated/tool-executor-integrated.json",
|
||||
"scripts/admit_wave_retire_tool_executor.py",
|
||||
]
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-retire-tool-executor",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-retire-tool-executor",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"src/go/tool-executor/internal/toolexec/cli.go",
|
||||
"src/rune/local_tool_runtime.py",
|
||||
"src/rune/tool_executor.py",
|
||||
"tests/test_tool_executor_delegation.py",
|
||||
"verification/integrated/reconcile-tools-integrated.json",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-retire-tool-executor")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-retire-tool-executor",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-retire-tool-executor",
|
||||
expected_new_entities=[
|
||||
"tests/test_tool_executor_delegation.py (red-first parity "
|
||||
"battery: Go delegation == Python oracle envelope-for-"
|
||||
"envelope; fail-loud binary-missing refusal; run_command "
|
||||
"never delegates; default-is-go pin)",
|
||||
"verification/integrated/tool-executor-integrated.json "
|
||||
"(integrated receipt, soak clock before any Python deletion)",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"LocalToolRuntime's executor resolves read_file, "
|
||||
"list_directory, glob_files and grep_files through the Go "
|
||||
"tool-executor binary by default; envelopes preserved "
|
||||
"exactly",
|
||||
"RUNE_TOOL_EXECUTOR_ENGINE=python pins the oracle path; "
|
||||
"a missing engine binary raises a typed RuntimeError (no "
|
||||
"silent Python fallback)",
|
||||
"run_command dispatches through the Python authority path "
|
||||
"only (approval door preserved, no Go bypass)",
|
||||
],
|
||||
verification_obligations=[
|
||||
"go test -race -short ./... in src/go/tool-executor green",
|
||||
"pytest tests/test_tool_executor_delegation.py "
|
||||
"tests/test_local_tool_runtime.py tests/test_p0_05_native_tools.py "
|
||||
"tests/test_mode_dispatch_gate.py tests/test_context_broker.py "
|
||||
"green",
|
||||
],
|
||||
approval_ref="user:gitea #2575 rebalanced overnight queue, wave B "
|
||||
"2026-10-24",
|
||||
contract_id="ADMIT-wave-retire-tool-executor",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Admission dispatch for the streaming-deadline ROOT-CAUSE wave (Gitea
|
||||
#2556 — the owner verdict: 'context deadline needs a proper solution we
|
||||
have just been patching around it rather than making the root cause
|
||||
better for local models').
|
||||
|
||||
classify + activate + admit (the headless scaffolding entry law): the
|
||||
wave replaces the flat whole-turn budgets (330s -> 480s -> 6x480s, the
|
||||
D46/D47 lineage) with STREAMING through the whole chain — Ollama wire,
|
||||
cloud wires, core /v1/chat/completions, entry proxy, TUI render — so a
|
||||
turn never sits inside one blocking HTTP call; the deadline collapses
|
||||
to an INTER-CHUNK IDLE TIMEOUT (no bytes = dead) with a generous
|
||||
overall backstop. Budget constants retire to non-streaming fallback
|
||||
paths only.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rune import construction_activation as cact # noqa: E402
|
||||
from rune import construction_admission as cadm # noqa: E402
|
||||
from rune import construction_mode as cmode # noqa: E402
|
||||
from rune import scaffold as sc # noqa: E402
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
OBJECTIVE = (
|
||||
"land the streaming root-cause wave (Gitea #2556, owner verdict "
|
||||
"2026-10-22): stream through the WHOLE turn chain so a local 27B "
|
||||
"thinking turn never sits inside one blocking HTTP call — (1) the "
|
||||
"Ollama wire runs stream=true /api/chat with NDJSON chunks parsed "
|
||||
"incrementally (content/thinking/tool_calls; the final message "
|
||||
"object carries the assembled reply) and the engine's bounded tool "
|
||||
"loop runs over streamed replies; (2) the cloud wires gain "
|
||||
"streaming parity ([OI]-compat SSE, Anthropic SSE events, Gemini "
|
||||
"streamGenerateContent); (3) the core /v1/chat/completions relays "
|
||||
"the streamed turn as NDJSON chunks for stream=true callers while "
|
||||
"non-streaming callers get the assembled answer as before; (4) the "
|
||||
"entry proxy passes the stream through and the TUI renders text as "
|
||||
"it arrives; (5) on streamed paths the whole-turn budgets retire — "
|
||||
"replaced by an inter-chunk idle timeout (default 120s, "
|
||||
"RUNE_STREAM_IDLE_TIMEOUT configurable) plus a 30-minute overall "
|
||||
"backstop; the budget constants remain only for non-streaming "
|
||||
"fallback paths"
|
||||
)
|
||||
AFFECTED = [
|
||||
"src/go/rune-core/internal/runecore/",
|
||||
"src/go/rune-entry/",
|
||||
"tui/runtime/lib/",
|
||||
"tui/cmd/rune-tui/",
|
||||
"scripts/admit_wave_streaming_deadline_root.py",
|
||||
]
|
||||
|
||||
def main() -> int:
|
||||
store = os.path.join(ROOT, "store", "scaffold")
|
||||
scaffold = sc.Scaffold(store, "rune")
|
||||
|
||||
md = cmode.classify_mode(
|
||||
OBJECTIVE,
|
||||
target_project="rune",
|
||||
project_exists=True,
|
||||
affected_surface=AFFECTED,
|
||||
)
|
||||
print("classified mode:", md.mode)
|
||||
|
||||
need = {
|
||||
"need_id": "need-wave-streaming-deadline-root",
|
||||
"objective": OBJECTIVE,
|
||||
"origin": "worker",
|
||||
"project": "rune",
|
||||
"task_ref": "wave-streaming-deadline-root",
|
||||
"affected_surface": AFFECTED,
|
||||
"evidence_refs": [
|
||||
"src/go/rune-core/internal/runecore/ollama.go",
|
||||
"src/go/rune-core/internal/runecore/cloud.go",
|
||||
"src/go/rune-core/internal/runecore/server.go",
|
||||
"src/go/rune-entry/internal/entry/serve.go",
|
||||
"tui/runtime/lib/complete.go",
|
||||
"tui/laneconc/laneconc.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"scripts/admit_wave_terminal_budgets.py",
|
||||
],
|
||||
}
|
||||
act = cact.activate(md, need=need, scaffold=scaffold,
|
||||
builder="wave-streaming-deadline-root")
|
||||
print("activated:", act.get("activated"), act.get("mode"))
|
||||
|
||||
env = cadm.admit(
|
||||
act,
|
||||
objective=OBJECTIVE,
|
||||
builder="wave-streaming-deadline-root",
|
||||
affected_surface=AFFECTED,
|
||||
evidence_refs=need["evidence_refs"],
|
||||
origin="worker",
|
||||
task_ref="wave-streaming-deadline-root",
|
||||
expected_new_entities=[
|
||||
"the streaming root-cause wave report (reports/)",
|
||||
"the red-first streaming chain tests (ollama NDJSON, cloud "
|
||||
"SSE, core stream relay, entry pass-through, TUI "
|
||||
"render-as-it-arrives, idle-timeout simulation)",
|
||||
"the inter-chunk idle timeout + 30-minute backstop on "
|
||||
"streamed paths (budgets retire to fallback)",
|
||||
],
|
||||
expected_runtime_effects=[
|
||||
"a local 27B thinking turn streams tokens continuously "
|
||||
"through core, entry proxy and TUI with no flat deadline; "
|
||||
"a genuinely dead model trips the idle timeout",
|
||||
],
|
||||
verification_obligations=[
|
||||
"-short race suites green on touched Go modules (the "
|
||||
"documented PTY family aside)",
|
||||
"full-chain simulation: a fake slow model emitting chunks "
|
||||
"across a long window completes the turn with no deadline "
|
||||
"fire; a silent model trips the idle timeout",
|
||||
"vet/gofmt clean",
|
||||
],
|
||||
approval_ref="user:streaming root-cause wave directive 2026-10-23 (Gitea #2556)",
|
||||
contract_id="ADMIT-wave-streaming-deadline-root",
|
||||
project_store=store,
|
||||
project_id="rune",
|
||||
)
|
||||
print(json.dumps(env, indent=2, default=str)[:2400])
|
||||
return 0 if env.get("success") else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -163,6 +163,25 @@ func runEvalCmd(args []string) int {
|
||||
})
|
||||
}
|
||||
printJSON(out)
|
||||
case "reconcile":
|
||||
out := []any{}
|
||||
for _, op := range ops {
|
||||
if op["op"] != "reconcile" {
|
||||
continue
|
||||
}
|
||||
model, _ := op["model"].(string)
|
||||
reported := false
|
||||
if v, ok := op["provider_reported"].(bool); ok {
|
||||
reported = v
|
||||
}
|
||||
eff, drift := ReconcileToolsSupport(model, reported)
|
||||
row := map[string]any{"effective": eff, "drift": nil}
|
||||
if drift != "" {
|
||||
row["drift"] = drift
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
printJSON(out)
|
||||
case "profiles":
|
||||
printJSON(registryToProbeJSON(LoadProfiles(OSFileSystem{}, profilesPath)))
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
// reconcile.go — the capability-floor drift reconciliation (R2 I-R2-022
|
||||
// truth_drift): a provider tool-support claim contradicting
|
||||
// CapabilityFloor must surface as unverified drift and never silently
|
||||
// flip eligibility. Ported from the Python oracle
|
||||
// (src/rune/model_routing.py: reconcile_tools_support), which stays the
|
||||
// parity oracle.
|
||||
|
||||
// ReconcileToolsSupport reconciles a provider-reported tool-support
|
||||
// claim against CapabilityFloor. Returns (effective_supports_tools,
|
||||
// drift_marker); the marker is "" when the claims agree or the model is
|
||||
// not floor-listed (nothing to reconcile — no floor verdict is
|
||||
// invented).
|
||||
func ReconcileToolsSupport(model string, providerReported bool) (bool, string) {
|
||||
for _, floor := range CapabilityFloor {
|
||||
if floor.Model == model {
|
||||
if floor.SupportsTools != providerReported {
|
||||
return providerReported,
|
||||
"unverified: provider reports supports_tools=" +
|
||||
boolWord(providerReported) + " for model '" + model +
|
||||
"' contradicting CAPABILITY_FLOOR supports_tools=" +
|
||||
boolWord(floor.SupportsTools) + "; floor still gates " +
|
||||
"router eligibility for unmeasured candidates"
|
||||
}
|
||||
return floor.SupportsTools, ""
|
||||
}
|
||||
}
|
||||
return providerReported, ""
|
||||
}
|
||||
|
||||
func boolWord(b bool) string {
|
||||
// The Python oracle f-strings {bool(...)} → "True"/"False"; the drift
|
||||
// marker must be byte-identical with the oracle text.
|
||||
if b {
|
||||
return "True"
|
||||
}
|
||||
return "False"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
// Red-first parity battery for ReconcileToolsSupport — mirrored from the
|
||||
// Python oracle battery (tests/test_p1_06_model_routing.py ::
|
||||
// test_reconcile_tools_support_surfaces_contradiction_as_unverified).
|
||||
// The Python module stays the oracle; this pins the Go port to the same
|
||||
// corpus (contradiction surfaces as unverified drift, agreement is clean,
|
||||
// non-floor models are never invented a verdict).
|
||||
|
||||
import "testing"
|
||||
|
||||
type reconcileCase struct {
|
||||
name string
|
||||
model string
|
||||
reported bool
|
||||
wantEff bool
|
||||
wantDrift string // "" = nil drift
|
||||
}
|
||||
|
||||
var reconcileCases = []reconcileCase{
|
||||
{
|
||||
name: "floor T1 contradiction: provider claims tools, floor says no",
|
||||
model: "qwen2.5:3b",
|
||||
reported: true,
|
||||
wantEff: true,
|
||||
wantDrift: "unverified: provider reports supports_tools=True for model " +
|
||||
"'qwen2.5:3b' contradicting CAPABILITY_FLOOR supports_tools=False; " +
|
||||
"floor still gates router eligibility for unmeasured candidates",
|
||||
},
|
||||
{
|
||||
name: "floor T4 contradiction: provider denies tools, floor says yes",
|
||||
model: "mimo-v2.5-pro",
|
||||
reported: false,
|
||||
wantEff: false,
|
||||
wantDrift: "unverified: provider reports supports_tools=False for model " +
|
||||
"'mimo-v2.5-pro' contradicting CAPABILITY_FLOOR supports_tools=True; " +
|
||||
"floor still gates router eligibility for unmeasured candidates",
|
||||
},
|
||||
{name: "floor T1 agreement (no tools)", model: "qwen2.5:3b", reported: false, wantEff: false, wantDrift: ""},
|
||||
{name: "floor T4 agreement (tools)", model: "mimo-v2.5-pro", reported: true, wantEff: true, wantDrift: ""},
|
||||
{name: "non-floor model: report passes through", model: "qwen3:8b", reported: true, wantEff: true, wantDrift: ""},
|
||||
{name: "non-floor model denial passes through", model: "qwen3:8b", reported: false, wantEff: false, wantDrift: ""},
|
||||
}
|
||||
|
||||
func TestReconcileToolsSupportParity(t *testing.T) {
|
||||
for _, tc := range reconcileCases {
|
||||
eff, drift := ReconcileToolsSupport(tc.model, tc.reported)
|
||||
if eff != tc.wantEff {
|
||||
t.Errorf("%s: effective = %v, want %v", tc.name, eff, tc.wantEff)
|
||||
}
|
||||
if drift != tc.wantDrift {
|
||||
t.Errorf("%s: drift = %q, want %q", tc.name, drift, tc.wantDrift)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ type anthropicChatRequest struct {
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
System []anthropicSystemBlock `json:"system,omitempty"`
|
||||
Tools []anthropicToolDef `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
type anthropicChatResponse struct {
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
package runecore
|
||||
|
||||
// cloud_stream.go — the cloud wires' streaming parity (Gitea #2556):
|
||||
// stream=true where the provider supports it — [OI]-compat SSE (data:
|
||||
// lines, [DONE] sentinel), anthropic /v1/messages SSE events, gemini
|
||||
// streamGenerateContent (alt=sse). Every implementation surfaces text
|
||||
// and thinking bytes through the sink AS THEY ARRIVE, enforces the
|
||||
// INTER-CHUNK IDLE TIMEOUT (no bytes = ErrStreamIdle; the 30-minute
|
||||
// backstop is the runaway guard) and returns the assembled ChatReply
|
||||
// (content + thinking + tool_calls) exactly like its non-streaming
|
||||
// leg. Providers without streaming keep the non-streaming path + the
|
||||
// existing budget (the constants' remaining tenant).
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// streamDo issues the streamed POST over a client with NO whole-call
|
||||
// Timeout (the idle watch governs), answering the body reader + the
|
||||
// typed provider errors.
|
||||
func streamDo(ctx context.Context, provider, url string, headers map[string]string, body []byte) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", provider, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
return nil, readProviderError(provider, resp)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// jsonStreamFallback answers whether the provider ignored stream=true
|
||||
// and served the plain JSON envelope (Content-Type application/json) —
|
||||
// the honest non-streaming fallback, never a decode of the wrong shape.
|
||||
func jsonStreamFallback(resp *http.Response) bool {
|
||||
return strings.Contains(resp.Header.Get("Content-Type"), "application/json")
|
||||
}
|
||||
|
||||
// oiDecodeReply builds the ChatReply from a non-streaming [OI] JSON
|
||||
// envelope (the stream fallback + the plain leg share the shapes).
|
||||
func oiDecodeReply(resp *http.Response, model, provider string, ctxLimit int) (ChatReply, error) {
|
||||
var out oiChatResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return ChatReply{}, fmt.Errorf("%s /chat/completions decode: %w", provider, err)
|
||||
}
|
||||
reply := ChatReply{Model: out.Model, ContextLimit: ctxLimit, ContextUsed: out.Usage.PromptTokens}
|
||||
if len(out.Choices) > 0 {
|
||||
msg := out.Choices[0].Message
|
||||
if msg.Content != nil {
|
||||
reply.Content = *msg.Content
|
||||
}
|
||||
if strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
reply.Thinking = []string{msg.ReasoningContent}
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
call := ToolCall{Name: tc.Function.Name}
|
||||
if strings.TrimSpace(tc.Function.Arguments) != "" {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(tc.Function.Arguments), &m) == nil {
|
||||
call.Args = m
|
||||
}
|
||||
}
|
||||
reply.ToolCalls = append(reply.ToolCalls, call)
|
||||
}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// oiStreamChunk is one [OI] SSE data payload (the delta shape).
|
||||
type oiStreamChunk struct {
|
||||
Model string `json:"model"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
} `json:"usage"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content *string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []oiStreamToolDl `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// oiStreamToolDl is one tool-call delta: the name on the first frame,
|
||||
// the JSON arguments accumulating across frames (indexed).
|
||||
type oiStreamToolDl struct {
|
||||
Index int `json:"index"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
// StreamChatWithTools is the [OI]-compat streaming leg: stream=true
|
||||
// chat/completions, SSE data lines parsed incrementally ([DONE]
|
||||
// terminates), content + reasoning surfaced through the sink.
|
||||
func (c *OpenAICompatClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
|
||||
wireMsgs, err := oiWireMessages(msgs)
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
body, err := json.Marshal(oiChatRequest{Model: bareWireModel(model), Messages: wireMsgs, Stream: true, Tools: tools})
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
idle := chunkIdleTimeout()
|
||||
w := watchIdle(ctx, idle, StreamTurnBackstop)
|
||||
defer w.stop()
|
||||
resp, err := streamDo(w.ctx, c.provider, c.baseURL+"/chat/completions",
|
||||
authHeaders(c.apiKey), body)
|
||||
if err != nil {
|
||||
return ChatReply{}, w.err(err, idle)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if jsonStreamFallback(resp) {
|
||||
return oiDecodeReply(resp, model, c.provider, contextLimitFor(c.ctxLimits, model))
|
||||
}
|
||||
var content strings.Builder
|
||||
var thinking strings.Builder
|
||||
argBufs := map[int]*strings.Builder{}
|
||||
names := map[int]string{}
|
||||
reply := ChatReply{ContextLimit: contextLimitFor(c.ctxLimits, model)}
|
||||
err = scanSSE(bufio.NewReader(resp.Body), func(data string) error {
|
||||
w.reset(idle)
|
||||
var chunk oiStreamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return fmt.Errorf("%s stream decode: %w", c.provider, err)
|
||||
}
|
||||
if chunk.Model != "" {
|
||||
reply.Model = chunk.Model
|
||||
}
|
||||
if chunk.Usage.PromptTokens > 0 {
|
||||
reply.ContextUsed = chunk.Usage.PromptTokens
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
return nil
|
||||
}
|
||||
d := chunk.Choices[0].Delta
|
||||
if d.Content != nil && *d.Content != "" {
|
||||
content.WriteString(*d.Content)
|
||||
emit(sink, StreamDelta{Kind: "text", Text: *d.Content})
|
||||
}
|
||||
if d.ReasoningContent != "" {
|
||||
thinking.WriteString(d.ReasoningContent)
|
||||
emit(sink, StreamDelta{Kind: "thinking", Text: d.ReasoningContent})
|
||||
}
|
||||
for _, tc := range d.ToolCalls {
|
||||
if tc.Function.Name != "" {
|
||||
names[tc.Index] = tc.Function.Name
|
||||
}
|
||||
buf := argBufs[tc.Index]
|
||||
if buf == nil {
|
||||
buf = &strings.Builder{}
|
||||
argBufs[tc.Index] = buf
|
||||
}
|
||||
buf.WriteString(tc.Function.Arguments)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && err != io.EOF {
|
||||
return ChatReply{}, w.err(err, idle)
|
||||
}
|
||||
reply.Content = content.String()
|
||||
if thinking.Len() > 0 {
|
||||
reply.Thinking = []string{thinking.String()}
|
||||
}
|
||||
reply.ToolCalls = oiStreamToolCalls(argBufs, names)
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// oiStreamToolCalls folds the accumulated tool-call deltas into the
|
||||
// reply's ToolCalls (arguments parsed as JSON objects when they parse).
|
||||
func oiStreamToolCalls(argBufs map[int]*strings.Builder, names map[int]string) []ToolCall {
|
||||
var out []ToolCall
|
||||
for idx := 0; idx < len(argBufs); idx++ {
|
||||
name := names[idx]
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
call := ToolCall{Name: name}
|
||||
if raw := argBufs[idx].String(); strings.TrimSpace(raw) != "" {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(raw), &m) == nil {
|
||||
call.Args = m
|
||||
}
|
||||
}
|
||||
out = append(out, call)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func authHeaders(apiKey string) map[string]string {
|
||||
if apiKey == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{"Authorization": "Bearer " + apiKey}
|
||||
}
|
||||
|
||||
// StreamChatWithTools is the anthropic streaming leg: stream=true
|
||||
// /v1/messages, the SSE event stream parsed incrementally
|
||||
// (content_block_delta carries the text/thinking/input_json bytes).
|
||||
func (c *AnthropicClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
|
||||
var system []anthropicSystemBlock
|
||||
rest := msgs
|
||||
for len(rest) > 0 && rest[0].Role == "system" {
|
||||
if strings.TrimSpace(rest[0].Content) != "" {
|
||||
system = append(system, anthropicSystemBlock{Type: "text", Text: rest[0].Content})
|
||||
}
|
||||
rest = rest[1:]
|
||||
}
|
||||
var wireTools []anthropicToolDef
|
||||
for _, t := range tools {
|
||||
wireTools = append(wireTools, anthropicToolDef{
|
||||
Name: t.Function.Name, Description: t.Function.Description, InputSchema: t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
body, err := json.Marshal(anthropicChatRequest{
|
||||
Model: model, MaxTokens: anthropicMaxTokens,
|
||||
Messages: anthropicWireMessages(rest), System: system, Tools: wireTools,
|
||||
Stream: true,
|
||||
})
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
idle := chunkIdleTimeout()
|
||||
w := watchIdle(ctx, idle, StreamTurnBackstop)
|
||||
defer w.stop()
|
||||
resp, err := streamDo(w.ctx, c.provider, c.baseURL+"/v1/messages",
|
||||
map[string]string{"x-api-key": c.apiKey, "anthropic-version": anthropicVersion}, body)
|
||||
if err != nil {
|
||||
return ChatReply{}, w.err(err, idle)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if jsonStreamFallback(resp) {
|
||||
return anthropicDecodeReply(resp, contextLimitFor(c.ctxLimits, model), c.provider)
|
||||
}
|
||||
reply := ChatReply{ContextLimit: contextLimitFor(c.ctxLimits, model)}
|
||||
var content strings.Builder
|
||||
var thinking strings.Builder
|
||||
// streaming tool calls: block index -> accumulated input JSON
|
||||
toolNames := map[int]string{}
|
||||
toolArgs := map[int]*strings.Builder{}
|
||||
err = scanSSE(bufio.NewReader(resp.Body), func(data string) error {
|
||||
w.reset(idle)
|
||||
var ev struct {
|
||||
Type string `json:"type"`
|
||||
Index int `json:"index"`
|
||||
Block *anthropicContentBlock `json:"content_block"`
|
||||
Delta *struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
PartialJSON string `json:"partial_json"`
|
||||
} `json:"delta"`
|
||||
Message *anthropicChatResponse `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
||||
return fmt.Errorf("%s stream decode: %w", c.provider, err)
|
||||
}
|
||||
switch ev.Type {
|
||||
case "message_start":
|
||||
if ev.Message != nil {
|
||||
reply.Model = ev.Message.Model
|
||||
reply.ContextUsed = ev.Message.Usage.InputTokens
|
||||
}
|
||||
case "content_block_start":
|
||||
if ev.Block != nil && ev.Block.Type == "tool_use" {
|
||||
toolNames[ev.Index] = ev.Block.Name
|
||||
toolArgs[ev.Index] = &strings.Builder{}
|
||||
}
|
||||
case "content_block_delta":
|
||||
if ev.Delta == nil {
|
||||
return nil
|
||||
}
|
||||
switch ev.Delta.Type {
|
||||
case "text_delta":
|
||||
if ev.Delta.Text != "" {
|
||||
content.WriteString(ev.Delta.Text)
|
||||
emit(sink, StreamDelta{Kind: "text", Text: ev.Delta.Text})
|
||||
}
|
||||
case "thinking_delta":
|
||||
if ev.Delta.Thinking != "" {
|
||||
thinking.WriteString(ev.Delta.Thinking)
|
||||
emit(sink, StreamDelta{Kind: "thinking", Text: ev.Delta.Thinking})
|
||||
}
|
||||
case "input_json_delta":
|
||||
if buf, ok := toolArgs[ev.Index]; ok {
|
||||
buf.WriteString(ev.Delta.PartialJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && err != io.EOF {
|
||||
return ChatReply{}, w.err(err, idle)
|
||||
}
|
||||
reply.Content = content.String()
|
||||
if thinking.Len() > 0 {
|
||||
reply.Thinking = []string{thinking.String()}
|
||||
}
|
||||
for idx := 0; idx < maxIntMapLen(toolNames); idx++ {
|
||||
name, ok := toolNames[idx]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
call := ToolCall{Name: name}
|
||||
if buf := toolArgs[idx]; buf != nil && strings.TrimSpace(buf.String()) != "" {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(buf.String()), &m) == nil {
|
||||
call.Args = m
|
||||
}
|
||||
}
|
||||
reply.ToolCalls = append(reply.ToolCalls, call)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func maxIntMapLen(m map[int]string) int {
|
||||
n := 0
|
||||
for k := range m {
|
||||
if k+1 > n {
|
||||
n = k + 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// anthropicDecodeReply builds the ChatReply from a non-streaming
|
||||
// /v1/messages JSON envelope (the stream fallback).
|
||||
func anthropicDecodeReply(resp *http.Response, ctxLimit int, provider string) (ChatReply, error) {
|
||||
var out anthropicChatResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return ChatReply{}, fmt.Errorf("%s /v1/messages decode: %w", provider, err)
|
||||
}
|
||||
reply := ChatReply{Model: out.Model, ContextLimit: ctxLimit, ContextUsed: out.Usage.InputTokens}
|
||||
var content strings.Builder
|
||||
for _, blk := range out.Content {
|
||||
switch blk.Type {
|
||||
case "text":
|
||||
content.WriteString(blk.Text)
|
||||
case "thinking":
|
||||
if strings.TrimSpace(blk.Thinking) != "" {
|
||||
reply.Thinking = append(reply.Thinking, blk.Thinking)
|
||||
}
|
||||
case "tool_use":
|
||||
reply.ToolCalls = append(reply.ToolCalls, ToolCall{Name: blk.Name, Args: blk.Input})
|
||||
}
|
||||
}
|
||||
reply.Content = content.String()
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// geminiDecodeReply builds the ChatReply from a non-streaming
|
||||
// generateContent JSON envelope (the stream fallback).
|
||||
func geminiDecodeReply(resp *http.Response, ctxLimit int, provider string) (ChatReply, error) {
|
||||
var out geminiChatResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return ChatReply{}, fmt.Errorf("%s generateContent decode: %w", provider, err)
|
||||
}
|
||||
reply := ChatReply{Model: out.ModelVersion, ContextLimit: ctxLimit, ContextUsed: out.UsageMetadata.PromptTokenCount}
|
||||
var content strings.Builder
|
||||
if len(out.Candidates) > 0 {
|
||||
for _, part := range out.Candidates[0].Content.Parts {
|
||||
if part.FunctionCall != nil {
|
||||
reply.ToolCalls = append(reply.ToolCalls, ToolCall{Name: part.FunctionCall.Name, Args: part.FunctionCall.Args})
|
||||
continue
|
||||
}
|
||||
if part.Thought {
|
||||
if strings.TrimSpace(part.Text) != "" {
|
||||
reply.Thinking = append(reply.Thinking, part.Text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
content.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
reply.Content = content.String()
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// StreamChatWithTools is the gemini streaming leg:
|
||||
// streamGenerateContent?alt=sse — each SSE data line is one
|
||||
// GenerateContentResponse chunk; text/thought parts and functionCall
|
||||
// parts surface as they arrive.
|
||||
func (c *GeminiClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
|
||||
var system *struct {
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
rest := msgs
|
||||
for len(rest) > 0 && rest[0].Role == "system" {
|
||||
if strings.TrimSpace(rest[0].Content) != "" {
|
||||
if system == nil {
|
||||
system = &struct {
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}{}
|
||||
}
|
||||
system.Parts = append(system.Parts, geminiPart{Text: rest[0].Content})
|
||||
}
|
||||
rest = rest[1:]
|
||||
}
|
||||
var wireTools []geminiTool
|
||||
if len(tools) > 0 {
|
||||
decls := make([]geminiFunctionDecl, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
decls = append(decls, geminiFunctionDecl{
|
||||
Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
wireTools = []geminiTool{{FunctionDeclarations: decls}}
|
||||
}
|
||||
body, err := json.Marshal(geminiChatRequest{Contents: geminiWireMessages(rest), SystemInstruction: system, Tools: wireTools})
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
idle := chunkIdleTimeout()
|
||||
w := watchIdle(ctx, idle, StreamTurnBackstop)
|
||||
defer w.stop()
|
||||
url := fmt.Sprintf("%s/%s:streamGenerateContent?alt=sse&key=%s", c.baseURL, geminiWireModel(model), c.apiKey)
|
||||
resp, err := streamDo(w.ctx, c.provider, url, nil, body)
|
||||
if err != nil {
|
||||
return ChatReply{}, w.err(err, idle)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if jsonStreamFallback(resp) {
|
||||
return geminiDecodeReply(resp, contextLimitFor(c.ctxLimits, model), c.provider)
|
||||
}
|
||||
reply := ChatReply{ContextLimit: contextLimitFor(c.ctxLimits, model)}
|
||||
var content strings.Builder
|
||||
var thinking strings.Builder
|
||||
err = scanSSE(bufio.NewReader(resp.Body), func(data string) error {
|
||||
w.reset(idle)
|
||||
var chunk geminiChatResponse
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return fmt.Errorf("%s stream decode: %w", c.provider, err)
|
||||
}
|
||||
if chunk.ModelVersion != "" {
|
||||
reply.Model = chunk.ModelVersion
|
||||
}
|
||||
if chunk.UsageMetadata.PromptTokenCount > 0 {
|
||||
reply.ContextUsed = chunk.UsageMetadata.PromptTokenCount
|
||||
}
|
||||
if len(chunk.Candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, part := range chunk.Candidates[0].Content.Parts {
|
||||
if part.FunctionCall != nil {
|
||||
reply.ToolCalls = append(reply.ToolCalls, ToolCall{Name: part.FunctionCall.Name, Args: part.FunctionCall.Args})
|
||||
continue
|
||||
}
|
||||
if part.Thought {
|
||||
if part.Text != "" {
|
||||
thinking.WriteString(part.Text)
|
||||
emit(sink, StreamDelta{Kind: "thinking", Text: part.Text})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if part.Text != "" {
|
||||
content.WriteString(part.Text)
|
||||
emit(sink, StreamDelta{Kind: "text", Text: part.Text})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && err != io.EOF {
|
||||
return ChatReply{}, w.err(err, idle)
|
||||
}
|
||||
reply.Content = content.String()
|
||||
if thinking.Len() > 0 {
|
||||
reply.Thinking = []string{thinking.String()}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package runecore
|
||||
|
||||
// cloud_stream_test.go — the cloud wires' streaming parity proofs
|
||||
// (Gitea #2556, red-first, hermetic): each wire's SSE body parses
|
||||
// incrementally, surfaces the text bytes through the sink, assembles
|
||||
// the reply (thinking + tool_calls included), and honors the
|
||||
// inter-chunk idle timeout.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sseServer is a hermetic SSE double: events written per line with a
|
||||
// delay between them.
|
||||
func sseServer(t *testing.T, path string, events []string, perEvent time.Duration) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != path {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
for i, ev := range events {
|
||||
_, _ = w.Write([]byte("data: " + ev + "\n\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
if i < len(events)-1 && perEvent > 0 {
|
||||
time.Sleep(perEvent)
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestOICompatStreamAssemblesReply(t *testing.T) {
|
||||
events := []string{
|
||||
`{"model":"glm","choices":[{"delta":{"content":"he"}}]}`,
|
||||
`{"model":"glm","choices":[{"delta":{"content":"llo"}}]}`,
|
||||
`{"model":"glm","choices":[{"delta":{"reasoning_content":"thought hard"}}]}`,
|
||||
`{"model":"glm","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\"pa"}}]}}]}`,
|
||||
`{"model":"glm","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\":\"x\"}"}}]}}]}`,
|
||||
`{"model":"glm","choices":[{"delta":{}}],"usage":{"prompt_tokens":90}}`,
|
||||
"[DONE]",
|
||||
}
|
||||
srv := sseServer(t, "/chat/completions", events, 0)
|
||||
c := NewOpenAICompatClient(srv.URL, "", "openai")
|
||||
var got strings.Builder
|
||||
reply, err := c.StreamChatWithTools(context.Background(), "openai/glm",
|
||||
[]ChatMessage{{Role: "user", Content: "hi"}}, nil, func(d StreamDelta) {
|
||||
if d.Kind == "text" {
|
||||
got.WriteString(d.Text)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.String() != "hello" {
|
||||
t.Fatalf("deltas = %q", got.String())
|
||||
}
|
||||
if reply.Content != "hello" {
|
||||
t.Fatalf("content = %q", reply.Content)
|
||||
}
|
||||
if len(reply.Thinking) != 1 || reply.Thinking[0] != "thought hard" {
|
||||
t.Fatalf("thinking = %v", reply.Thinking)
|
||||
}
|
||||
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "read_file" || reply.ToolCalls[0].Args["path"] != "x" {
|
||||
t.Fatalf("tool calls = %+v", reply.ToolCalls)
|
||||
}
|
||||
if reply.ContextUsed != 90 {
|
||||
t.Fatalf("context used = %d", reply.ContextUsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOICompatStreamIdleTimeout(t *testing.T) {
|
||||
t.Setenv(ChunkIdleTimeoutEnv, "1")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewOpenAICompatClient(srv.URL, "", "openai")
|
||||
_, err := c.StreamChatWithTools(context.Background(), "m", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
|
||||
if !errors.Is(err, ErrStreamIdle) {
|
||||
t.Fatalf("err = %v, want ErrStreamIdle", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicStreamAssemblesReply(t *testing.T) {
|
||||
events := []string{
|
||||
`{"type":"message_start","message":{"model":"claude","usage":{"input_tokens":55}}}`,
|
||||
`{"type":"content_block_start","index":0,"content_block":{"type":"text"}}`,
|
||||
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"bon"}}`,
|
||||
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"jour"}}`,
|
||||
`{"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"musing"}}`,
|
||||
`{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_read_file","name":"read_file"}}`,
|
||||
`{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"y\"}"}}`,
|
||||
`{"type":"message_stop"}`,
|
||||
}
|
||||
srv := sseServer(t, "/v1/messages", events, 0)
|
||||
c := NewAnthropicClient(srv.URL, "k", "anthropic")
|
||||
var got strings.Builder
|
||||
reply, err := c.StreamChatWithTools(context.Background(), "claude",
|
||||
[]ChatMessage{{Role: "user", Content: "hi"}}, nil, func(d StreamDelta) {
|
||||
if d.Kind == "text" {
|
||||
got.WriteString(d.Text)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.String() != "bonjour" || reply.Content != "bonjour" {
|
||||
t.Fatalf("deltas=%q content=%q", got.String(), reply.Content)
|
||||
}
|
||||
if len(reply.Thinking) != 1 || reply.Thinking[0] != "musing" {
|
||||
t.Fatalf("thinking = %v", reply.Thinking)
|
||||
}
|
||||
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "read_file" || reply.ToolCalls[0].Args["path"] != "y" {
|
||||
t.Fatalf("tool calls = %+v", reply.ToolCalls)
|
||||
}
|
||||
if reply.ContextUsed != 55 {
|
||||
t.Fatalf("context used = %d", reply.ContextUsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiStreamAssemblesReply(t *testing.T) {
|
||||
events := []string{
|
||||
`{"candidates":[{"content":{"role":"model","parts":[{"text":"cia"},{"thought":true,"text":"ponder"},{"text":"o"}]}}],"modelVersion":"gemini","usageMetadata":{"promptTokenCount":31}}`,
|
||||
`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"glob_files","args":{"pattern":"*.go"}}}]}}]}`,
|
||||
}
|
||||
srv := sseServer(t, "/models/gemini:streamGenerateContent", events, 0)
|
||||
c := NewGeminiClient(srv.URL, "k", "gemini")
|
||||
var got strings.Builder
|
||||
reply, err := c.StreamChatWithTools(context.Background(), "models/gemini",
|
||||
[]ChatMessage{{Role: "user", Content: "hi"}}, nil, func(d StreamDelta) {
|
||||
if d.Kind == "text" {
|
||||
got.WriteString(d.Text)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.String() != "ciao" || reply.Content != "ciao" {
|
||||
t.Fatalf("deltas=%q content=%q", got.String(), reply.Content)
|
||||
}
|
||||
if len(reply.Thinking) != 1 || reply.Thinking[0] != "ponder" {
|
||||
t.Fatalf("thinking = %v", reply.Thinking)
|
||||
}
|
||||
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "glob_files" || reply.ToolCalls[0].Args["pattern"] != "*.go" {
|
||||
t.Fatalf("tool calls = %+v", reply.ToolCalls)
|
||||
}
|
||||
if reply.ContextUsed != 31 {
|
||||
t.Fatalf("context used = %d", reply.ContextUsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloudStreamsSlowModelFinishes — one wire suffices for the
|
||||
// timing law (the watch is shared): an SSE model emitting every 200ms
|
||||
// across a window no flat budget tolerated completes.
|
||||
func TestCloudStreamsSlowModelFinishes(t *testing.T) {
|
||||
t.Setenv(ChunkIdleTimeoutEnv, "1")
|
||||
var events []string
|
||||
for i := 0; i < 10; i++ {
|
||||
events = append(events, fmt.Sprintf(`{"model":"m","choices":[{"delta":{"content":"c%d "}}]}`, i))
|
||||
}
|
||||
events = append(events, "[DONE]")
|
||||
srv := sseServer(t, "/chat/completions", events, 200*time.Millisecond)
|
||||
defer srv.Close()
|
||||
c := NewOpenAICompatClient(srv.URL, "", "openai")
|
||||
reply, err := c.StreamChatWithTools(context.Background(), "m", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("slow streamed cloud turn failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(reply.Content, "c9") {
|
||||
t.Fatalf("content = %q", reply.Content)
|
||||
}
|
||||
}
|
||||
@@ -190,29 +190,40 @@ func (e *Engine) release(channel string) {
|
||||
delete(e.inFlight, channel)
|
||||
}
|
||||
|
||||
// runTurn drives the turn's model call: the tool loop when the client
|
||||
// can carry tools AND the turn's dispatch mode exposes a toolset (D45
|
||||
// toolset gap — the model must SEE the allowed suite and be able to
|
||||
// ACT, gated). The mode is channel-derived (modeForDispatch, #2537):
|
||||
// the task channel runs TASK semantics regardless of the face's mode;
|
||||
// every other channel follows the face.
|
||||
func (e *Engine) runTurn(ctx context.Context, client ModelClient, model, channel string, payload []ChatMessage) (ChatReply, error) {
|
||||
// modelOnce runs ONE model call on the loop's leg: the streaming leg
|
||||
// when the client streams (Gitea #2556 — the reply's bytes surface
|
||||
// through sink as they arrive), the non-streaming leg otherwise. The
|
||||
// loop's bounded semantics are unchanged either way.
|
||||
func modelOnce(ctx context.Context, client ModelClient, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
|
||||
if sc, ok := client.(StreamChatClient); ok {
|
||||
return sc.StreamChatWithTools(ctx, model, msgs, tools, sink)
|
||||
}
|
||||
if tc, ok := client.(ToolChatClient); ok {
|
||||
return tc.ChatWithTools(ctx, model, msgs, tools)
|
||||
}
|
||||
return client.Chat(ctx, model, msgs)
|
||||
}
|
||||
|
||||
// runTurn drives the turn's model call: the tool loop when the turn's
|
||||
// dispatch mode exposes a toolset (D45 toolset gap — the model must
|
||||
// SEE the allowed suite and be able to ACT, gated). The mode is
|
||||
// channel-derived (modeForDispatch, #2537): the task channel runs TASK
|
||||
// semantics regardless of the face's mode; every other channel follows
|
||||
// the face. A streaming client streams every loop leg (sink non-nil
|
||||
// only on the streaming door).
|
||||
func (e *Engine) runTurn(ctx context.Context, client ModelClient, model, channel string, payload []ChatMessage, sink StreamSink) (ChatReply, error) {
|
||||
tools := ToolsetFor(modeForDispatch(channel, e.home))
|
||||
if len(tools) == 0 {
|
||||
return client.Chat(ctx, model, payload)
|
||||
return modelOnce(ctx, client, model, payload, nil, sink)
|
||||
}
|
||||
tc, ok := client.(ToolChatClient)
|
||||
if !ok {
|
||||
return client.Chat(ctx, model, payload)
|
||||
}
|
||||
if !tc.SupportsTools(ctx, model) {
|
||||
if tc, ok := client.(ToolChatClient); !ok || !tc.SupportsTools(ctx, model) {
|
||||
return client.Chat(ctx, model, payload)
|
||||
}
|
||||
msgs := payload
|
||||
var reply ChatReply
|
||||
var err error
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
reply, err = tc.ChatWithTools(ctx, model, msgs, tools)
|
||||
reply, err = modelOnce(ctx, client, model, msgs, tools, sink)
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
@@ -238,7 +249,7 @@ func (e *Engine) runTurn(ctx context.Context, client ModelClient, model, channel
|
||||
// The loop hit its cap: one final call with the toolset withdrawn —
|
||||
// the model answers in text, it never keeps demanding tools forever
|
||||
// (the Python bounded-loop law).
|
||||
return tc.ChatWithTools(ctx, model, msgs, nil)
|
||||
return modelOnce(ctx, client, model, msgs, nil, sink)
|
||||
}
|
||||
|
||||
// Turn runs one channel turn: admit, resolve the model (the record's
|
||||
@@ -246,6 +257,15 @@ func (e *Engine) runTurn(ctx context.Context, client ModelClient, model, channel
|
||||
// the user message, assemble THIS channel's context, call the model
|
||||
// once, persist the assistant message, answer.
|
||||
func (e *Engine) Turn(ctx context.Context, req TurnRequest) (TurnResult, error) {
|
||||
return e.TurnStream(ctx, req, nil)
|
||||
}
|
||||
|
||||
// TurnStream runs one channel turn with the model's answer bytes
|
||||
// surfaced through sink AS THEY ARRIVE (Gitea #2556 — the whole turn
|
||||
// never sits inside one blocking call). A nil sink keeps the classic
|
||||
// Turn behavior byte-for-byte. Admit, persistence, tool-loop and
|
||||
// result shapes are identical to Turn.
|
||||
func (e *Engine) TurnStream(ctx context.Context, req TurnRequest, sink StreamSink) (TurnResult, error) {
|
||||
if !validChannel(req.Channel) {
|
||||
return TurnResult{}, errors.New("invalid channel id")
|
||||
}
|
||||
@@ -305,7 +325,7 @@ func (e *Engine) Turn(ctx context.Context, req TurnRequest) (TurnResult, error)
|
||||
payload = append(payload, cm)
|
||||
}
|
||||
|
||||
reply, err := e.runTurn(ctx, client, model, req.Channel, payload)
|
||||
reply, err := e.runTurn(ctx, client, model, req.Channel, payload, sink)
|
||||
if err != nil {
|
||||
// the user message stays persisted: the user DID send it (the
|
||||
// Python oracle's behavior); the failure is honest, not silent
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
package runecore
|
||||
|
||||
// gittools.go — D49's native git family (owner ruling 2026-10-22:
|
||||
// "she also needs tools to be able to connect to git projects. and
|
||||
// make that native"). Six first-class tools — git_status, git_diff,
|
||||
// git_log, git_branch, git_add, git_commit — implemented by shelling
|
||||
// out to git in the repo root (stdlib only, the Go core's zero-dep
|
||||
// split law; no external git library), porcelain output parsed
|
||||
// honestly, typed JSON results the model can read. git_add and
|
||||
// git_commit refuse the landing-law banned paths (check_landing.py
|
||||
// rule 4) so the model's git door obeys the same law every other
|
||||
// mutation door does; in a real checkout git_commit also runs the
|
||||
// repo's own pre-commit hook — the scaffolding authority stays the
|
||||
// safety layer at the mutation door.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultRepoRoot resolves the repository root the git family shells
|
||||
// into (mirrors tui/wasm's data-home law): RUNE_REPO_ROOT overrides
|
||||
// for tests and deploy-time roots; the default is the canonical
|
||||
// checkout.
|
||||
const defaultRepoRoot = "/mnt/workdrive/rune"
|
||||
|
||||
func DefaultRepoRoot() string {
|
||||
if v := os.Getenv("RUNE_REPO_ROOT"); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultRepoRoot
|
||||
}
|
||||
|
||||
// gitLandingBannedPaths mirrors scripts/check_landing.py's
|
||||
// SWEEP_RACE_BANNED_PATHS (the gate is the authority; keep in sync):
|
||||
// live store state the sweep mutates — three 102MB accidents — never
|
||||
// committable whatever its size.
|
||||
var gitLandingBannedPaths = map[string]bool{
|
||||
"store/scaffold/current.json": true,
|
||||
"store/scaffold/legacy-current-migration-snapshot.json": true,
|
||||
}
|
||||
|
||||
// gitLandingMaxFileBytes mirrors check_landing.py's
|
||||
// MAX_LANDING_FILE_BYTES: any blob over the ceiling refuses at the
|
||||
// git_add door.
|
||||
const gitLandingMaxFileBytes = 20 * 1024 * 1024
|
||||
|
||||
func gitRun(root string, timeout time.Duration, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", root}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_PAGER=cat")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
if len(msg) > 2000 {
|
||||
msg = msg[:2000]
|
||||
}
|
||||
return "", fmt.Errorf("git %s: %s", args[0], msg)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func typedJSON(v any) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// repoRelPath normalizes a model-supplied path to a slash-separated
|
||||
// path relative to the repo root; empty when the path is not inside
|
||||
// the repo.
|
||||
func repoRelPath(root, p string) string {
|
||||
p = expandHome(strings.TrimSpace(p))
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(p) {
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return ""
|
||||
}
|
||||
p = rel
|
||||
}
|
||||
return filepath.ToSlash(filepath.Clean(p))
|
||||
}
|
||||
|
||||
// ── typed results ───────────────────────────────────────────────────
|
||||
|
||||
type GitStatusEntry struct {
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path"`
|
||||
OldPath string `json:"old_path,omitempty"`
|
||||
}
|
||||
|
||||
type GitStatusResult struct {
|
||||
Branch string `json:"branch"`
|
||||
Ahead int `json:"ahead"`
|
||||
Behind int `json:"behind"`
|
||||
Clean bool `json:"clean"`
|
||||
Entries []GitStatusEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type GitDiffName struct {
|
||||
Change string `json:"change"`
|
||||
Path string `json:"path"`
|
||||
OldPath string `json:"old_path,omitempty"`
|
||||
}
|
||||
|
||||
type GitDiffResult struct {
|
||||
Form string `json:"form"`
|
||||
Scope string `json:"scope"`
|
||||
Unstaged []GitDiffName `json:"unstaged,omitempty"`
|
||||
Staged []GitDiffName `json:"staged,omitempty"`
|
||||
Patch string `json:"patch,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
type GitLogEntry struct {
|
||||
Sha string `json:"sha"`
|
||||
Short string `json:"short"`
|
||||
Author string `json:"author"`
|
||||
When string `json:"when"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
type GitLogResult struct {
|
||||
Commits []GitLogEntry `json:"commits"`
|
||||
}
|
||||
|
||||
type GitBranchResult struct {
|
||||
Current string `json:"current"`
|
||||
Branches []string `json:"branches"`
|
||||
}
|
||||
|
||||
type GitAddResult struct {
|
||||
Added []string `json:"added"`
|
||||
}
|
||||
|
||||
type GitCommitResult struct {
|
||||
Committed bool `json:"committed"`
|
||||
Sha string `json:"sha"`
|
||||
Subject string `json:"subject"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// ── the tools ───────────────────────────────────────────────────────
|
||||
|
||||
func gitStatusTool(root string) (string, error) {
|
||||
out, err := gitRun(root, 30*time.Second, "status", "--porcelain=v1", "-b", "-z")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res := GitStatusResult{Entries: []GitStatusEntry{}}
|
||||
records := strings.Split(out, "\x00")
|
||||
for i := 0; i < len(records); i++ {
|
||||
rec := records[i]
|
||||
if rec == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(rec, "## ") {
|
||||
head := strings.TrimPrefix(rec, "## ")
|
||||
if strings.HasPrefix(head, "No commits yet on ") {
|
||||
res.Branch = strings.TrimPrefix(head, "No commits yet on ")
|
||||
continue
|
||||
}
|
||||
if open := strings.Index(head, " ["); open >= 0 && strings.HasSuffix(head, "]") {
|
||||
for _, kv := range strings.Split(head[open+2:len(head)-1], ",") {
|
||||
kv = strings.TrimSpace(kv)
|
||||
switch {
|
||||
case strings.HasPrefix(kv, "ahead "):
|
||||
res.Ahead, _ = strconv.Atoi(strings.TrimPrefix(kv, "ahead "))
|
||||
case strings.HasPrefix(kv, "behind "):
|
||||
res.Behind, _ = strconv.Atoi(strings.TrimPrefix(kv, "behind "))
|
||||
}
|
||||
}
|
||||
head = head[:open]
|
||||
}
|
||||
if idx := strings.Index(head, "..."); idx >= 0 {
|
||||
head = head[:idx]
|
||||
}
|
||||
res.Branch = head
|
||||
continue
|
||||
}
|
||||
if len(rec) < 4 {
|
||||
continue
|
||||
}
|
||||
code := rec[:2]
|
||||
path := rec[3:]
|
||||
entry := GitStatusEntry{Code: code, Path: path}
|
||||
// with -z a rename/copy record carries the OLD path as the
|
||||
// next NUL record — consume it
|
||||
if (code[0] == 'R' || code[0] == 'C') && i+1 < len(records) {
|
||||
entry.OldPath = records[i+1]
|
||||
i++
|
||||
}
|
||||
res.Entries = append(res.Entries, entry)
|
||||
}
|
||||
res.Clean = len(res.Entries) == 0
|
||||
return typedJSON(res)
|
||||
}
|
||||
|
||||
func parseDiffNames(out string) []GitDiffName {
|
||||
var files []GitDiffName
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
entry := GitDiffName{Change: strings.TrimRight(f[0], "0123456789"), Path: f[len(f)-1]}
|
||||
if len(f) >= 3 {
|
||||
entry.OldPath = f[1]
|
||||
}
|
||||
files = append(files, entry)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func gitDiffTool(root, form string, hasStaged, staged bool) (string, error) {
|
||||
if form == "" {
|
||||
form = "names"
|
||||
}
|
||||
if form != "names" && form != "patch" {
|
||||
return "", fmt.Errorf("git_diff: form must be \"names\" or \"patch\"")
|
||||
}
|
||||
res := GitDiffResult{Form: form}
|
||||
switch {
|
||||
case !hasStaged:
|
||||
res.Scope = "all"
|
||||
case staged:
|
||||
res.Scope = "staged"
|
||||
default:
|
||||
res.Scope = "unstaged"
|
||||
}
|
||||
needUnstaged := !hasStaged || !staged
|
||||
needStaged := !hasStaged || staged
|
||||
run := func(cached bool) (string, error) {
|
||||
args := []string{"diff"}
|
||||
if cached {
|
||||
args = append(args, "--cached")
|
||||
}
|
||||
if form == "names" {
|
||||
args = append(args, "--name-status")
|
||||
}
|
||||
return gitRun(root, 30*time.Second, args...)
|
||||
}
|
||||
if needUnstaged {
|
||||
out, err := run(false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if form == "names" {
|
||||
res.Unstaged = parseDiffNames(out)
|
||||
}
|
||||
res.Patch += "=== unstaged ===\n" + out
|
||||
}
|
||||
if needStaged {
|
||||
out, err := run(true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if form == "names" {
|
||||
res.Staged = parseDiffNames(out)
|
||||
}
|
||||
res.Patch += "=== staged ===\n" + out
|
||||
}
|
||||
if form == "patch" {
|
||||
const maxPatch = 16000
|
||||
if len(res.Patch) > maxPatch {
|
||||
res.Patch = res.Patch[:maxPatch]
|
||||
res.Truncated = true
|
||||
}
|
||||
} else {
|
||||
res.Patch = ""
|
||||
}
|
||||
return typedJSON(res)
|
||||
}
|
||||
|
||||
func gitLogTool(root string, limit int) (string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
out, err := gitRun(root, 30*time.Second, "log",
|
||||
fmt.Sprintf("-%d", limit),
|
||||
"--pretty=%H%x1f%h%x1f%an%x1f%ar%x1f%s")
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not have any commits yet") {
|
||||
return typedJSON(GitLogResult{Commits: []GitLogEntry{}})
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
res := GitLogResult{Commits: []GitLogEntry{}}
|
||||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
||||
f := strings.Split(line, "\x1f")
|
||||
if len(f) != 5 {
|
||||
continue
|
||||
}
|
||||
res.Commits = append(res.Commits, GitLogEntry{
|
||||
Sha: f[0], Short: f[1], Author: f[2], When: f[3], Subject: f[4],
|
||||
})
|
||||
}
|
||||
return typedJSON(res)
|
||||
}
|
||||
|
||||
func gitBranchTool(root string) (string, error) {
|
||||
out, err := gitRun(root, 15*time.Second, "branch", "--format=%(HEAD)%00%(refname:short)")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res := GitBranchResult{Branches: []string{}}
|
||||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
||||
f := strings.SplitN(line, "\x00", 2)
|
||||
if len(f) != 2 || f[1] == "" {
|
||||
continue
|
||||
}
|
||||
res.Branches = append(res.Branches, f[1])
|
||||
if strings.TrimSpace(f[0]) == "*" {
|
||||
res.Current = f[1]
|
||||
}
|
||||
}
|
||||
return typedJSON(res)
|
||||
}
|
||||
|
||||
func gitAddTool(root string, paths []string) (string, error) {
|
||||
if len(paths) == 0 {
|
||||
return "", fmt.Errorf("git_add: paths required")
|
||||
}
|
||||
cleaned := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
rel := repoRelPath(root, p)
|
||||
if rel == "" || rel == "." {
|
||||
return "", fmt.Errorf("git_add: %q is not a path inside the repository", p)
|
||||
}
|
||||
if gitLandingBannedPaths[rel] {
|
||||
return "", fmt.Errorf("git_add: %s is a banned landing-law path (live store state the sweep mutates — scripts/check_landing.py rule 4); it never enters the index", rel)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err == nil && fi.Size() > gitLandingMaxFileBytes {
|
||||
return "", fmt.Errorf("git_add: %s is %d bytes, over the %d-byte landing-law ceiling (scripts/check_landing.py rule 4)", rel, fi.Size(), gitLandingMaxFileBytes)
|
||||
}
|
||||
cleaned = append(cleaned, rel)
|
||||
}
|
||||
if _, err := gitRun(root, 30*time.Second, append([]string{"add", "--"}, cleaned...)...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return typedJSON(GitAddResult{Added: cleaned})
|
||||
}
|
||||
|
||||
func gitCommitTool(root, message string) (string, error) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return "", fmt.Errorf("git_commit: message required")
|
||||
}
|
||||
out, err := gitRun(root, 30*time.Second, "diff", "--cached", "--name-only")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var staged []string
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
staged = append(staged, l)
|
||||
}
|
||||
}
|
||||
if len(staged) == 0 {
|
||||
return "", fmt.Errorf("git_commit: nothing staged — stage files with git_add first")
|
||||
}
|
||||
var banned []string
|
||||
for _, p := range staged {
|
||||
if gitLandingBannedPaths[filepath.ToSlash(filepath.Clean(p))] {
|
||||
banned = append(banned, p)
|
||||
}
|
||||
}
|
||||
if len(banned) > 0 {
|
||||
return "", fmt.Errorf("git_commit: refused — the staged tree carries banned landing-law path(s) %s (live store state, scripts/check_landing.py rule 4); unstage them first", strings.Join(banned, ", "))
|
||||
}
|
||||
if _, err := gitRun(root, 120*time.Second, "commit", "-m", message); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sha, err := gitRun(root, 15*time.Second, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
subj, err := gitRun(root, 15*time.Second, "log", "-1", "--pretty=%s")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return typedJSON(GitCommitResult{
|
||||
Committed: true,
|
||||
Sha: strings.TrimSpace(sha),
|
||||
Subject: strings.TrimSpace(subj),
|
||||
Files: staged,
|
||||
})
|
||||
}
|
||||
|
||||
// gitToolDefs is the git family's wire schemas.
|
||||
func gitToolDefs() []ToolDef {
|
||||
return []ToolDef{
|
||||
toolSpec("git_status", "Show the working-tree status of the Rune repository — branch, ahead/behind, and every changed or untracked file — as typed JSON.",
|
||||
map[string]any{}, nil),
|
||||
toolSpec("git_diff", "Show the repository's unstaged and staged changes as typed JSON: a name-only change list, or a unified patch.",
|
||||
map[string]any{"form": map[string]any{"type": "string", "description": "\"names\" (default) or \"patch\""},
|
||||
"staged": map[string]any{"type": "boolean", "description": "true = staged only, false = unstaged only, omit = both"}},
|
||||
nil),
|
||||
toolSpec("git_log", "Show the repository's recent commits (sha, short sha, author, when, subject) as typed JSON.",
|
||||
map[string]any{"limit": map[string]any{"type": "integer", "description": "how many commits (default 10, max 100)"}},
|
||||
nil),
|
||||
toolSpec("git_branch", "List the repository's local branches and which one is current, as typed JSON.",
|
||||
map[string]any{}, nil),
|
||||
toolSpec("git_add", "Stage files for a commit in the Rune repository. Refuses the landing-law banned paths (live store state never enters the index).",
|
||||
map[string]any{"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "repo-relative or absolute file paths to stage"}},
|
||||
[]string{"paths"}),
|
||||
toolSpec("git_commit", "Commit the repository's staged changes with a required message. Refuses banned landing-law paths in the staged tree and runs the repository's pre-commit hook.",
|
||||
map[string]any{"message": map[string]any{"type": "string", "description": "the commit message"}},
|
||||
[]string{"message"}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package runecore
|
||||
|
||||
// gittools_test.go — D49's native git family, red-first. The model
|
||||
// asked for "tools to be able to connect to git projects" — these
|
||||
// tests pin each tool against a fixture repository in t.TempDir()
|
||||
// (hermetic: global/system git config null-routed), the porcelain
|
||||
// parsing, the typed JSON results, and the landing-law banned-path
|
||||
// refusals on git_add/git_commit (scripts/check_landing.py rule 4).
|
||||
// The full-loop test proves a CHAT-mode turn (D49: capability in
|
||||
// every pane) carries the git family on the wire and the typed result
|
||||
// reaches the model.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fixtureRepo builds a hermetic git repository: main branch, one
|
||||
// initial commit, identity configured locally (global config is
|
||||
// null-routed per git call).
|
||||
func fixtureRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
git("init", "-b", "main")
|
||||
git("config", "user.email", "fixture@example.com")
|
||||
git("config", "user.name", "Fixture")
|
||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("one\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git("add", "README.md")
|
||||
git("commit", "-m", "initial")
|
||||
return dir
|
||||
}
|
||||
|
||||
func decode[T any](t *testing.T, raw string) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
t.Fatalf("typed JSON did not parse: %v: %q", err, raw)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestGitStatusTool(t *testing.T) {
|
||||
dir := fixtureRepo(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := gitStatusTool(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("git_status: %v", err)
|
||||
}
|
||||
st := decode[GitStatusResult](t, raw)
|
||||
if st.Branch != "main" {
|
||||
t.Fatalf("branch = %q, want main", st.Branch)
|
||||
}
|
||||
if st.Clean {
|
||||
t.Fatalf("status reports clean with an untracked file: %s", raw)
|
||||
}
|
||||
var sawUntracked bool
|
||||
for _, e := range st.Entries {
|
||||
if e.Path == "tracked.txt" && strings.Contains(e.Code, "?") {
|
||||
sawUntracked = true
|
||||
}
|
||||
}
|
||||
if !sawUntracked {
|
||||
t.Fatalf("untracked file missing from entries: %s", raw)
|
||||
}
|
||||
|
||||
// staged change + ahead count
|
||||
if _, err := gitRun(dir, 10*time.Second, "add", "tracked.txt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gitRun(dir, 10*time.Second, "commit", "-m", "second"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err = gitStatusTool(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("git_status: %v", err)
|
||||
}
|
||||
st = decode[GitStatusResult](t, raw)
|
||||
if !st.Clean {
|
||||
t.Fatalf("status not clean after commit: %s", raw)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("changed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err = gitStatusTool(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("git_status: %v", err)
|
||||
}
|
||||
st = decode[GitStatusResult](t, raw)
|
||||
if len(st.Entries) != 1 || st.Entries[0].Path != "tracked.txt" {
|
||||
t.Fatalf("entries = %+v, want tracked.txt modified", st.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitDiffTool(t *testing.T) {
|
||||
dir := fixtureRepo(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("one\ntwo\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// unstaged: names form (default = both sections)
|
||||
raw, err := gitDiffTool(dir, "names", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("git_diff names: %v", err)
|
||||
}
|
||||
d := decode[GitDiffResult](t, raw)
|
||||
if len(d.Unstaged) != 1 || d.Unstaged[0].Path != "README.md" || d.Unstaged[0].Change != "M" {
|
||||
t.Fatalf("unstaged names = %+v", d.Unstaged)
|
||||
}
|
||||
if d.Staged != nil {
|
||||
t.Fatalf("staged section should be empty, got %+v", d.Staged)
|
||||
}
|
||||
// staged: names form, staged scope only
|
||||
if _, err := gitRun(dir, 10*time.Second, "add", "README.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("one\ntwo\nthree\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err = gitDiffTool(dir, "names", true, true)
|
||||
if err != nil {
|
||||
t.Fatalf("git_diff staged names: %v", err)
|
||||
}
|
||||
d = decode[GitDiffResult](t, raw)
|
||||
if len(d.Staged) != 1 || d.Staged[0].Path != "README.md" {
|
||||
t.Fatalf("staged names = %+v", d.Staged)
|
||||
}
|
||||
// patch form: hunk headers present, both sections labeled
|
||||
raw, err = gitDiffTool(dir, "patch", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("git_diff patch: %v", err)
|
||||
}
|
||||
d = decode[GitDiffResult](t, raw)
|
||||
if !strings.Contains(d.Patch, "@@") || !strings.Contains(d.Patch, "+three") {
|
||||
t.Fatalf("patch missing hunks: %q", d.Patch)
|
||||
}
|
||||
if !strings.Contains(d.Patch, "=== unstaged ===") || !strings.Contains(d.Patch, "=== staged ===") {
|
||||
t.Fatalf("patch sections unlabeled: %q", d.Patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLogTool(t *testing.T) {
|
||||
dir := fixtureRepo(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gitRun(dir, 10*time.Second, "add", "b.txt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gitRun(dir, 10*time.Second, "commit", "-m", "second commit"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := gitLogTool(dir, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("git_log: %v", err)
|
||||
}
|
||||
lg := decode[GitLogResult](t, raw)
|
||||
if len(lg.Commits) != 1 {
|
||||
t.Fatalf("limit=1 returned %d commits", len(lg.Commits))
|
||||
}
|
||||
if lg.Commits[0].Subject != "second commit" || len(lg.Commits[0].Sha) != 40 || lg.Commits[0].Short == "" {
|
||||
t.Fatalf("commit entry = %+v", lg.Commits[0])
|
||||
}
|
||||
raw, err = gitLogTool(dir, 0) // default limit
|
||||
if err != nil {
|
||||
t.Fatalf("git_log default: %v", err)
|
||||
}
|
||||
lg = decode[GitLogResult](t, raw)
|
||||
if len(lg.Commits) != 2 {
|
||||
t.Fatalf("default limit returned %d commits, want both", len(lg.Commits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLogToolEmptyRepo(t *testing.T) {
|
||||
// an honest empty answer for a repo with no commits — never a raw
|
||||
// git error the model cannot act on
|
||||
dir := t.TempDir()
|
||||
cmd := exec.Command("git", "-C", dir, "init", "-b", "main")
|
||||
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("init: %v: %s", err, out)
|
||||
}
|
||||
raw, err := gitLogTool(dir, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("git_log on empty repo: %v", err)
|
||||
}
|
||||
lg := decode[GitLogResult](t, raw)
|
||||
if len(lg.Commits) != 0 {
|
||||
t.Fatalf("empty repo returned %+v", lg.Commits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitBranchTool(t *testing.T) {
|
||||
dir := fixtureRepo(t)
|
||||
if _, err := gitRun(dir, 10*time.Second, "branch", "feature"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := gitBranchTool(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("git_branch: %v", err)
|
||||
}
|
||||
br := decode[GitBranchResult](t, raw)
|
||||
if br.Current != "main" {
|
||||
t.Fatalf("current = %q, want main", br.Current)
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, b := range br.Branches {
|
||||
found[b] = true
|
||||
}
|
||||
if !found["main"] || !found["feature"] {
|
||||
t.Fatalf("branches = %v, want main + feature", br.Branches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitAddAndCommitTools(t *testing.T) {
|
||||
dir := fixtureRepo(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "made.txt"), []byte("work\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := gitAddTool(dir, []string{"made.txt"})
|
||||
if err != nil {
|
||||
t.Fatalf("git_add: %v", err)
|
||||
}
|
||||
add := decode[GitAddResult](t, raw)
|
||||
if len(add.Added) != 1 || add.Added[0] != "made.txt" {
|
||||
t.Fatalf("added = %+v", add.Added)
|
||||
}
|
||||
raw, err = gitCommitTool(dir, "add made.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("git_commit: %v", err)
|
||||
}
|
||||
cm := decode[GitCommitResult](t, raw)
|
||||
if !cm.Committed || len(cm.Sha) != 40 || cm.Subject != "add made.txt" {
|
||||
t.Fatalf("commit result = %s", raw)
|
||||
}
|
||||
// the commit really landed
|
||||
raw, err = gitLogTool(dir, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lg := decode[GitLogResult](t, raw)
|
||||
if lg.Commits[0].Subject != "add made.txt" {
|
||||
t.Fatalf("log head = %+v", lg.Commits[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitAddToolRequiresPaths(t *testing.T) {
|
||||
dir := fixtureRepo(t)
|
||||
if _, err := gitAddTool(dir, nil); err == nil || !strings.Contains(err.Error(), "paths required") {
|
||||
t.Fatalf("git_add with no paths must be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCommitToolRequiresMessage(t *testing.T) {
|
||||
dir := fixtureRepo(t)
|
||||
if _, err := gitCommitTool(dir, " "); err == nil || !strings.Contains(err.Error(), "message required") {
|
||||
t.Fatalf("git_commit with blank message must be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitToolsRefuseBannedLandingPaths(t *testing.T) {
|
||||
// The landing-law banned paths (check_landing.py rule 4's
|
||||
// SWEEP_RACE_BANNED_PATHS) never enter the index or a commit —
|
||||
// live store state the sweep mutates, three 102MB accidents.
|
||||
dir := fixtureRepo(t)
|
||||
banned := filepath.Join(dir, "store", "scaffold")
|
||||
if err := os.MkdirAll(banned, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bannedFile := filepath.Join(banned, "current.json")
|
||||
if err := os.WriteFile(bannedFile, []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gitAddTool(dir, []string{"store/scaffold/current.json"}); err == nil || !strings.Contains(err.Error(), "banned landing-law path") {
|
||||
t.Fatalf("git_add of a banned path must be refused, got %v", err)
|
||||
}
|
||||
// staged through some other road, git_commit still refuses
|
||||
if _, err := gitRun(dir, 10*time.Second, "add", "store/scaffold/current.json"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gitCommitTool(dir, "smuggle the store state"); err == nil || !strings.Contains(err.Error(), "banned landing-law path") {
|
||||
t.Fatalf("git_commit with a banned staged path must be refused, got %v", err)
|
||||
}
|
||||
// and nothing was committed
|
||||
raw, err := gitStatusTool(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := decode[GitStatusResult](t, raw)
|
||||
if st.Clean {
|
||||
t.Fatal("repo reports clean — the banned file was committed or lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitToolsRefuseOversizedBlob(t *testing.T) {
|
||||
// The 20MB landing-law ceiling (check_landing.py rule 4,
|
||||
// MAX_LANDING_FILE_BYTES) is enforced at the git_add door too.
|
||||
dir := fixtureRepo(t)
|
||||
big := filepath.Join(dir, "big.bin")
|
||||
if err := os.WriteFile(big, make([]byte, 21*1024*1024), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gitAddTool(dir, []string{"big.bin"}); err == nil || !strings.Contains(err.Error(), "ceiling") {
|
||||
t.Fatalf("git_add of an oversized blob must be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitFamilyRidesTheChatWire(t *testing.T) {
|
||||
// D49 red-first: the git family is exposed to the model in CHAT
|
||||
// mode (capability in every pane) and a git_status call's typed
|
||||
// result reaches the model in the tool loop.
|
||||
t.Setenv("RUNE_MODE", "CHAT")
|
||||
t.Setenv("RUNE_REPO_ROOT", fixtureRepo(t))
|
||||
if err := os.WriteFile(filepath.Join(os.Getenv("RUNE_REPO_ROOT"), "wire.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fm := &fakeToolModel{}
|
||||
fm.script = []ChatReply{
|
||||
{Content: "", ToolCalls: []ToolCall{{Name: "git_status", Args: map[string]any{}}}},
|
||||
{Content: "there are changes", Model: "m"},
|
||||
}
|
||||
e, _ := newTestEngine(t, fm)
|
||||
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "how is the repo?"}); err != nil {
|
||||
t.Fatalf("turn: %v", err)
|
||||
}
|
||||
names := toolNames(fm.toolsets[0])
|
||||
for _, want := range []string{"git_status", "git_diff", "git_log", "git_branch", "git_add", "git_commit"} {
|
||||
if !has(names, want) {
|
||||
t.Fatalf("D49: %s must ride the CHAT wire, got: %v", want, names)
|
||||
}
|
||||
}
|
||||
var seen bool
|
||||
for _, m := range fm.payloadLog[1] {
|
||||
if m.Role == "tool" && strings.Contains(m.Content, "wire.txt") && strings.Contains(m.Content, `"success":true`) {
|
||||
seen = true
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
var dump, _ = json.Marshal(fm.payloadLog[1])
|
||||
t.Fatalf("the typed git_status result never reached the model: %s", dump)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package runecore
|
||||
// same endpoint, same envelope, stdlib HTTP.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -22,22 +23,27 @@ import (
|
||||
type OllamaClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
// streamClient carries NO whole-call Timeout: the streaming leg is
|
||||
// governed by the inter-chunk idle watch (Client.Timeout caps body
|
||||
// reads too — a legitimate long stream would be cut mid-answer).
|
||||
streamClient *http.Client
|
||||
}
|
||||
|
||||
// DefaultOllamaURL is the Python adapter's default endpoint.
|
||||
const DefaultOllamaURL = "http://localhost:11434"
|
||||
|
||||
// NewOllamaClient builds the client. Empty base URL falls back to the
|
||||
// default. The chat budget mirrors the chain's law (>=330s — the
|
||||
// 27B-class answer window plus headroom, the same number the TUI
|
||||
// completion client and the entry proxy carry).
|
||||
// NewOllamaClient builds the client. The non-streaming client's chat
|
||||
// budget mirrors the chain's law (>=330s — the 27B-class answer window
|
||||
// plus headroom, the same number the TUI completion client and the
|
||||
// entry proxy carry); the streaming client leans on the idle watch.
|
||||
func NewOllamaClient(baseURL string) *OllamaClient {
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultOllamaURL
|
||||
}
|
||||
return &OllamaClient{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
client: &http.Client{Timeout: 330 * time.Second},
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
client: &http.Client{Timeout: 330 * time.Second},
|
||||
streamClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +239,101 @@ func clipStr(s string, n int) string {
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
// ollamaStreamChunk is one NDJSON line of a streamed /api/chat: a
|
||||
// message/token chunk (content and/or thinking and/or tool_calls) or
|
||||
// the terminal done:true chunk carrying the token accounting.
|
||||
type ollamaStreamChunk struct {
|
||||
Model string `json:"model"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking *string `json:"thinking"`
|
||||
ToolCalls []ollamaToolCall `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
Done bool `json:"done"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
}
|
||||
|
||||
// StreamChatWithTools is the streaming leg (Gitea #2556): stream=true
|
||||
// /api/chat, NDJSON chunks parsed incrementally, every content and
|
||||
// thinking byte surfaced through sink AS IT ARRIVES; the final reply
|
||||
// carries the assembled content + thinking + tool_calls exactly like
|
||||
// the non-streaming leg's. The inter-chunk idle watch replaces the
|
||||
// flat deadline: no bytes for the idle budget = ErrStreamIdle; the
|
||||
// 30-minute backstop is the runaway guard.
|
||||
func (c *OllamaClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
|
||||
idle := chunkIdleTimeout()
|
||||
w := watchIdle(ctx, idle, StreamTurnBackstop)
|
||||
defer w.stop()
|
||||
body, err := json.Marshal(ollamaChatRequest{Model: model, Messages: msgs, Stream: true, Tools: tools})
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(w.ctx, http.MethodPost, c.baseURL+"/api/chat", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.streamClient.Do(req)
|
||||
if err != nil {
|
||||
return ChatReply{}, w.err(fmt.Errorf("ollama: %w", err), idle)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return ChatReply{}, fmt.Errorf("ollama /api/chat: HTTP %d: %s", resp.StatusCode, clipStr(string(raw), 300))
|
||||
}
|
||||
var content, thinking strings.Builder
|
||||
var toolCalls []ToolCall
|
||||
var last ollamaStreamChunk
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
w.reset(idle)
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var chunk ollamaStreamChunk
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
return ChatReply{}, fmt.Errorf("ollama /api/chat stream decode: %w", err)
|
||||
}
|
||||
last = chunk
|
||||
if chunk.Message.Content != "" {
|
||||
content.WriteString(chunk.Message.Content)
|
||||
emit(sink, StreamDelta{Kind: "text", Text: chunk.Message.Content})
|
||||
}
|
||||
if chunk.Message.Thinking != nil && *chunk.Message.Thinking != "" {
|
||||
thinking.WriteString(*chunk.Message.Thinking)
|
||||
emit(sink, StreamDelta{Kind: "thinking", Text: *chunk.Message.Thinking})
|
||||
}
|
||||
for _, tc := range chunk.Message.ToolCalls {
|
||||
call := ToolCall{Name: tc.Function.Name}
|
||||
if len(tc.Function.Arguments) > 0 {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(tc.Function.Arguments, &m) == nil {
|
||||
call.Args = m
|
||||
}
|
||||
}
|
||||
toolCalls = append(toolCalls, call)
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return ChatReply{}, w.err(fmt.Errorf("ollama stream read: %w", err), idle)
|
||||
}
|
||||
visible, inline := splitInlineThinking(content.String())
|
||||
reply := ChatReply{Content: visible, Model: last.Model, ContextUsed: last.PromptEvalCount}
|
||||
reply.ToolCalls = toolCalls
|
||||
reply.ContextLimit = c.contextLimit(ctx, model)
|
||||
if thinking.Len() > 0 {
|
||||
reply.Thinking = []string{thinking.String()}
|
||||
} else if inline != "" {
|
||||
reply.Thinking = []string{inline}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// showCapabilities asks /api/show for the model's capabilities list
|
||||
// ("tools", "thinking", ...). Fail-soft: error → nil.
|
||||
func (c *OllamaClient) showCapabilities(ctx context.Context, model string) ([]string, error) {
|
||||
|
||||
@@ -217,6 +217,11 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
channel = "task" // the operator default; the core never invents a channel
|
||||
}
|
||||
|
||||
if req.Stream {
|
||||
s.streamCompletion(w, r, req.Model, channel, userText)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.engine.Turn(r.Context(), TurnRequest{
|
||||
Channel: channel, UserText: userText, Model: req.Model,
|
||||
})
|
||||
@@ -255,10 +260,6 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id := newCompletionID()
|
||||
created := time.Now().UTC().Unix()
|
||||
if req.Stream {
|
||||
s.writeChunks(w, id, created, res.Model, assistantText, runeBlk)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
@@ -276,39 +277,105 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// writeChunks answers stream=true the Python core's framing: a content
|
||||
// chunk, then the terminal chunk carrying finish_reason and the rune
|
||||
// block (the front door flushes each line).
|
||||
func (s *Server) writeChunks(w http.ResponseWriter, id string, created int64, model, content string, runeBlk map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
contentChunk := map[string]any{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []any{map[string]any{
|
||||
"index": 0,
|
||||
"delta": map[string]any{"role": "assistant", "content": content},
|
||||
"finish_reason": nil,
|
||||
}},
|
||||
// streamCompletion answers stream=true the Python core's framing with
|
||||
// the model's bytes relayed AS THEY ARRIVE (Gitea #2556 — the turn
|
||||
// never sits inside one blocking call server-side): a content chunk
|
||||
// per streamed text delta, then the terminal chunk carrying
|
||||
// finish_reason and the rune block. An error before the first byte
|
||||
// answers the honest error envelope; an error mid-stream answers an
|
||||
// error terminal chunk (success=false — the stream shape stays valid).
|
||||
func (s *Server) streamCompletion(w http.ResponseWriter, r *http.Request, model, channel, userText string) {
|
||||
id := newCompletionID()
|
||||
created := time.Now().UTC().Unix()
|
||||
header := false
|
||||
deltas := false
|
||||
writeHeader := func() {
|
||||
if !header {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
header = true
|
||||
}
|
||||
}
|
||||
terminal := map[string]any{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []any{map[string]any{
|
||||
"index": 0, "delta": map[string]any{}, "finish_reason": "stop",
|
||||
}},
|
||||
"rune": runeBlk,
|
||||
}
|
||||
for _, chunk := range []map[string]any{contentChunk, terminal} {
|
||||
writeChunk := func(chunk map[string]any) bool {
|
||||
line, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if _, err := w.Write(append(line, '\n')); err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
chunkFor := func(delta string) map[string]any {
|
||||
return map[string]any{
|
||||
"id": id, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": []any{map[string]any{
|
||||
"index": 0,
|
||||
"delta": map[string]any{"role": "assistant", "content": delta},
|
||||
"finish_reason": nil,
|
||||
}},
|
||||
}
|
||||
}
|
||||
sink := func(d StreamDelta) {
|
||||
if d.Kind != "text" || d.Text == "" {
|
||||
return
|
||||
}
|
||||
deltas = true
|
||||
writeHeader()
|
||||
writeChunk(chunkFor(d.Text))
|
||||
}
|
||||
res, err := s.engine.TurnStream(r.Context(), TurnRequest{
|
||||
Channel: channel, UserText: userText, Model: model,
|
||||
}, sink)
|
||||
if res.ContextLimit > 0 {
|
||||
s.setContext(res.ContextUsed, res.ContextLimit)
|
||||
}
|
||||
runeBlk := map[string]any{
|
||||
"success": err == nil,
|
||||
"user_text": res.AssistantText,
|
||||
"model": res.Model,
|
||||
"error": "",
|
||||
"channel": channel,
|
||||
"thinking": res.Thinking,
|
||||
"messages": map[string]string{"user": res.UserMsgID, "assistant": res.AssistantMsgID},
|
||||
"core": "go",
|
||||
}
|
||||
if err != nil {
|
||||
runeBlk["error"] = clipStr(err.Error(), 300)
|
||||
}
|
||||
if err != nil && !header {
|
||||
var pe *ProviderError
|
||||
switch {
|
||||
case errors.Is(err, ErrChannelBusy):
|
||||
errorEnvelope(w, http.StatusConflict, "channel_busy", err.Error())
|
||||
case errors.Is(err, ErrNoProvider):
|
||||
errorEnvelope(w, http.StatusServiceUnavailable, "no_provider", err.Error())
|
||||
case errors.Is(err, ErrProviderUnrouted):
|
||||
errorEnvelope(w, http.StatusServiceUnavailable, "provider_unrouted", clipStr(err.Error(), 300))
|
||||
case errors.Is(err, ErrProviderKeyMissing):
|
||||
errorEnvelope(w, http.StatusServiceUnavailable, "provider_key_missing", clipStr(err.Error(), 300))
|
||||
case errors.As(err, &pe):
|
||||
errorEnvelope(w, http.StatusBadGateway, "provider_error", clipStr(err.Error(), 300))
|
||||
default:
|
||||
errorEnvelope(w, http.StatusInternalServerError, "internal_error", clipStr(err.Error(), 200))
|
||||
}
|
||||
return
|
||||
}
|
||||
writeHeader()
|
||||
// the fallback law: a client that couldn't stream (or a non-streaming
|
||||
// provider leg) still answers the full assembled content in ONE
|
||||
// chunk — the classic framing, nothing dropped.
|
||||
if !deltas {
|
||||
writeChunk(chunkFor(res.AssistantText))
|
||||
}
|
||||
terminal := chunkFor("")
|
||||
terminal["choices"].([]any)[0].(map[string]any)["delta"] = map[string]any{}
|
||||
terminal["choices"].([]any)[0].(map[string]any)["finish_reason"] = "stop"
|
||||
terminal["rune"] = runeBlk
|
||||
writeChunk(terminal)
|
||||
}
|
||||
|
||||
// setContext records the last turn's context accounting (D44 readout).
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package runecore
|
||||
|
||||
// stream.go — the streaming root-cause wave (Gitea #2556, owner
|
||||
// verdict: the flat whole-turn budgets patched the symptom; the root
|
||||
// cause is ONE long-blocking HTTP call per model turn). A streamed
|
||||
// turn never hits a deadline because bytes arrive continuously: the
|
||||
// deadline collapses to an INTER-CHUNK IDLE TIMEOUT (no bytes for X
|
||||
// seconds = genuinely dead) plus a generous overall backstop. The
|
||||
// 330s/480s/6x480s budget constants remain only for the non-streaming
|
||||
// fallback paths (clients that do not stream, the cap-out
|
||||
// withdrawn-tools call, and legacy callers).
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultChunkIdleTimeout is the inter-chunk idle budget on every
|
||||
// streamed model call: no bytes for this long = the model is
|
||||
// genuinely dead (the old deadline's honest replacement).
|
||||
const DefaultChunkIdleTimeout = 120 * time.Second
|
||||
|
||||
// StreamTurnBackstop is the overall cap on one streamed turn — sized
|
||||
// generously (30 minutes) so only a runaway chain trips it; the idle
|
||||
// timeout is the real guardian.
|
||||
const StreamTurnBackstop = 30 * time.Minute
|
||||
|
||||
// ChunkIdleTimeoutEnv is the env knob that resizes the inter-chunk
|
||||
// idle budget (seconds; an unparsable or non-positive value keeps the
|
||||
// default — the knob never disables the guard).
|
||||
const ChunkIdleTimeoutEnv = "RUNE_STREAM_IDLE_TIMEOUT"
|
||||
|
||||
// ErrStreamIdle is the typed dead-model refusal: the stream produced
|
||||
// no bytes for the whole idle budget.
|
||||
var ErrStreamIdle = errors.New("stream idle: no model bytes within the inter-chunk idle budget")
|
||||
|
||||
// chunkIdleTimeout resolves the inter-chunk idle budget: the env knob
|
||||
// wins when it parses to a positive number of seconds; the default
|
||||
// otherwise.
|
||||
func chunkIdleTimeout() time.Duration {
|
||||
if v := strings.TrimSpace(os.Getenv(ChunkIdleTimeoutEnv)); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
return DefaultChunkIdleTimeout
|
||||
}
|
||||
|
||||
// idleWatch enforces the inter-chunk idle timeout on one streamed
|
||||
// call. The client calls reset() after EVERY chunk read; the watch
|
||||
// cancels the derived ctx when the budget lapses. The error surfaces
|
||||
// through the ctx as ErrStreamIdle (not a bare context.Canceled), so
|
||||
// the honest dead-model reason reaches the caller.
|
||||
type idleWatch struct {
|
||||
ctx context.Context
|
||||
timer *time.Timer
|
||||
fired bool
|
||||
cancelAll context.CancelFunc
|
||||
}
|
||||
|
||||
// watchIdle derives an idle-enforced ctx from the caller's — the same
|
||||
// cancel context carries the backstop deadline; the idle fire is
|
||||
// distinguished by the fired flag (the honest dead-model error).
|
||||
func watchIdle(parent context.Context, idle, backstop time.Duration) *idleWatch {
|
||||
ctx, cancelAll := context.WithTimeout(parent, backstop)
|
||||
w := &idleWatch{ctx: ctx, cancelAll: cancelAll}
|
||||
w.timer = time.AfterFunc(idle, func() {
|
||||
w.fired = true
|
||||
cancelAll()
|
||||
})
|
||||
return w
|
||||
}
|
||||
|
||||
// reset arms the idle budget afresh — called after every chunk.
|
||||
func (w *idleWatch) reset(idle time.Duration) {
|
||||
w.timer.Reset(idle)
|
||||
}
|
||||
|
||||
// stop disarms the watch (completion or error) and releases the
|
||||
// context.
|
||||
func (w *idleWatch) stop() {
|
||||
w.timer.Stop()
|
||||
w.cancelAll()
|
||||
}
|
||||
|
||||
// err maps a stream-read failure onto the honest typed error: the
|
||||
// idle fire wins (dead model), the backstop's deadline reads as
|
||||
// itself, everything else passes through.
|
||||
func (w *idleWatch) err(readErr error, idle time.Duration) error {
|
||||
if w.fired {
|
||||
return fmt.Errorf("%w (%s)", ErrStreamIdle, idle)
|
||||
}
|
||||
if readErr != nil {
|
||||
if errors.Is(readErr, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("stream turn backstop exceeded (%s)", StreamTurnBackstop)
|
||||
}
|
||||
return readErr
|
||||
}
|
||||
return w.ctx.Err()
|
||||
}
|
||||
|
||||
// StreamDelta is one incremental model byte: visible answer text
|
||||
// ("text") or surfaced reasoning ("thinking").
|
||||
type StreamDelta struct {
|
||||
Kind string // "text" | "thinking"
|
||||
Text string
|
||||
}
|
||||
|
||||
// StreamSink consumes deltas as they arrive (never after the call
|
||||
// returns; nil-safe call sites).
|
||||
type StreamSink func(StreamDelta)
|
||||
|
||||
// StreamChatClient is the streaming leg of the model boundary: the
|
||||
// same ChatWithTools semantics (the engine's bounded tool loop is
|
||||
// unchanged) with the answer's bytes surfaced through sink as they
|
||||
// arrive. The final ChatReply carries the assembled content, thinking
|
||||
// and tool_calls exactly as the non-streaming leg's does. The client
|
||||
// owns the idle enforcement (it reads the wire); every implementation
|
||||
// derives its read ctx through watchIdle.
|
||||
type StreamChatClient interface {
|
||||
ToolChatClient
|
||||
StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error)
|
||||
}
|
||||
|
||||
// emit is the nil-safe sink call.
|
||||
func emit(sink StreamSink, d StreamDelta) {
|
||||
if sink != nil {
|
||||
sink(d)
|
||||
}
|
||||
}
|
||||
|
||||
// scanSSE reads an SSE body line-wise: every "data:" line's payload
|
||||
// goes to fn; "data: [DONE]" terminates (per the [OI] SSE framing).
|
||||
// Comment lines (":" prefix) and empty lines are skipped. The caller's
|
||||
// read failures pass through (the idle watch maps them).
|
||||
func scanSSE(body *bufio.Reader, fn func(data string) error) error {
|
||||
for {
|
||||
line, err := body.ReadString('\n')
|
||||
trimmed := strings.TrimRight(line, "\r\n")
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, ":") {
|
||||
if payload, ok := strings.CutPrefix(trimmed, "data: "); ok {
|
||||
if strings.TrimSpace(payload) == "[DONE]" {
|
||||
return nil
|
||||
}
|
||||
if err := fn(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if payload, ok := strings.CutPrefix(trimmed, "data:"); ok && strings.TrimSpace(payload) == "[DONE]" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package runecore
|
||||
|
||||
// stream_test.go — the streaming root-cause wave's frozen proofs
|
||||
// (Gitea #2556, red-first): the Ollama NDJSON stream parses
|
||||
// incrementally and answers the assembled reply; the INTER-CHUNK IDLE
|
||||
// TIMEOUT replaces the flat deadline (a slow model that keeps sending
|
||||
// bytes finishes no matter how long it takes; a dead model trips the
|
||||
// idle error); the engine's bounded tool loop runs over streamed
|
||||
// replies; the server relays the streamed turn chunk-by-chunk.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeStreamClient is a StreamChatClient for the engine loop proofs.
|
||||
type fakeStreamClient struct {
|
||||
calls int
|
||||
scripts []struct {
|
||||
reply ChatReply
|
||||
deltas []StreamDelta
|
||||
}
|
||||
thinks bool
|
||||
sunk []StreamDelta
|
||||
}
|
||||
|
||||
func (f *fakeStreamClient) add(reply ChatReply, deltas ...StreamDelta) {
|
||||
f.scripts = append(f.scripts, struct {
|
||||
reply ChatReply
|
||||
deltas []StreamDelta
|
||||
}{reply, deltas})
|
||||
}
|
||||
|
||||
func (f *fakeStreamClient) SupportsTools(context.Context, string) bool { return f.thinks }
|
||||
|
||||
func (f *fakeStreamClient) ChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef) (ChatReply, error) {
|
||||
return f.Chat(ctx, model, msgs)
|
||||
}
|
||||
|
||||
func (f *fakeStreamClient) Chat(ctx context.Context, model string, msgs []ChatMessage) (ChatReply, error) {
|
||||
f.calls++
|
||||
return f.script().reply, nil
|
||||
}
|
||||
|
||||
func (f *fakeStreamClient) script() (out struct {
|
||||
reply ChatReply
|
||||
deltas []StreamDelta
|
||||
}) {
|
||||
i := f.calls - 1
|
||||
if i >= len(f.scripts) {
|
||||
i = len(f.scripts) - 1
|
||||
}
|
||||
return f.scripts[i]
|
||||
}
|
||||
|
||||
func (f *fakeStreamClient) StreamChatWithTools(ctx context.Context, model string, msgs []ChatMessage, tools []ToolDef, sink StreamSink) (ChatReply, error) {
|
||||
f.calls++
|
||||
s := f.script()
|
||||
for _, d := range s.deltas {
|
||||
f.sunk = append(f.sunk, d)
|
||||
emit(sink, d)
|
||||
}
|
||||
return s.reply, nil
|
||||
}
|
||||
|
||||
func (f *fakeStreamClient) Tags(context.Context) ([]ModelInfo, error) { return nil, nil }
|
||||
|
||||
// ollamaStreamServer is a hermetic /api/chat double: lines written on
|
||||
// schedule (delay after each), the final done:true chunk closing.
|
||||
func ollamaStreamServer(t *testing.T, lines []string, perLine time.Duration) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/api/show") {
|
||||
_, _ = w.Write([]byte(`{"capabilities":["tools","thinking"]}`))
|
||||
return
|
||||
}
|
||||
var req ollamaChatRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if !req.Stream {
|
||||
t.Errorf("ollama stream double: request did not carry stream=true")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
for i, line := range lines {
|
||||
_, _ = w.Write([]byte(line + "\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
if i < len(lines)-1 && perLine > 0 {
|
||||
time.Sleep(perLine)
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func ollamaChunk(content string) string {
|
||||
return `{"model":"q","message":{"role":"assistant","content":` + jsonString(content) + `},"done":false}`
|
||||
}
|
||||
|
||||
// TestOllamaStreamAssemblesReply — the NDJSON chunks parse
|
||||
// incrementally; every text byte surfaces through the sink in wire
|
||||
// order; the final reply carries the assembled content + thinking and
|
||||
// the token accounting from the done chunk.
|
||||
func TestOllamaStreamAssemblesReply(t *testing.T) {
|
||||
srv := ollamaStreamServer(t, []string{
|
||||
ollamaChunk("hel"),
|
||||
ollamaChunk("lo "),
|
||||
`{"model":"q","message":{"role":"assistant","thinking":"pondering"},"done":false}`,
|
||||
ollamaChunk("world"),
|
||||
`{"model":"q","message":{"role":"assistant"},"done":true,"prompt_eval_count":42,"eval_count":7}`,
|
||||
}, 0)
|
||||
c := NewOllamaClient(srv.URL)
|
||||
var got []StreamDelta
|
||||
reply, err := c.StreamChatWithTools(context.Background(), "q", []ChatMessage{{Role: "user", Content: "hi"}}, nil, func(d StreamDelta) { got = append(got, d) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply.Content != "hello world" {
|
||||
t.Fatalf("content = %q", reply.Content)
|
||||
}
|
||||
if len(reply.Thinking) != 1 || reply.Thinking[0] != "pondering" {
|
||||
t.Fatalf("thinking = %v", reply.Thinking)
|
||||
}
|
||||
if reply.ContextUsed != 42 {
|
||||
t.Fatalf("context used = %d", reply.ContextUsed)
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, d := range got {
|
||||
if d.Kind != "text" {
|
||||
continue
|
||||
}
|
||||
text.WriteString(d.Text)
|
||||
}
|
||||
if text.String() != "hello world" {
|
||||
t.Fatalf("deltas = %q", text.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOllamaStreamToolCalls — tool_calls riding the stream parse into
|
||||
// the reply (the loop's wire shape, streamed).
|
||||
func TestOllamaStreamToolCalls(t *testing.T) {
|
||||
srv := ollamaStreamServer(t, []string{
|
||||
`{"model":"q","message":{"role":"assistant","tool_calls":[{"function":{"name":"read_file","arguments":{"path":"x"}}}]},"done":false}`,
|
||||
`{"model":"q","message":{"role":"assistant"},"done":true}`,
|
||||
}, 0)
|
||||
c := NewOllamaClient(srv.URL)
|
||||
reply, err := c.StreamChatWithTools(context.Background(), "q", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(reply.ToolCalls) != 1 || reply.ToolCalls[0].Name != "read_file" {
|
||||
t.Fatalf("tool calls = %+v", reply.ToolCalls)
|
||||
}
|
||||
if reply.ToolCalls[0].Args["path"] != "x" {
|
||||
t.Fatalf("args = %+v", reply.ToolCalls[0].Args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOllamaStreamSlowModelFinishes — the root-cause proof: a model
|
||||
// that emits a chunk every 200ms across a window LONGER than the old
|
||||
// flat per-call budgets would ever allow (scaled 1:25 from the live
|
||||
// 5s-for-10-minutes shape; the mechanism is identical — bytes keep
|
||||
// arriving, the turn completes, no deadline fires).
|
||||
func TestOllamaStreamSlowModelFinishes(t *testing.T) {
|
||||
t.Setenv(ChunkIdleTimeoutEnv, "1") // 1s idle budget; chunks every 200ms
|
||||
var lines []string
|
||||
for i := 0; i < 12; i++ {
|
||||
lines = append(lines, ollamaChunk(fmt.Sprintf("chunk%d ", i)))
|
||||
}
|
||||
lines = append(lines, `{"model":"q","message":{"role":"assistant"},"done":true}`)
|
||||
srv := ollamaStreamServer(t, lines, 200*time.Millisecond)
|
||||
defer srv.Close()
|
||||
c := NewOllamaClient(srv.URL)
|
||||
reply, err := c.StreamChatWithTools(context.Background(), "q", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("slow streamed turn failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(reply.Content, "chunk11") {
|
||||
t.Fatalf("content = %q", reply.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOllamaStreamIdleTimeout — the dead model: bytes stop; the idle
|
||||
// budget lapses; the typed ErrStreamIdle fires (never a hang).
|
||||
func TestOllamaStreamIdleTimeout(t *testing.T) {
|
||||
t.Setenv(ChunkIdleTimeoutEnv, "1")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(ollamaChunk("first") + "\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
<-r.Context().Done() // then silence forever
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewOllamaClient(srv.URL)
|
||||
start := time.Now()
|
||||
_, err := c.StreamChatWithTools(context.Background(), "q", []ChatMessage{{Role: "user", Content: "hi"}}, nil, nil)
|
||||
if !errors.Is(err, ErrStreamIdle) {
|
||||
t.Fatalf("err = %v, want ErrStreamIdle", err)
|
||||
}
|
||||
if time.Since(start) > 5*time.Second {
|
||||
t.Fatalf("idle timeout took %v — not the 1s budget", time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineStreamedToolLoop — the bounded tool loop runs over
|
||||
// streamed replies: call 1 asks for a tool, call 2 answers in text;
|
||||
// the loop's iteration discipline is unchanged and the deltas flowed.
|
||||
func TestEngineStreamedToolLoop(t *testing.T) {
|
||||
fc := &fakeStreamClient{thinks: true}
|
||||
fc.add(ChatReply{Content: "", ToolCalls: []ToolCall{{Name: "list_directory", Args: map[string]any{"path": "."}}}},
|
||||
StreamDelta{Kind: "text", Text: "thinking..."})
|
||||
fc.add(ChatReply{Content: "done answering"},
|
||||
StreamDelta{Kind: "text", Text: "done answering"})
|
||||
e, _ := newTestEngine(t, fc)
|
||||
var got []StreamDelta
|
||||
res, err := e.TurnStream(context.Background(), TurnRequest{Channel: "chat", UserText: "list files"}, func(d StreamDelta) { got = append(got, d) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.AssistantText != "done answering" {
|
||||
t.Fatalf("answer = %q", res.AssistantText)
|
||||
}
|
||||
if fc.calls != 2 {
|
||||
t.Fatalf("model calls = %d, want the loop's 2", fc.calls)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("deltas = %d, want both legs streamed", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerStreamsChunksLive — stream=true relays the model's bytes
|
||||
// AS THEY ARRIVE: the first chunk lands while the model is still
|
||||
// emitting (never one blocking call server-side).
|
||||
func TestServerStreamsChunksLive(t *testing.T) {
|
||||
t.Setenv(ChunkIdleTimeoutEnv, "5")
|
||||
srv := ollamaStreamServer(t, []string{
|
||||
ollamaChunk("live "),
|
||||
ollamaChunk("answer"),
|
||||
`{"model":"q","message":{"role":"assistant"},"done":true}`,
|
||||
}, 300*time.Millisecond)
|
||||
defer srv.Close()
|
||||
oc := NewOllamaClient(srv.URL)
|
||||
e, _ := newTestEngine(t, oc)
|
||||
core := httptest.NewServer(NewServer(e, oc, e.home).Handler())
|
||||
defer core.Close()
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"channel": "chat", "stream": true,
|
||||
"messages": []map[string]any{{"role": "user", "content": "hi"}},
|
||||
})
|
||||
resp, err := http.Post(core.URL+"/v1/chat/completions", "application/json", strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
start := time.Now()
|
||||
buf := make([]byte, 4096)
|
||||
var got strings.Builder
|
||||
firstAt := time.Time{}
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
got.Write(buf[:n])
|
||||
if firstAt.IsZero() && strings.Contains(got.String(), "live") {
|
||||
firstAt = time.Now()
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstAt.IsZero() {
|
||||
t.Fatalf("no chunk arrived incrementally: %q", got.String())
|
||||
}
|
||||
if elapsed := firstAt.Sub(start); elapsed > 200*time.Millisecond {
|
||||
t.Fatalf("first chunk at %v — the core buffered the stream", elapsed)
|
||||
}
|
||||
if !strings.Contains(got.String(), `"finish_reason":"stop"`) {
|
||||
t.Fatalf("terminal chunk missing: %q", got.String())
|
||||
}
|
||||
if !strings.Contains(got.String(), `"success":true`) {
|
||||
t.Fatalf("rune block missing: %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerStreamErrorBeforeFirstByte — a refusal (no provider
|
||||
// record) on a streamed request answers the honest error envelope,
|
||||
// never a half-open stream.
|
||||
func TestServerStreamErrorBeforeFirstByte(t *testing.T) {
|
||||
oc := NewOllamaClient("http://127.0.0.1:1") // dead local wire
|
||||
e, _ := newTestEngine(t, oc)
|
||||
core := httptest.NewServer(NewServer(e, oc, e.home).Handler())
|
||||
defer core.Close()
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"channel": "chat", "stream": true,
|
||||
"messages": []map[string]any{{"role": "user", "content": "hi"}},
|
||||
})
|
||||
resp, err := http.Post(core.URL+"/v1/chat/completions", "application/json", strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), `"error"`) {
|
||||
t.Fatalf("want the error envelope, got %q", string(body))
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
package runecore
|
||||
|
||||
// tools.go — THE TOOLSET GAP (D45 item 5): the Go core's chat path
|
||||
// surfaces the mode-allowed tool suite to the model and executes the
|
||||
// model's tool calls mode-gated. Oracle: the Python operator path
|
||||
// (rune_operator.py _capability_selection → local_tool_runtime.py,
|
||||
// the T2/T3 wave) — the ONE frozen matrix artifact
|
||||
// schemas/mode/v1/mode_matrix.json is the authority: CHAT/PLAN expose
|
||||
// the read family, TASK adds the write family. The dispatch gate stays
|
||||
// the security boundary: every call re-reads the active mode and a
|
||||
// mode-disallowed tool executes as a typed tool error, never bytes.
|
||||
// tools.go — the tool suite the wire carries and the executor runs.
|
||||
// D45 landed the mode-gated toolset; D49 (owner ruling 2026-10-22,
|
||||
// "capability in every pane") SUPERSEDES the mode gate: capability is
|
||||
// inherent in EVERY pane — CHAT/PLAN/TASK all expose the read family,
|
||||
// the write family AND the native git family, because the model must
|
||||
// KNOW it can read, write and shell without asking (the owner: "i
|
||||
// want that ability to be in every pane"). The consent card remains
|
||||
// the WORK-PROMOTION mechanism (work shows in the tasks pane) — it is
|
||||
// organizational, never a permission gate. The real safety layer is
|
||||
// the scaffolding authority + contract gates at mutation doors (the
|
||||
// landing law). ToolsetFor keeps the per-mode switch (future modes
|
||||
// may still narrow) and ExecuteGatedTool keeps the dispatch-gate seam
|
||||
// (future policies hook there; the refusal stays for
|
||||
// genuinely-unavailable tools) — but no mode withholds the suite
|
||||
// today. schemas/mode/v1/mode_matrix.json's CHAT=read-only pin is
|
||||
// retired by this ruling.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -56,10 +63,12 @@ func toolSpec(name, desc string, props map[string]any, required []string) ToolDe
|
||||
}}
|
||||
}
|
||||
|
||||
// ToolsetFor returns the mode's tool schemas — the frozen matrix's
|
||||
// executor_tools ∩ the Go core's implemented ops. CHAT/PLAN: the read
|
||||
// family. TASK: + write_file, edit_file, run_command. Unknown mode →
|
||||
// empty (fail closed; the turn runs toolless rather than ungated).
|
||||
// ToolsetFor returns the mode's tool schemas. D49: every known mode
|
||||
// carries the FULL suite — read family + write family + git family —
|
||||
// because capability is inherent in every pane (the owner's ruling;
|
||||
// the wire must tell the model it can act, not hide the tools and
|
||||
// make the model beg for shell access). Unknown mode → empty (fail
|
||||
// closed; the turn runs toolless rather than ungated).
|
||||
func ToolsetFor(mode string) []ToolDef {
|
||||
read := []ToolDef{
|
||||
toolSpec("list_directory", "List the entries of a directory on the user's machine.",
|
||||
@@ -81,25 +90,25 @@ func ToolsetFor(mode string) []ToolDef {
|
||||
"include": map[string]any{"type": "string", "description": "filename glob filter"}},
|
||||
[]string{"pattern"}),
|
||||
}
|
||||
write := []ToolDef{
|
||||
toolSpec("write_file", "Create or overwrite a file on the user's machine. Available in EVERY pane and mode — no elevation needed; sustained work-shaped activity gets promoted to the tasks pane.",
|
||||
map[string]any{"path": map[string]any{"type": "string"},
|
||||
"content": map[string]any{"type": "string"}},
|
||||
[]string{"path", "content"}),
|
||||
toolSpec("edit_file", "Replace the first occurrence of a search string in a file. Available in EVERY pane and mode — no elevation needed.",
|
||||
map[string]any{"path": map[string]any{"type": "string"},
|
||||
"search": map[string]any{"type": "string"},
|
||||
"replace": map[string]any{"type": "string"}},
|
||||
[]string{"path", "search", "replace"}),
|
||||
toolSpec("run_command", "Run a shell command on the user's machine (bash, 60-second timeout). Available in EVERY pane and mode — shell access is inherent; sustained work gets promoted to the tasks pane.",
|
||||
map[string]any{"command": map[string]any{"type": "string"},
|
||||
"workdir": map[string]any{"type": "string", "description": "working directory"}},
|
||||
[]string{"command"}),
|
||||
}
|
||||
full := append(append(read, write...), gitToolDefs()...)
|
||||
switch mode {
|
||||
case "TASK":
|
||||
return append(read,
|
||||
toolSpec("write_file", "Create or overwrite a file on the user's machine.",
|
||||
map[string]any{"path": map[string]any{"type": "string"},
|
||||
"content": map[string]any{"type": "string"}},
|
||||
[]string{"path", "content"}),
|
||||
toolSpec("edit_file", "Replace the first occurrence of a search string in a file.",
|
||||
map[string]any{"path": map[string]any{"type": "string"},
|
||||
"search": map[string]any{"type": "string"},
|
||||
"replace": map[string]any{"type": "string"}},
|
||||
[]string{"path", "search", "replace"}),
|
||||
toolSpec("run_command", "Run a shell command on the user's machine.",
|
||||
map[string]any{"command": map[string]any{"type": "string"},
|
||||
"workdir": map[string]any{"type": "string", "description": "working directory"}},
|
||||
[]string{"command"}),
|
||||
)
|
||||
case "CHAT", "PLAN":
|
||||
return read
|
||||
case "TASK", "CHAT", "PLAN":
|
||||
return full
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -153,11 +162,14 @@ type toolEnvelope struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ExecuteGatedTool executes ONE model-requested tool against the mode
|
||||
// gate read FRESH at dispatch (the T3 two-door law: seeing is a
|
||||
// prompt, the gate is the boundary). A mode-disallowed call returns a
|
||||
// typed refusal envelope — tool-error data the model can read, never
|
||||
// an execution.
|
||||
// ExecuteGatedTool executes ONE model-requested tool, checking the
|
||||
// mode's suite FRESH at dispatch (the T3 two-door law: seeing is a
|
||||
// prompt, the gate is the boundary). D49: no mode withholds the write
|
||||
// family or the git family — writes execute in any mode; the consent
|
||||
// card promotes work (organization), it never gates tools. The
|
||||
// dispatch-gate SEAM stays (future policies hook here): a tool
|
||||
// genuinely outside the mode's suite returns a typed refusal envelope
|
||||
// — tool-error data the model can read, never an execution.
|
||||
func ExecuteGatedTool(home, mode, name string, args map[string]any) map[string]any {
|
||||
if mode == "" {
|
||||
mode = ActiveMode(home)
|
||||
@@ -193,9 +205,28 @@ func argInt(args map[string]any, key string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func argBool(args map[string]any, key string) (val, ok bool) {
|
||||
v, ok := args[key].(bool)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func argStrings(args map[string]any, key string) []string {
|
||||
raw, ok := args[key].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// executeGoTool runs the tool in-process (stdlib only — the Go core's
|
||||
// zero-dep split law). Paths are home-expanded; every op is read-only
|
||||
// except the TASK-only write family.
|
||||
// zero-dep split law). Paths are home-expanded. The git family shells
|
||||
// out to git in DefaultRepoRoot() (gittools.go).
|
||||
func executeGoTool(name string, args map[string]any) map[string]any {
|
||||
env := func(res string, err error) map[string]any {
|
||||
if err != nil {
|
||||
@@ -220,6 +251,23 @@ func executeGoTool(name string, args map[string]any) map[string]any {
|
||||
return env(editFileTool(argStr(args, "path", ""), argStr(args, "search", ""), argStr(args, "replace", "")))
|
||||
case "run_command":
|
||||
return env(runCommandTool(argStr(args, "command", ""), argStr(args, "workdir", "")))
|
||||
case "git_status":
|
||||
return env(gitStatusTool(DefaultRepoRoot()))
|
||||
case "git_diff":
|
||||
staged, hasStaged := argBool(args, "staged")
|
||||
return env(gitDiffTool(DefaultRepoRoot(), argStr(args, "form", "names"), hasStaged, staged))
|
||||
case "git_log":
|
||||
limit := argInt(args, "limit")
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
return env(gitLogTool(DefaultRepoRoot(), limit))
|
||||
case "git_branch":
|
||||
return env(gitBranchTool(DefaultRepoRoot()))
|
||||
case "git_add":
|
||||
return env(gitAddTool(DefaultRepoRoot(), argStrings(args, "paths")))
|
||||
case "git_commit":
|
||||
return env(gitCommitTool(DefaultRepoRoot(), argStr(args, "message", "")))
|
||||
default:
|
||||
return map[string]any{"success": false, "error": fmt.Sprintf("unknown tool %q", name)}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package runecore
|
||||
|
||||
// tools_test.go — THE TOOLSET GAP (D45 item 5, red-first): the Go core
|
||||
// (the default live core, ensure_core.py's supervision target) must
|
||||
// surface the mode-allowed tool suite to the model on the chat path
|
||||
// and execute the model's tool calls mode-gated. The Python operator
|
||||
// path (local_tool_runtime.py) landed this (T2/T3); the engine (D39)
|
||||
// predates it and called the model exactly once with no tools — the
|
||||
// owner's live defect ("I cannot directly access your desktop").
|
||||
// tools_test.go — the toolset tests. D45 landed the mode-gated suite;
|
||||
// D49 (owner ruling 2026-10-22, "capability in every pane") SUPERSEDES
|
||||
// the mode gate: the write family and the git family are inherent in
|
||||
// CHAT/PLAN/TASK alike — the consent card promotes work to the tasks
|
||||
// pane (organization), it never gates tools (permission). The dispatch
|
||||
// gate stays as the seam for genuinely-unavailable tools.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -67,9 +66,11 @@ func has(names []string, want string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestChatTurnExposesReadToolsInChatMode(t *testing.T) {
|
||||
// D45: a CHAT-mode turn's model must SEE the read tools
|
||||
// (list_directory/read_file at minimum) and never the write family.
|
||||
func TestChatTurnExposesFullSuiteInChatMode(t *testing.T) {
|
||||
// D49: a CHAT-mode turn's model must SEE the full suite — read
|
||||
// tools, the write family AND the git family (capability is
|
||||
// inherent in every pane; the pane organizes visibility, not
|
||||
// capability).
|
||||
t.Setenv("RUNE_MODE", "CHAT")
|
||||
fm := &fakeToolModel{}
|
||||
fm.replies = map[string]string{"": "done"}
|
||||
@@ -83,19 +84,49 @@ func TestChatTurnExposesReadToolsInChatMode(t *testing.T) {
|
||||
t.Fatal("model was called with no toolset — the toolset gap (D45)")
|
||||
}
|
||||
names := toolNames(fm.toolsets[0])
|
||||
if !has(names, "list_directory") || !has(names, "read_file") {
|
||||
t.Fatalf("read tools not exposed in CHAT mode: %v", names)
|
||||
}
|
||||
if has(names, "write_file") || has(names, "edit_file") || has(names, "run_command") {
|
||||
t.Fatalf("write tools exposed in CHAT mode: %v", names)
|
||||
for _, want := range []string{"list_directory", "read_file", "write_file", "edit_file", "run_command",
|
||||
"git_status", "git_diff", "git_log", "git_branch", "git_add", "git_commit"} {
|
||||
if !has(names, want) {
|
||||
t.Fatalf("D49: %s must be exposed in CHAT mode (capability in every pane), got: %v", want, names)
|
||||
}
|
||||
}
|
||||
_ = res
|
||||
}
|
||||
|
||||
func TestChatModeRefusesModeDisallowedToolCall(t *testing.T) {
|
||||
// The dispatch gate stays the security boundary (T3): a CHAT-mode
|
||||
// model call for write_file executes as a typed tool error, never
|
||||
// bytes on disk.
|
||||
func TestPlanModeExposesFullSuite(t *testing.T) {
|
||||
// D49: PLAN carries the full suite too (the pane organizes
|
||||
// visibility, not capability).
|
||||
t.Setenv("RUNE_MODE", "PLAN")
|
||||
names := toolNames(ToolsetFor("PLAN"))
|
||||
for _, want := range []string{"write_file", "run_command", "git_commit"} {
|
||||
if !has(names, want) {
|
||||
t.Fatalf("D49: %s must be exposed in PLAN mode, got: %v", want, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolDescriptionsStateAvailability(t *testing.T) {
|
||||
// D49 point 3: the model said "I need shell access" because the
|
||||
// wire showed read-only. The wire must SAY the writes are
|
||||
// available — the descriptions the toolset carries state it.
|
||||
for _, mode := range []string{"CHAT", "PLAN", "TASK"} {
|
||||
for _, td := range ToolsetFor(mode) {
|
||||
switch td.Function.Name {
|
||||
case "write_file", "edit_file", "run_command":
|
||||
desc := strings.ToLower(td.Function.Description)
|
||||
if !strings.Contains(desc, "every pane") {
|
||||
t.Fatalf("%s description must state it is available in every pane (mode %s): %q",
|
||||
td.Function.Name, mode, td.Function.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatModeWriteExecutes(t *testing.T) {
|
||||
// D49: the write-refusal-by-mode retires — a CHAT-mode model call
|
||||
// for write_file EXECUTES (the file lands; the consent card
|
||||
// promotes work, it does not gate tools).
|
||||
t.Setenv("RUNE_MODE", "CHAT")
|
||||
dir := t.TempDir()
|
||||
fm := &fakeToolModel{}
|
||||
@@ -109,29 +140,50 @@ func TestChatModeRefusesModeDisallowedToolCall(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("turn: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "x.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("write_file EXECUTED in CHAT mode — the mode gate failed")
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "x.txt"))
|
||||
if err != nil {
|
||||
t.Fatal("write_file refused in CHAT mode — the D49 doctrine (capability in every pane) is not landed")
|
||||
}
|
||||
if string(raw) != "no" {
|
||||
t.Fatalf("file content = %q", raw)
|
||||
}
|
||||
if len(fm.payloadLog) < 2 {
|
||||
t.Fatalf("expected a tool-result round trip, got %d calls", len(fm.payloadLog))
|
||||
}
|
||||
toolMsgs := []ChatMessage{}
|
||||
var sawSuccess bool
|
||||
for _, m := range fm.payloadLog[1] {
|
||||
if m.Role == "tool" {
|
||||
toolMsgs = append(toolMsgs, m)
|
||||
if m.Role == "tool" && strings.Contains(m.Content, `"success":true`) {
|
||||
sawSuccess = true
|
||||
}
|
||||
}
|
||||
if len(toolMsgs) == 0 {
|
||||
t.Fatal("no tool result message fed back to the model")
|
||||
}
|
||||
if !strings.Contains(toolMsgs[0].Content, "not reachable in mode CHAT") {
|
||||
t.Fatalf("tool result should carry the typed mode refusal, got: %s", toolMsgs[0].Content)
|
||||
if !sawSuccess {
|
||||
t.Fatalf("tool result should carry the successful write envelope")
|
||||
}
|
||||
if res.AssistantText != "ok, stopped" {
|
||||
t.Fatalf("final answer = %q", res.AssistantText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenuinelyUnavailableToolStillRefused(t *testing.T) {
|
||||
// The dispatch-gate seam stays (D49): a tool genuinely outside the
|
||||
// suite is a typed refusal naming the mode, never an execution.
|
||||
t.Setenv("RUNE_MODE", "CHAT")
|
||||
fm := &fakeToolModel{}
|
||||
fm.script = []ChatReply{
|
||||
{Content: "", ToolCalls: []ToolCall{{Name: "format_disk", Args: map[string]any{}}}},
|
||||
{Content: "understood", Model: "m"},
|
||||
}
|
||||
e, _ := newTestEngine(t, fm)
|
||||
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "format it"}); err != nil {
|
||||
t.Fatalf("turn: %v", err)
|
||||
}
|
||||
for _, m := range fm.payloadLog[1] {
|
||||
if m.Role == "tool" && !strings.Contains(m.Content, "not reachable in mode CHAT") {
|
||||
t.Fatalf("tool result should carry the typed refusal for a genuinely-unavailable tool, got: %s", m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskModeExposesWrites(t *testing.T) {
|
||||
// The mode-gated matrix: TASK exposes the write family (and the
|
||||
// model's write actually lands — Rune can act).
|
||||
@@ -203,8 +255,10 @@ func TestListDirectoryToolCallFullLoop(t *testing.T) {
|
||||
// turn's channel through ONE door (modeForDispatch), never a second
|
||||
// ActiveMode read that can disagree. Consent happens while the face
|
||||
// sits in CHAT; the task queue discharges into the task channel —
|
||||
// those dispatches carry the TASK toolset (write family) regardless of
|
||||
// where the face is parked.
|
||||
// those dispatches carry the TASK toolset regardless of where the face
|
||||
// is parked. D49 makes the toolset the same full suite on every
|
||||
// channel and in every mode; the door stays so future policies (and
|
||||
// future modes that narrow the suite) hook in one place.
|
||||
|
||||
func TestTaskChannelExposesWriteToolsWithFaceChatMode(t *testing.T) {
|
||||
// RED #2537: the owner bug — face mode CHAT, turn on the task
|
||||
@@ -229,9 +283,10 @@ func TestTaskChannelExposesWriteToolsWithFaceChatMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatChannelFaceChatModeStaysReadOnly(t *testing.T) {
|
||||
// The channel law must not leak the other way: a chat-channel turn
|
||||
// with the face in CHAT stays read-only.
|
||||
func TestChatChannelFaceChatModeCarriesFullSuite(t *testing.T) {
|
||||
// D49: the channel law's read-only half retires — a chat-channel
|
||||
// turn with the face in CHAT carries the FULL suite (capability is
|
||||
// inherent in every pane).
|
||||
t.Setenv("RUNE_MODE", "CHAT")
|
||||
fm := &fakeToolModel{}
|
||||
fm.script = []ChatReply{{Content: "here", Model: "m"}}
|
||||
@@ -240,11 +295,10 @@ func TestChatChannelFaceChatModeStaysReadOnly(t *testing.T) {
|
||||
t.Fatalf("turn: %v", err)
|
||||
}
|
||||
names := toolNames(fm.toolsets[0])
|
||||
if !has(names, "list_directory") || !has(names, "read_file") {
|
||||
t.Fatalf("read tools not exposed: %v", names)
|
||||
}
|
||||
if has(names, "write_file") || has(names, "edit_file") || has(names, "run_command") {
|
||||
t.Fatalf("write tools exposed on a chat-channel CHAT-face turn: %v", names)
|
||||
for _, want := range []string{"list_directory", "read_file", "write_file", "edit_file", "run_command", "git_status", "git_commit"} {
|
||||
if !has(names, want) {
|
||||
t.Fatalf("D49: %s must be exposed on a chat-channel CHAT-face turn, got: %v", want, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +323,8 @@ func TestDispatchGateHonorsChannelDerivedMode(t *testing.T) {
|
||||
// The gate and the wire derive through the SAME door (#2537): a
|
||||
// task-channel write executes with the face in CHAT (the gate may
|
||||
// not re-read the face mode and refuse what the wire offered), and
|
||||
// a chat-channel write is refused with the face in CHAT.
|
||||
// — since D49 — a chat-channel write executes too (capability is
|
||||
// inherent in every pane).
|
||||
t.Run("task channel allows write with face CHAT", func(t *testing.T) {
|
||||
t.Setenv("RUNE_MODE", "CHAT")
|
||||
dir := t.TempDir()
|
||||
@@ -292,27 +347,29 @@ func TestDispatchGateHonorsChannelDerivedMode(t *testing.T) {
|
||||
t.Fatalf("file content = %q", raw)
|
||||
}
|
||||
})
|
||||
t.Run("chat channel refuses write with face CHAT", func(t *testing.T) {
|
||||
t.Run("chat channel executes write with face CHAT", func(t *testing.T) {
|
||||
// D49: the write-refusal half retires — a chat-channel write
|
||||
// with the face in CHAT EXECUTES (capability is inherent in
|
||||
// every pane).
|
||||
t.Setenv("RUNE_MODE", "CHAT")
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "no.txt")
|
||||
target := filepath.Join(dir, "yes.txt")
|
||||
fm := &fakeToolModel{}
|
||||
fm.script = []ChatReply{
|
||||
{Content: "", ToolCalls: []ToolCall{{Name: "write_file", Args: map[string]any{
|
||||
"path": target, "content": "no"}}}},
|
||||
{Content: "stopped", Model: "m"},
|
||||
"path": target, "content": "carried out"}}}},
|
||||
{Content: "wrote it", Model: "m"},
|
||||
}
|
||||
e, _ := newTestEngine(t, fm)
|
||||
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "write it anyway"}); err != nil {
|
||||
if _, err := e.Turn(context.Background(), TurnRequest{Channel: "chat", UserText: "write it"}); err != nil {
|
||||
t.Fatalf("turn: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatal("write_file EXECUTED on a chat-channel dispatch — the gate failed")
|
||||
raw, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal("write_file REFUSED on a chat-channel dispatch — the D49 doctrine (capability in every pane) is not landed")
|
||||
}
|
||||
for _, m := range fm.payloadLog[1] {
|
||||
if m.Role == "tool" && !strings.Contains(m.Content, "not reachable in mode CHAT") {
|
||||
t.Fatalf("tool result should carry the typed mode refusal, got: %s", m.Content)
|
||||
}
|
||||
if string(raw) != "carried out" {
|
||||
t.Fatalf("file content = %q", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,17 +192,19 @@ type frontDoor struct {
|
||||
// lib.CompletionBudget so the two legs of the chain can never drift.
|
||||
const CompletionBudget = proxyClientBudget
|
||||
|
||||
// newFrontDoor builds a front door over one core target with the
|
||||
// production budget wiring: BOTH proxy clients carry the WHOLE-TURN
|
||||
// budget (D47 defect 3 — a dispatched completion runs the core's tool
|
||||
// loop, up to ToolLoopIterations model calls; the per-class deadline
|
||||
// rides the ctx the TUI binds, the doors never cut beneath the
|
||||
// longest class).
|
||||
// newFrontDoor builds a front door over one core target. The dispatch
|
||||
// route's completion client carries NO whole-call Timeout (the
|
||||
// streaming root-cause wave, Gitea #2556): a streamed turn makes
|
||||
// progress continuously and the CORE governs its deadlines — the
|
||||
// inter-chunk idle timeout plus the 30-minute backstop — so a flat
|
||||
// client cap here would only patch the symptom again. The generic
|
||||
// proxy client keeps the WHOLE-TURN budget for the non-streaming
|
||||
// fallback paths (the 6x480s constants' remaining tenant).
|
||||
func newFrontDoor(core string) *frontDoor {
|
||||
return &frontDoor{
|
||||
core: core,
|
||||
client: &http.Client{Timeout: TurnBudget},
|
||||
completionClient: &http.Client{Timeout: TurnBudget},
|
||||
completionClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +239,7 @@ func (f *frontDoor) proxyOn(out *http.Client) http.HandlerFunc {
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(r.Method, f.core+r.URL.Path, body)
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, f.core+r.URL.Path, body)
|
||||
if err != nil {
|
||||
errorEnvelope(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package entry
|
||||
|
||||
// streaming_entry_test.go — the streaming root-cause wave's entry-leg
|
||||
// proof (Gitea #2556): the front door relays a streamed /v1/chat/
|
||||
// completions response CHUNK BY CHUNK — a chunk the core emits reaches
|
||||
// the caller before the core emits the next one (no buffering at the
|
||||
// proxy), so the turn never sits inside one blocking call on the
|
||||
// entry leg either.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEntryProxyRelaysStreamChunks — a fake core emits two NDJSON
|
||||
// chunk lines 300ms apart; the relayed first chunk must arrive while
|
||||
// the second is still in flight.
|
||||
func TestEntryProxyRelaysStreamChunks(t *testing.T) {
|
||||
lines := []string{
|
||||
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"hel"}}]}` + "\n",
|
||||
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n",
|
||||
}
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
for i, line := range lines {
|
||||
_, _ = w.Write([]byte(line))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
if i == 0 {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
|
||||
f := newFrontDoor(core.URL)
|
||||
door := httptest.NewServer(f.mux())
|
||||
defer door.Close()
|
||||
|
||||
start := time.Now()
|
||||
resp, err := http.Post(door.URL+"/v1/chat/completions", "application/json",
|
||||
strings.NewReader(`{"model":"m","messages":[{"role":"user","content":"hi"}],"stream":true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
buf := make([]byte, 4096)
|
||||
firstAt := time.Time{}
|
||||
var got strings.Builder
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
got.Write(buf[:n])
|
||||
if firstAt.IsZero() && strings.Contains(got.String(), "hel") {
|
||||
firstAt = time.Now()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstAt.IsZero() {
|
||||
t.Fatalf("first chunk never arrived incrementally: %q", got.String())
|
||||
}
|
||||
if elapsed := firstAt.Sub(start); elapsed >= 250*time.Millisecond {
|
||||
t.Fatalf("first chunk arrived at %v — the proxy buffered the stream", elapsed)
|
||||
}
|
||||
if !strings.Contains(got.String(), "finish_reason") {
|
||||
t.Fatalf("terminal chunk lost: %q", got.String())
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
package entry
|
||||
|
||||
// turnbudget_entry_test.go — the D47 defect 3 law on the ENTRY legs:
|
||||
// every door that proxies a dispatched completion (the serve proxy
|
||||
// client + the continuous-runtime door's completion client) must carry
|
||||
// the WHOLE-TURN budget — the sum of the tool loop's model calls, the
|
||||
// local-class ceiling — never the old single-call 480s. Red-first.
|
||||
// turnbudget_entry_test.go — the budget law on the ENTRY legs after
|
||||
// the streaming root-cause wave (Gitea #2556): the dispatch route's
|
||||
// completion client carries NO whole-call Timeout (a streamed turn is
|
||||
// governed by the core's inter-chunk idle timeout + backstop — a flat
|
||||
// client cap would patch the symptom again); the generic proxy client
|
||||
// keeps the whole-turn budget for the non-streaming fallback paths.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEntryCompletionClientsCarryWholeTurnBudget — both entry doors'
|
||||
// completion clients ride >= the local whole-turn budget (6 × the
|
||||
// per-call 480s; the ctx the TUI binds is the per-class deadline, the
|
||||
// client timeout must never cut beneath the longest class).
|
||||
func TestEntryCompletionClientsCarryWholeTurnBudget(t *testing.T) {
|
||||
// TestEntryDispatchClientIsUngatedAndProxyKeepsFallback — the two
|
||||
// clients split by deadline governance: the completion (dispatch /
|
||||
// streamed) client must NOT cap the whole call, the generic proxy
|
||||
// client must carry >= the whole-turn fallback budget.
|
||||
func TestEntryDispatchClientIsUngatedAndProxyKeepsFallback(t *testing.T) {
|
||||
if TurnBudget < ToolLoopIterations*PerCallBudget {
|
||||
t.Fatalf("TurnBudget = %v, want >= %v (the whole-turn sum of %d model calls)",
|
||||
TurnBudget, ToolLoopIterations*PerCallBudget, ToolLoopIterations)
|
||||
@@ -25,9 +26,9 @@ func TestEntryCompletionClientsCarryWholeTurnBudget(t *testing.T) {
|
||||
}
|
||||
f := newFrontDoor("http://127.0.0.1:8376")
|
||||
if f.client == nil || f.client.Timeout < TurnBudget {
|
||||
t.Fatalf("proxy client budget = %+v, want the whole-turn %v", f.client, TurnBudget)
|
||||
t.Fatalf("proxy client budget = %+v, want the whole-turn %v (the non-streaming fallback)", f.client, TurnBudget)
|
||||
}
|
||||
if f.completionClient == nil || f.completionClient.Timeout < TurnBudget {
|
||||
t.Fatalf("completion client budget = %+v, want the whole-turn %v", f.completionClient, TurnBudget)
|
||||
if f.completionClient == nil || f.completionClient.Timeout != 0 {
|
||||
t.Fatalf("completion client timeout = %v, want 0 (the streamed turn is governed by the core's idle watch, never a flat client cap)", f.completionClient.Timeout)
|
||||
}
|
||||
}
|
||||
@@ -215,17 +215,21 @@ func runMode(argv []string) int {
|
||||
}
|
||||
|
||||
func readFile(path string, offset, limit int) Result {
|
||||
f, err := os.Open(path)
|
||||
// Python-oracle parity (tool_executor._read_file): existence is
|
||||
// reported as "Not found: <path>" before any read is attempted.
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
if os.IsNotExist(statErr) {
|
||||
return fail("Not found: " + path)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("Cannot read %s: %v", path, err))
|
||||
}
|
||||
defer f.Close()
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
return fail(fmt.Sprintf("Read failed: %v", err))
|
||||
}
|
||||
// Python-oracle parity: split("\n") keeps the trailing empty element
|
||||
// after a final newline, so a file ending in "\n" gets a numbered
|
||||
// empty last line — do NOT use bufio.Scanner, which drops it.
|
||||
lines := strings.Split(string(data), "\n")
|
||||
start := offset - 1
|
||||
if start < 0 {
|
||||
start = 0
|
||||
@@ -239,12 +243,26 @@ func readFile(path string, offset, limit int) Result {
|
||||
}
|
||||
var numbered []string
|
||||
for i := start; i < end; i++ {
|
||||
numbered = append(numbered, fmt.Sprintf("%d: %s", i+1, lines[i]))
|
||||
// Python-oracle parity: line numbers restart at the offset within
|
||||
// the window (windowed index + offset), not the absolute index.
|
||||
numbered = append(numbered, fmt.Sprintf("%d: %s", i-start+offset, lines[i]))
|
||||
}
|
||||
return ok(strings.Join(numbered, "\n"))
|
||||
result := strings.Join(numbered, "\n")
|
||||
if end < len(lines) {
|
||||
result += fmt.Sprintf("\n\n[showing lines %d-%d of %d total]", offset, end, len(lines))
|
||||
}
|
||||
if start > 0 {
|
||||
result = fmt.Sprintf("[showing from line %d]\n", offset) + result
|
||||
}
|
||||
return ok(result)
|
||||
}
|
||||
|
||||
func listDirectory(path, pattern string) Result {
|
||||
// Python-oracle parity (tool_executor._list_directory): a missing or
|
||||
// non-directory path reports "Not a directory: <path>".
|
||||
if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() {
|
||||
return fail("Not a directory: " + path)
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("Cannot list %s: %v", path, err))
|
||||
@@ -337,6 +355,11 @@ func grepFiles(pattern, path, include string) Result {
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("Bad pattern: %v", err))
|
||||
}
|
||||
// Python-oracle parity (tool_executor._grep_files): hits carry the
|
||||
// ABSOLUTE resolved path, not the caller's spelling.
|
||||
if abs, absErr := filepath.Abs(path); absErr == nil {
|
||||
path = abs
|
||||
}
|
||||
var out []string
|
||||
var walkFn func(string)
|
||||
walkFn = func(dir string) {
|
||||
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.338.0"
|
||||
__version__ = "0.346.0"
|
||||
|
||||
# One build identity (handoff 2026-09-10 §4): the semantic version above is
|
||||
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
|
||||
|
||||
@@ -233,7 +233,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.338.0"' in rune_version,
|
||||
'__version__ = "0.346.0"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.request
|
||||
from typing import Callable, Protocol, Sequence, TypedDict, cast
|
||||
@@ -38,6 +40,91 @@ from .tool_protocol import (
|
||||
|
||||
LOG = logging.getLogger("rune.context_broker")
|
||||
|
||||
# Native engine integration (wave B, Gitea #2575, PORT→INTEGRATE→REMOVE):
|
||||
# the four read-only file operations (read_file, list_directory, glob_files,
|
||||
# grep_files) resolve through the Go engine (src/go/tool-executor, the same
|
||||
# CLI/envelope surface the Go parity batteries pin). The Python bodies in
|
||||
# tool_executor.py stay as the oracle/escape hatch
|
||||
# (RUNE_TOOL_EXECUTOR_ENGINE=python); fail-loud, no silent fallback — a
|
||||
# missing binary raises a typed RuntimeError. run_command is deliberately
|
||||
# NOT delegated: it is mutating (tool_executor.READ_ONLY_TOOLS excludes it)
|
||||
# and the approval door owns every dispatch of it.
|
||||
|
||||
_GO_EXECUTOR_OPS = frozenset(
|
||||
{"read_file", "list_directory", "glob_files", "grep_files"}
|
||||
)
|
||||
|
||||
def _lte_go_engine_enabled() -> bool:
|
||||
return os.environ.get("RUNE_TOOL_EXECUTOR_ENGINE", "go") != "python"
|
||||
|
||||
def _lte_go_engine_bin() -> str:
|
||||
override = os.environ.get("RUNE_TOOL_EXECUTOR_BIN")
|
||||
if override:
|
||||
return override
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
return os.path.join(root, "src", "go", "tool-executor", "tool-executor")
|
||||
|
||||
def _go_execute_tool(tool_name: str, arguments: dict) -> dict:
|
||||
"""Shell out to the Go tool-executor CLI and return its JSON envelope.
|
||||
|
||||
Argv mirrors dispatchOp (internal/toolexec/modegate.go): read_file
|
||||
<path> [offset] [limit]; list_directory <path> [pattern];
|
||||
glob_files <pattern> [path]; grep_files <pattern> [path] [include].
|
||||
"""
|
||||
bin_path = _lte_go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"tool-executor go engine binary missing: {bin_path} "
|
||||
"(set RUNE_TOOL_EXECUTOR_ENGINE=python for the legacy path)"
|
||||
)
|
||||
if tool_name == "read_file":
|
||||
argv = [bin_path, "read_file", str(arguments.get("path", "")),
|
||||
str(int(arguments.get("offset", 1) or 1)),
|
||||
str(int(arguments.get("limit", 100) or 100))]
|
||||
elif tool_name == "list_directory":
|
||||
argv = [bin_path, "list_directory", str(arguments.get("path", "."))]
|
||||
if arguments.get("pattern"):
|
||||
argv.append(str(arguments["pattern"]))
|
||||
elif tool_name == "glob_files":
|
||||
argv = [bin_path, "glob_files", str(arguments.get("pattern", "")),
|
||||
str(arguments.get("path", "."))]
|
||||
elif tool_name == "grep_files":
|
||||
argv = [bin_path, "grep_files", str(arguments.get("pattern", "")),
|
||||
str(arguments.get("path", "."))]
|
||||
if arguments.get("include"):
|
||||
argv.append(str(arguments["include"]))
|
||||
else: # pragma: no cover — guarded by _GO_EXECUTOR_OPS
|
||||
raise RuntimeError(f"tool-executor go engine: unsupported op {tool_name!r}")
|
||||
try:
|
||||
proc = subprocess.run(argv, capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"tool-executor go engine failed: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"tool-executor go engine exit {proc.returncode}: {proc.stderr.strip()}"
|
||||
)
|
||||
envelope = json.loads(proc.stdout)
|
||||
if not isinstance(envelope, dict):
|
||||
raise RuntimeError("tool-executor go engine returned a non-object envelope")
|
||||
return envelope
|
||||
|
||||
class _GoDelegatingExecutor:
|
||||
"""Executor wrapper: read-only ops resolve through the Go engine, every
|
||||
other op (and the RUNE_TOOL_EXECUTOR_ENGINE=python escape hatch) falls
|
||||
through to the wrapped executor untouched. Attribute access (the
|
||||
authority seam, _uses_tool_authority) delegates to the inner executor."""
|
||||
|
||||
def __init__(self, inner: Callable[[str, dict], dict]):
|
||||
self._inner = inner
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self._inner, name)
|
||||
|
||||
def __call__(self, tool_name: str, arguments: dict) -> dict:
|
||||
if _lte_go_engine_enabled() and tool_name in _GO_EXECUTOR_OPS:
|
||||
return _go_execute_tool(tool_name, arguments)
|
||||
return self._inner(tool_name, arguments)
|
||||
|
||||
#: Conservative context window used ONLY when /api/show discovery fails;
|
||||
#: the failure is recorded via the P0-06 registry (T1) — never silent.
|
||||
CONTEXT_LENGTH_FALLBACK = 8192
|
||||
@@ -337,7 +424,11 @@ class LocalToolRuntime:
|
||||
if max_iterations < 1 or max_iterations > 8:
|
||||
raise ValueError("max_iterations must be between 1 and 8")
|
||||
self.transport = transport
|
||||
self.executor = executor
|
||||
# Wave B (#2575): read-only file ops resolve through the Go
|
||||
# tool-executor engine by default; every other op and the
|
||||
# RUNE_TOOL_EXECUTOR_ENGINE=python escape hatch hit the wrapped
|
||||
# executor untouched (approval door preserved).
|
||||
self.executor = _GoDelegatingExecutor(executor)
|
||||
self.model = model
|
||||
self.tools = list(TOOLS if tools is None else tools)
|
||||
self.tool_names = frozenset(
|
||||
|
||||
@@ -267,8 +267,9 @@ def save_profiles(path, profiles: dict) -> Path:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Native engine integration (retirement-conversion row 21, PORT→INTEGRATE):
|
||||
# the routing kernel (rank) and the measured-store fold
|
||||
# (update_profiles_from_traces) resolve through the Go engine
|
||||
# the routing kernel (rank), the measured-store fold
|
||||
# (update_profiles_from_traces), AND the capability-floor drift
|
||||
# reconciliation (reconcile_tools_support) resolve through the Go engine
|
||||
# (src/go/model_routing, `eval` surface) — the same engine the GREEN 7.2
|
||||
# parity packet (verification/parity/model-routing.json) proved
|
||||
# decision-for-decision. The Python implementations below stay as the
|
||||
@@ -454,6 +455,36 @@ CAPABILITY_FLOOR = (
|
||||
|
||||
|
||||
def reconcile_tools_support(model: str, provider_reported: bool) -> tuple[bool, str | None]:
|
||||
"""Fail-loud delegation facade for capability-floor drift reconciliation.
|
||||
|
||||
Resolves through the Go engine (src/go/model_routing, ``eval
|
||||
reconcile`` surface) — the same engine the parity battery pins
|
||||
decision-for-decision against this module's oracle body
|
||||
(``_reconcile_tools_support_python``, kept as the
|
||||
RUNE_MODEL_ROUTING_ENGINE=python escape hatch and oracle). Missing
|
||||
binary or engine error raises RuntimeError (no silent Python
|
||||
fallback).
|
||||
"""
|
||||
if not _go_engine_enabled():
|
||||
return _reconcile_tools_support_python(model, provider_reported)
|
||||
bin_path = _go_engine_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
raise RuntimeError(
|
||||
f"model-routing go engine binary missing: {bin_path} "
|
||||
"(set RUNE_MODEL_ROUTING_ENGINE=python for the legacy path)"
|
||||
)
|
||||
ops = [{
|
||||
"op": "reconcile",
|
||||
"model": model,
|
||||
"provider_reported": bool(provider_reported),
|
||||
}]
|
||||
out = _go_eval("reconcile", ops)
|
||||
row = out[0] if out and isinstance(out[0], dict) else {}
|
||||
drift = row.get("drift")
|
||||
return bool(row.get("effective")), (str(drift) if drift is not None else None)
|
||||
|
||||
|
||||
def _reconcile_tools_support_python(model: str, provider_reported: bool) -> tuple[bool, str | None]:
|
||||
"""Reconcile a provider-reported tool-support claim against the floor.
|
||||
|
||||
R2 I-R2-022 truth_drift; spec tools-providers-devices "Capability
|
||||
|
||||
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.338.0
|
||||
Version: v0.346.0
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -2047,7 +2047,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.338.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.346.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-22T19:47:11Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-22T19:47:34Z",
|
||||
"evidence": "user:D50-residual directive 2026-10-22",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-22T19:47:53Z",
|
||||
"evidence": "builder:wave-d50-residuals",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-wave-d50-residuals"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "wave-d50-residuals",
|
||||
"evidence": [
|
||||
"affected surface known: ['tui/', 'src/go/rune-core/internal/runecore/record.go']",
|
||||
"insufficient lexical evidence either way"
|
||||
],
|
||||
"lifecycle": "proportional",
|
||||
"mode": "EXISTING_PROJECT_CHANGE",
|
||||
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
|
||||
"project_authority": "rune_internal",
|
||||
"target_project": "rune"
|
||||
},
|
||||
"affected_paths": [
|
||||
"scripts/admit_wave_d50_residuals.py",
|
||||
"src/go/rune-core/internal/runecore/record.go",
|
||||
"src/go/rune-core/internal/runecore/store.go",
|
||||
"tui/"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"tasks-pane work blocks show a dim \u21a9 msg_id reference; phantom queue rows are filtered; tasks-focus enter dispatches directly; pinned work persists across boots"
|
||||
],
|
||||
"predicted_frontier": {
|
||||
"change_kind": "implementation_change",
|
||||
"contextual_not_obligations": [],
|
||||
"contracts": [],
|
||||
"level0_direct": [],
|
||||
"level1_obligations": [],
|
||||
"level2_verification": [],
|
||||
"max_level": 2,
|
||||
"traversal_scope": "spine-structural-only"
|
||||
},
|
||||
"scope_provenance": {
|
||||
"caller_added": [],
|
||||
"generated_from_affected_surface": [
|
||||
"scripts/admit_wave_d50_residuals.py",
|
||||
"src/go/rune-core/internal/runecore/record.go",
|
||||
"src/go/rune-core/internal/runecore/store.go",
|
||||
"tui/"
|
||||
]
|
||||
},
|
||||
"task_ref": "wave-d50-residuals",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/admit_wave_d50_residuals.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/rune-core/internal/runecore/record.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/rune-core/internal/runecore/store.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": []
|
||||
},
|
||||
"id": "contract:ADMIT-wave-d50-residuals",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: land the D50-residual wave (Gitea #2560, D51 round folded): (1) msg_id linkage \u2014 tasks-pane work-stream records carry th"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"red-first enter-dispatch and phantom-filter tests",
|
||||
"the D50-residuals wave report (reports/)",
|
||||
"the pinned-work hot-state store + tests"
|
||||
],
|
||||
"gap_evidence": "insufficient lexical evidence either way; affected surface known: ['tui/', 'src/go/rune-core/internal/runecore/record.go']",
|
||||
"migration_obligations": [],
|
||||
"need": "land the D50-residual wave (Gitea #2560, D51 round folded): (1) msg_id linkage \u2014 tasks-pane work-stream records carry the spawning chat msg_id (msg ids exist core-side per newMsgID), rendered as a dim \u21a9 reference in the work block; (2) phantom-row filter \u2014 filter stale queue rows by the freshness law (the 30-min occupancy pattern) in the tasks pane, read-side in the TUI; (3) enter-steal removal \u2014 remove the tasks-pane row-selection walker entirely; enter on tasks-pane focus starts direct task execution (D44 doctrine); (4) pinned work \u2014 reserved pinned area at the top of the tasks pane with a pin icon and 'pinned' label, each pin carrying a plain-english summary, click opens the outstanding pinned work view, pins persist across boots via the hot-state store pattern; the tasks pane renders blank otherwise with no selectable rows; (5) dynamic reflow \u2014 bubbles/boxes re-wrap on window resize (nothing clips)",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [],
|
||||
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
|
||||
"research_refs": [
|
||||
"scripts/admit_wave_rebase_tui_wiring.py",
|
||||
"store/scaffold/docs/ADMIT-wave-d46-live-defects.md",
|
||||
"tui/chatpane/blocks.go",
|
||||
"tui/cmd/rune-tui/selection.go",
|
||||
"tui/lanes/activity.go",
|
||||
"tui/tasks/render.go"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
"scripts/admit_wave_d50_residuals.py",
|
||||
"src/go/rune-core/internal/runecore/record.go",
|
||||
"src/go/rune-core/internal/runecore/store.go",
|
||||
"tui/"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"PTY proofs for enter-dispatch and pin persistence",
|
||||
"build/vet/gofmt clean",
|
||||
"go test -race ./... in tui/ green"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-22T19:47:53Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-23T17:26:43Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-23T17:27:02Z",
|
||||
"evidence": "user:D54 dispatch 2026-10-23 (Gitea #2571)",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-23T17:27:20Z",
|
||||
"evidence": "builder:wave-d54-nd-presentation",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-wave-d54-nd-presentation"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "wave-d54-nd-presentation",
|
||||
"evidence": [
|
||||
"affected surface known: ['tui/lanes/activity.go', 'tui/chatpane/blocks.go']",
|
||||
"change scoped to an existing project/target",
|
||||
"target project: rune"
|
||||
],
|
||||
"lifecycle": "proportional",
|
||||
"mode": "EXISTING_PROJECT_CHANGE",
|
||||
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
|
||||
"project_authority": "rune_internal",
|
||||
"target_project": "rune"
|
||||
},
|
||||
"affected_paths": [
|
||||
"scripts/admit_wave_d54_nd_presentation.py",
|
||||
"tui/chatpane/blocks.go",
|
||||
"tui/chatpane/chatpane.go",
|
||||
"tui/chatpane/scroll_test.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"tui/lanes/activity.go",
|
||||
"tui/settings/config.go",
|
||||
"tui/settings/settings.go",
|
||||
"tui/themes/roles.go",
|
||||
"tui/themes/theme.go"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"calm mode mutes chroma tokens and stops the activity wave; long answers open with a one-line plain-english summary; face strings read literally"
|
||||
],
|
||||
"predicted_frontier": {
|
||||
"change_kind": "implementation_change",
|
||||
"contextual_not_obligations": [],
|
||||
"contracts": [],
|
||||
"level0_direct": [],
|
||||
"level1_obligations": [],
|
||||
"level2_verification": [],
|
||||
"max_level": 2,
|
||||
"traversal_scope": "spine-structural-only"
|
||||
},
|
||||
"scope_provenance": {
|
||||
"caller_added": [],
|
||||
"generated_from_affected_surface": [
|
||||
"scripts/admit_wave_d54_nd_presentation.py",
|
||||
"tui/chatpane/blocks.go",
|
||||
"tui/chatpane/chatpane.go",
|
||||
"tui/chatpane/scroll_test.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"tui/lanes/activity.go",
|
||||
"tui/settings/config.go",
|
||||
"tui/settings/settings.go",
|
||||
"tui/themes/roles.go",
|
||||
"tui/themes/theme.go"
|
||||
]
|
||||
},
|
||||
"task_ref": "wave-d54-nd-presentation",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/admit_wave_d54_nd_presentation.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/chatpane/blocks.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/chatpane/chatpane.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/chatpane/scroll_test.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/cmd/rune-tui/main.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/lanes/activity.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/settings/config.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/settings/settings.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/themes/roles.go"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/themes/theme.go"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": []
|
||||
},
|
||||
"id": "contract:ADMIT-wave-d54-nd-presentation",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: implement the neurodivergent-presentation mechanical map (research/factory-nuerodivergent-presentation/synthesis.md, Git"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"the colour-role enum in tui/themes (one hex per role)",
|
||||
"the motion-allowed gate on the activity wave",
|
||||
"the sensory (vivid|calm) setting key in the settings store"
|
||||
],
|
||||
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['tui/lanes/activity.go', 'tui/chatpane/blocks.go']",
|
||||
"migration_obligations": [],
|
||||
"need": "implement the neurodivergent-presentation mechanical map (research/factory-nuerodivergent-presentation/synthesis.md, Gitea #2571): the motion law (motionAllowed gate on the activity wave, never-animate law on the chat pane, one-animating-region-per-frame in the face chain), the colour-token law (role enum in tui/themes, one hex per role, contrast >=4.5:1 unit test, Faint-on-dim fix), the spacing law (one blank line between blocks, blocks never merge), summary-first rendering (one-line plain-english summary box for long answers), the calm-mode sensory setting (vivid|calm, default vivid \u2014 calm mutes chroma at the token layer and sets motionAllowed false), and the literal-language audit of face strings",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [],
|
||||
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
|
||||
"research_refs": [
|
||||
"research/factory-nuerodivergent-presentation/synthesis.md",
|
||||
"tui/chatpane/blocks.go",
|
||||
"tui/chatpane/chatpane.go",
|
||||
"tui/lanes/activity.go",
|
||||
"tui/settings/config.go",
|
||||
"tui/themes/theme.go"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
"scripts/admit_wave_d54_nd_presentation.py",
|
||||
"tui/chatpane/blocks.go",
|
||||
"tui/chatpane/chatpane.go",
|
||||
"tui/chatpane/scroll_test.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"tui/lanes/activity.go",
|
||||
"tui/settings/config.go",
|
||||
"tui/settings/settings.go",
|
||||
"tui/themes/roles.go",
|
||||
"tui/themes/theme.go"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"contrast unit test >=4.5:1 body text, dim tier floor verified (Faint-on-dim breach fixed)",
|
||||
"go test -race -short ./... in tui/ green (documented pre-existing PTY boot family aside)",
|
||||
"go vet + gofmt clean in tui/"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-23T17:27:20Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"checkpoints": [
|
||||
{
|
||||
"at": "2026-10-23T22:47:01Z",
|
||||
"evidence": "predicted-frontier",
|
||||
"note": "{\"level0_direct\": [], \"level1_obligations\": [], \"contracts\": [], \"level2_verification\": []}",
|
||||
"phase": "draft"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-23T22:47:19Z",
|
||||
"evidence": "user:streaming root-cause wave directive 2026-10-23 (Gitea #2556)",
|
||||
"note": "",
|
||||
"phase": "approved"
|
||||
},
|
||||
{
|
||||
"at": "2026-10-23T22:47:36Z",
|
||||
"evidence": "builder:wave-streaming-deadline-root",
|
||||
"note": "",
|
||||
"phase": "building"
|
||||
}
|
||||
],
|
||||
"evidence_refs": [
|
||||
"contract-created:ADMIT-wave-streaming-deadline-root"
|
||||
],
|
||||
"ext": {
|
||||
"activation": {
|
||||
"activated": true,
|
||||
"builder": "wave-streaming-deadline-root",
|
||||
"evidence": [
|
||||
"affected surface known: ['src/go/rune-core/internal/runecore/', 'src/go/rune-entry/']",
|
||||
"change scoped to an existing project/target",
|
||||
"target project: rune"
|
||||
],
|
||||
"lifecycle": "proportional",
|
||||
"mode": "EXISTING_PROJECT_CHANGE",
|
||||
"note": "attached to existing project authority; Need/completeness/patterns/contract flow governs proportionally",
|
||||
"project_authority": "rune_internal",
|
||||
"target_project": "rune"
|
||||
},
|
||||
"affected_paths": [
|
||||
"scripts/admit_wave_streaming_deadline_root.py",
|
||||
"src/go/rune-core/internal/runecore/",
|
||||
"src/go/rune-entry/",
|
||||
"tui/cmd/rune-tui/",
|
||||
"tui/runtime/lib/"
|
||||
],
|
||||
"affected_regions": [],
|
||||
"affected_ui_surfaces": [],
|
||||
"expected_runtime_effects": [
|
||||
"a local 27B thinking turn streams tokens continuously through core, entry proxy and TUI with no flat deadline; a genuinely dead model trips the idle timeout"
|
||||
],
|
||||
"predicted_frontier": {
|
||||
"change_kind": "implementation_change",
|
||||
"contextual_not_obligations": [],
|
||||
"contracts": [],
|
||||
"level0_direct": [],
|
||||
"level1_obligations": [],
|
||||
"level2_verification": [],
|
||||
"max_level": 2,
|
||||
"traversal_scope": "spine-structural-only"
|
||||
},
|
||||
"scope_provenance": {
|
||||
"caller_added": [],
|
||||
"generated_from_affected_surface": [
|
||||
"scripts/admit_wave_streaming_deadline_root.py",
|
||||
"src/go/rune-core/internal/runecore/",
|
||||
"src/go/rune-entry/",
|
||||
"tui/cmd/rune-tui/",
|
||||
"tui/runtime/lib/"
|
||||
]
|
||||
},
|
||||
"task_ref": "wave-streaming-deadline-root",
|
||||
"unresolved_surfaces": [
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "scripts/admit_wave_streaming_deadline_root.py"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/rune-core/internal/runecore/"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "src/go/rune-entry/"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/cmd/rune-tui/"
|
||||
},
|
||||
{
|
||||
"kind": "source_path",
|
||||
"reason": "no authority records this path as an entity (path remains file-scope only)",
|
||||
"surface": "tui/runtime/lib/"
|
||||
}
|
||||
],
|
||||
"workflow_config_surfaces": []
|
||||
},
|
||||
"id": "contract:ADMIT-wave-streaming-deadline-root",
|
||||
"identities": {
|
||||
"acceptance": [
|
||||
"bounded repair verified: land the streaming root-cause wave (Gitea #2556, owner verdict 2026-10-22): stream through the WHOLE turn chain so a loc"
|
||||
],
|
||||
"affected_entities": [],
|
||||
"expected_consumers": [],
|
||||
"expected_new_entities": [
|
||||
"the inter-chunk idle timeout + 30-minute backstop on streamed paths (budgets retire to fallback)",
|
||||
"the red-first streaming chain tests (ollama NDJSON, cloud SSE, core stream relay, entry pass-through, TUI render-as-it-arrives, idle-timeout simulation)",
|
||||
"the streaming root-cause wave report (reports/)"
|
||||
],
|
||||
"gap_evidence": "change scoped to an existing project/target; target project: rune; affected surface known: ['src/go/rune-core/internal/runecore/', 'src/go/rune-entry/']",
|
||||
"migration_obligations": [],
|
||||
"need": "land the streaming root-cause wave (Gitea #2556, owner verdict 2026-10-22): stream through the WHOLE turn chain so a local 27B thinking turn never sits inside one blocking HTTP call \u2014 (1) the Ollama wire runs stream=true /api/chat with NDJSON chunks parsed incrementally (content/thinking/tool_calls; the final message object carries the assembled reply) and the engine's bounded tool loop runs over streamed replies; (2) the cloud wires gain streaming parity ([OI]-compat SSE, Anthropic SSE events, Gemini streamGenerateContent); (3) the core /v1/chat/completions relays the streamed turn as NDJSON chunks for stream=true callers while non-streaming callers get the assembled answer as before; (4) the entry proxy passes the stream through and the TUI renders text as it arrives; (5) on streamed paths the whole-turn budgets retire \u2014 replaced by an inter-chunk idle timeout (default 120s, RUNE_STREAM_IDLE_TIMEOUT configurable) plus a 30-minute overall backstop; the budget constants remain only for non-streaming fallback paths",
|
||||
"next_action": "complete implementation, then verify",
|
||||
"non_goals": [],
|
||||
"origin": "worker",
|
||||
"regression_obligations": [],
|
||||
"requirement": "headless admission (classified: EXISTING_PROJECT_CHANGE)",
|
||||
"research_refs": [
|
||||
"scripts/admit_wave_terminal_budgets.py",
|
||||
"src/go/rune-core/internal/runecore/cloud.go",
|
||||
"src/go/rune-core/internal/runecore/ollama.go",
|
||||
"src/go/rune-core/internal/runecore/server.go",
|
||||
"src/go/rune-entry/internal/entry/serve.go",
|
||||
"tui/cmd/rune-tui/main.go",
|
||||
"tui/laneconc/laneconc.go",
|
||||
"tui/runtime/lib/complete.go"
|
||||
],
|
||||
"resolution": "",
|
||||
"rollback_ref": "git revert",
|
||||
"scope": [
|
||||
"scripts/admit_wave_streaming_deadline_root.py",
|
||||
"src/go/rune-core/internal/runecore/",
|
||||
"src/go/rune-entry/",
|
||||
"tui/cmd/rune-tui/",
|
||||
"tui/runtime/lib/"
|
||||
],
|
||||
"toolchain": {},
|
||||
"verification_obligations": [
|
||||
"-short race suites green on touched Go modules (the documented PTY family aside)",
|
||||
"full-chain simulation: a fake slow model emitting chunks across a long window completes the turn with no deadline fire; a silent model trips the idle timeout",
|
||||
"vet/gofmt clean"
|
||||
]
|
||||
},
|
||||
"kind": "construction_contract",
|
||||
"maturity": "DECLARED",
|
||||
"observed_at": "2026-10-23T22:47:36Z",
|
||||
"phase": "building",
|
||||
"status": "open",
|
||||
"tags": []
|
||||
}
|
||||
@@ -15,6 +15,15 @@ from typing import Sequence, cast
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
# Wave B (#2575): these fixtures script the Python tool executor — pin the
|
||||
# RUNE_TOOL_EXECUTOR_ENGINE=python escape hatch so the Go delegation
|
||||
# (pinned separately in tests/test_tool_executor_delegation.py) does not
|
||||
# bypass the scripted executor.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_tool_executor_engine(monkeypatch):
|
||||
monkeypatch.setenv("RUNE_TOOL_EXECUTOR_ENGINE", "python")
|
||||
|
||||
|
||||
from rune.local_tool_runtime import ( # noqa: E402
|
||||
ChatMessage,
|
||||
LocalToolRuntime,
|
||||
|
||||
@@ -38,6 +38,14 @@ import pytest
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
# Wave B (#2575): scripted-executor fixtures pin the Python escape hatch
|
||||
# (RUNE_TOOL_EXECUTOR_ENGINE=python); Go delegation is pinned in
|
||||
# tests/test_tool_executor_delegation.py.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_tool_executor_engine(monkeypatch):
|
||||
monkeypatch.setenv("RUNE_TOOL_EXECUTOR_ENGINE", "python")
|
||||
|
||||
|
||||
from rune.local_tool_runtime import ChatMessage, LocalToolRuntime # noqa: E402
|
||||
from rune import tool_executor # noqa: E402
|
||||
from rune.tool_protocol import ToolEvent, ToolRequest # noqa: E402
|
||||
|
||||
@@ -13,10 +13,20 @@ import unittest
|
||||
from pathlib import Path
|
||||
from typing import Sequence, cast
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
# Wave B (#2575): scripted-executor fixtures pin the Python escape hatch
|
||||
# (RUNE_TOOL_EXECUTOR_ENGINE=python); Go delegation is pinned in
|
||||
# tests/test_tool_executor_delegation.py.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _python_tool_executor_engine(monkeypatch):
|
||||
monkeypatch.setenv("RUNE_TOOL_EXECUTOR_ENGINE", "python")
|
||||
|
||||
|
||||
from rune.local_tool_runtime import ( # noqa: E402
|
||||
ChatMessage,
|
||||
LocalToolRuntime,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Red-first parity battery: the Go reconcile engine vs the Python oracle.
|
||||
|
||||
The capability-floor drift reconciliation (reconcile_tools_support) is
|
||||
the last seam that kept local_tool_runtime.py on its Python import
|
||||
(model-routing-integrated.json blocks_full_switchover, wave 181). The
|
||||
Python body stays as the oracle/escape hatch
|
||||
(RUNE_MODEL_ROUTING_ENGINE=python); this battery pins the Go delegation
|
||||
facade to it decision-for-decision over the recorded oracle corpus
|
||||
(contradiction → unverified drift, agreement → clean, non-floor →
|
||||
passthrough), checks the fail-loud refusal when the engine binary is
|
||||
missing, and receipts the local_tool_runtime consumer wiring.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
CORPUS = [
|
||||
("qwen2.5:3b", True), # floor T1 contradiction (floor=False)
|
||||
("mimo-v2.5-pro", False), # floor T4 contradiction (floor=True)
|
||||
("qwen2.5:3b", False), # floor T1 agreement
|
||||
("mimo-v2.5-pro", True), # floor T4 agreement
|
||||
("qwen3:8b", True), # non-floor passthrough
|
||||
("qwen3:8b", False), # non-floor passthrough
|
||||
]
|
||||
|
||||
|
||||
def _jbytes(value):
|
||||
return json.dumps(value, sort_keys=True, ensure_ascii=True)
|
||||
|
||||
|
||||
def _run(engine):
|
||||
from rune import model_routing as mr
|
||||
|
||||
old = os.environ.get("RUNE_MODEL_ROUTING_ENGINE")
|
||||
os.environ["RUNE_MODEL_ROUTING_ENGINE"] = engine
|
||||
try:
|
||||
return [mr.reconcile_tools_support(m, r) for m, r in CORPUS]
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop("RUNE_MODEL_ROUTING_ENGINE", None)
|
||||
else:
|
||||
os.environ["RUNE_MODEL_ROUTING_ENGINE"] = old
|
||||
|
||||
|
||||
def test_go_delegation_matches_python_oracle_byte_for_byte():
|
||||
oracle = _run("python")
|
||||
go = _run("go")
|
||||
assert _jbytes(go) == _jbytes(oracle)
|
||||
# spot-check the drift arm against the recorded oracle text
|
||||
assert go[0][0] is True
|
||||
assert go[0][1] is not None
|
||||
assert "contradicting CAPABILITY_FLOOR supports_tools=False" in go[0][1]
|
||||
assert "unverified: provider reports supports_tools=True" in go[0][1]
|
||||
assert go[1][0] is False and "supports_tools=False" in go[1][1]
|
||||
assert go[2] == (False, None) and go[3] == (True, None)
|
||||
assert go[4] == (True, None) and go[5] == (False, None)
|
||||
|
||||
|
||||
def test_default_is_go_engine():
|
||||
# default (unset env) resolves through the Go binary, not the oracle body
|
||||
env = os.environ.pop("RUNE_MODEL_ROUTING_ENGINE", None)
|
||||
try:
|
||||
default = _run(os.environ.get("RUNE_MODEL_ROUTING_ENGINE", "go"))
|
||||
finally:
|
||||
if env is not None:
|
||||
os.environ["RUNE_MODEL_ROUTING_ENGINE"] = env
|
||||
assert _jbytes(default) == _jbytes(_run("python"))
|
||||
|
||||
|
||||
def test_fail_loud_when_engine_binary_missing(tmp_path, monkeypatch):
|
||||
from rune import model_routing as mr
|
||||
|
||||
monkeypatch.setattr(mr, "_go_engine_bin", lambda: str(tmp_path / "nope"))
|
||||
monkeypatch.setenv("RUNE_MODEL_ROUTING_ENGINE", "go")
|
||||
with pytest.raises(RuntimeError, match="go engine binary missing"):
|
||||
mr.reconcile_tools_support("qwen2.5:3b", True)
|
||||
|
||||
|
||||
def test_local_tool_runtime_consumer_receipts_drift():
|
||||
# the consumer seam: LocalToolRuntime reconciles through the facade and
|
||||
# surfaces the drift marker on capability_drift
|
||||
from rune.local_tool_runtime import LocalToolRuntime
|
||||
from rune.model_routing import _reconcile_tools_support_python
|
||||
|
||||
eff, drift = _reconcile_tools_support_python("qwen2.5:3b", True)
|
||||
assert drift is not None
|
||||
# parity: the facade's default path produces the same marker
|
||||
os.environ.pop("RUNE_MODEL_ROUTING_ENGINE", None)
|
||||
from rune import model_routing as mr
|
||||
|
||||
g_eff, g_drift = mr.reconcile_tools_support("qwen2.5:3b", True)
|
||||
assert (g_eff, g_drift) == (eff, drift)
|
||||
assert LocalToolRuntime is not None # import seam still wired
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Red-first parity battery, wave B (Gitea #2575): the Go tool-executor
|
||||
engine vs the Python oracle through the LocalToolRuntime delegation facade.
|
||||
|
||||
The four read-only file operations (read_file, list_directory, glob_files,
|
||||
grep_files) resolve through src/go/tool-executor by default; the Python
|
||||
bodies in tool_executor.py stay as the oracle/escape hatch
|
||||
(RUNE_TOOL_EXECUTOR_ENGINE=python). This battery pins the facade to the
|
||||
oracle envelope-for-envelope over fixture calls, checks the fail-loud
|
||||
refusal when the engine binary is missing, and receipts that run_command
|
||||
NEVER delegates (mutating — the approval door owns it).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(_ROOT / "src"))
|
||||
|
||||
from rune.local_tool_runtime import ( # noqa: E402
|
||||
_GO_EXECUTOR_OPS,
|
||||
_GoDelegatingExecutor,
|
||||
_go_execute_tool,
|
||||
_lte_go_engine_bin,
|
||||
_lte_go_engine_enabled,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sandbox(tmp_path, monkeypatch):
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "docs" / "a.txt").write_text("alpha line\nbeta line\ngamma line\n")
|
||||
(tmp_path / "docs" / "b.py").write_text("def one():\n return 1\n")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _oracle(tool_name, arguments):
|
||||
from rune.tool_executor import execute_tool
|
||||
|
||||
old = os.environ.get("RUNE_TOOL_EXECUTOR_ENGINE")
|
||||
os.environ["RUNE_TOOL_EXECUTOR_ENGINE"] = "python"
|
||||
inner = _GoDelegatingExecutor(execute_tool)
|
||||
try:
|
||||
return inner(tool_name, arguments)
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
|
||||
else:
|
||||
os.environ["RUNE_TOOL_EXECUTOR_ENGINE"] = old
|
||||
|
||||
|
||||
def _facade(tool_name, arguments):
|
||||
return _GoDelegatingExecutor(lambda n, a: (_ for _ in ()).throw(
|
||||
AssertionError("inner executor must not be reached for read-only ops"))
|
||||
)(tool_name, arguments)
|
||||
|
||||
|
||||
CASES = [
|
||||
("read_file", {"path": "docs/a.txt"}),
|
||||
("read_file", {"path": "docs/a.txt", "offset": 2, "limit": 1}),
|
||||
("list_directory", {"path": "docs"}),
|
||||
("list_directory", {"path": "docs", "pattern": "*.txt"}),
|
||||
("glob_files", {"pattern": "docs/*.py"}),
|
||||
("glob_files", {"pattern": "docs/*"}),
|
||||
("grep_files", {"pattern": "line", "path": "docs"}),
|
||||
("grep_files", {"pattern": "line", "path": "docs", "include": "*.txt"}),
|
||||
("read_file", {"path": "docs/missing.txt"}),
|
||||
("list_directory", {"path": "docs/missing-dir"}),
|
||||
("grep_files", {"pattern": "zzz-no-match", "path": "docs"}),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("tool_name,arguments", CASES)
|
||||
def test_go_delegation_matches_python_oracle_envelope(sandbox, tool_name, arguments):
|
||||
assert _jbytes(_facade(tool_name, dict(arguments))) == _jbytes(
|
||||
_oracle(tool_name, dict(arguments))
|
||||
)
|
||||
|
||||
def _jbytes(value):
|
||||
return json.dumps(value, sort_keys=True, ensure_ascii=True)
|
||||
|
||||
def test_default_engine_is_go():
|
||||
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
|
||||
assert _lte_go_engine_enabled() is True
|
||||
|
||||
def test_python_escape_hatch_reaches_inner_executor(sandbox):
|
||||
from rune.tool_executor import execute_tool
|
||||
|
||||
os.environ["RUNE_TOOL_EXECUTOR_ENGINE"] = "python"
|
||||
try:
|
||||
seen = []
|
||||
def inner(name, args):
|
||||
seen.append(name)
|
||||
return {"success": True, "result": "oracle"}
|
||||
out = _GoDelegatingExecutor(inner)("read_file", {"path": "docs/a.txt"})
|
||||
finally:
|
||||
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
|
||||
assert seen == ["read_file"]
|
||||
assert out == {"success": True, "result": "oracle"}
|
||||
|
||||
def test_fail_loud_when_engine_binary_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"rune.local_tool_runtime._lte_go_engine_bin",
|
||||
lambda: str(tmp_path / "nope"),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="go engine binary missing"):
|
||||
_go_execute_tool("read_file", {"path": "whatever"})
|
||||
|
||||
def test_run_command_never_delegates_even_in_go_mode():
|
||||
# run_command is mutating — the approval door owns every dispatch.
|
||||
assert "run_command" not in _GO_EXECUTOR_OPS
|
||||
seen = []
|
||||
def inner(name, args):
|
||||
seen.append(name)
|
||||
return {"success": True, "result": "authority path"}
|
||||
os.environ.pop("RUNE_TOOL_EXECUTOR_ENGINE", None)
|
||||
out = _GoDelegatingExecutor(inner)("run_command", {"command": "echo hi"})
|
||||
assert seen == ["run_command"]
|
||||
assert out == {"success": True, "result": "authority path"}
|
||||
|
||||
def test_runtime_wraps_executor_and_keeps_attribute_seam():
|
||||
from rune.local_tool_runtime import LocalToolRuntime
|
||||
|
||||
class Transport:
|
||||
def supports_tools(self, model):
|
||||
return False
|
||||
def supports_thinking(self, model):
|
||||
return False
|
||||
|
||||
def executor(name, args):
|
||||
return {"success": True, "result": "inner"}
|
||||
|
||||
runtime = LocalToolRuntime(
|
||||
transport=Transport(), executor=executor, model="qwen3:8b"
|
||||
)
|
||||
assert isinstance(runtime.executor, _GoDelegatingExecutor)
|
||||
assert getattr(runtime.executor, "_uses_tool_authority", "sentinel") == "sentinel"
|
||||
assert _lte_go_engine_bin().endswith(os.path.join("src", "go", "tool-executor", "tool-executor"))
|
||||
+96
-11
@@ -20,6 +20,16 @@
|
||||
//
|
||||
// B4: a long answer never breaks the bubble — the growing-bubble
|
||||
// model replaces the fixed-shape card; answers stay left-aligned.
|
||||
//
|
||||
// THE NEVER-ANIMATE LAW (D54 #2571 item 1): the chat pane holds the
|
||||
// reading content, so it NEVER animates — no looping motion, no
|
||||
// blinking, no reflow-on-append. Stream append is APPEND-ONLY: a new
|
||||
// message adds lines at the tail and must not change a single
|
||||
// already-rendered line of the completed blocks above it (asserted by
|
||||
// TestAppendNeverReflowsCompletedBlocks; the stay-put scroll law in
|
||||
// scroll.go rides the same contract). The activity strip is the
|
||||
// face's one animating region, gated behind motionAllowed
|
||||
// (tui/lanes/activity.go).
|
||||
package chatpane
|
||||
|
||||
import (
|
||||
@@ -27,8 +37,58 @@ import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"rune.dev/rune/tui/themes"
|
||||
)
|
||||
|
||||
// THE SPACING LAW (D54 #2571 item 3): exactly ONE blank line between
|
||||
// blocks — the layout constant blockGapBlankLines, pure layout (no
|
||||
// theme dependency). Block TYPES never merge: the parser keeps a
|
||||
// list a list and a paragraph a paragraph (mdBlocks), and the
|
||||
// renderer never abuts two block regions — a blank line always sits
|
||||
// between them (COGA 4.4.10 white spacing; one idea, one block, one
|
||||
// gap).
|
||||
const blockGapBlankLines = 1
|
||||
|
||||
// THE SUMMARY-FIRST LAW (D54 #2571 item 4): an answer whose block
|
||||
// count exceeds summaryBlockThreshold opens with a ONE-LINE
|
||||
// plain-english summary box — what the answer contains, counted
|
||||
// literally — before any block (COGA 4.4.8; the generalization of
|
||||
// the "code (n lines)" collapse hint). Literal wording only: no
|
||||
// idiom, no figure of speech.
|
||||
const summaryBlockThreshold = 8
|
||||
|
||||
// summaryLine composes the one-line plain-english summary for a
|
||||
// block stream ("Summary: 12 blocks — 9 paragraphs, 2 lists, 1
|
||||
// table.").
|
||||
func summaryLine(blocks []mdBlock) string {
|
||||
counts := map[string]int{}
|
||||
var order []string
|
||||
for _, b := range blocks {
|
||||
name := b.kind
|
||||
if name == "list" {
|
||||
if b.ordered {
|
||||
name = "steps"
|
||||
} else {
|
||||
name = "lists"
|
||||
}
|
||||
} else if name == "para" {
|
||||
name = "paragraphs"
|
||||
} else {
|
||||
name = name + "s"
|
||||
}
|
||||
if counts[name] == 0 {
|
||||
order = append(order, name)
|
||||
}
|
||||
counts[name]++
|
||||
}
|
||||
parts := make([]string, 0, len(order))
|
||||
for _, name := range order {
|
||||
parts = append(parts, fmt.Sprintf("%d %s", counts[name], name))
|
||||
}
|
||||
return fmt.Sprintf("Summary: %d blocks — %s. The blocks follow.", len(blocks), strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// mdBlock is one parsed presentation block of an answer's prose.
|
||||
type mdBlock struct {
|
||||
kind string // "para" | "list" | "table" | "heading"
|
||||
@@ -196,6 +256,17 @@ func listMarkerNumber(line string) int {
|
||||
// law's one-stamp-per-message shape).
|
||||
func (p *Pane) answerPresentation(m Message, styled bool, ts string, w int, right bool) []string {
|
||||
var out []string
|
||||
segs := mdBlocks(m.Content)
|
||||
// SUMMARY-FIRST (#2571 item 4): long answers open with the one-
|
||||
// line plain-english summary box, before any block.
|
||||
if len(segs) > summaryBlockThreshold {
|
||||
sum := summaryLine(segs)
|
||||
if styled {
|
||||
sum = dimStyle.Render(sum)
|
||||
}
|
||||
out = append(out, boxAround([]string{sum}, w, styled, narrationBorderStyle)...)
|
||||
out = append(out, make([]string, blockGapBlankLines)...)
|
||||
}
|
||||
var run []mdBlock
|
||||
flush := func() {
|
||||
if len(run) == 0 {
|
||||
@@ -217,23 +288,24 @@ func (p *Pane) answerPresentation(m Message, styled bool, ts string, w int, righ
|
||||
}
|
||||
out = append(out, p.bubbleBoxLines(styled, assistantBorderStyle, assistantStyle,
|
||||
runeStyle, Diamond+" ", body, ts, w, right)...)
|
||||
out = append(out, make([]string, blockGapBlankLines)...)
|
||||
ts = "" // one stamp per message
|
||||
run = nil
|
||||
}
|
||||
for _, seg := range mdBlocks(m.Content) {
|
||||
for _, seg := range segs {
|
||||
switch seg.kind {
|
||||
case "table":
|
||||
flush()
|
||||
out = append(out, tableLines(seg.rows, w, styled, roleBorderStyle(m.Role))...)
|
||||
out = append(out, "")
|
||||
out = append(out, make([]string, blockGapBlankLines)...)
|
||||
case "heading":
|
||||
flush()
|
||||
out = append(out, headingBoxLines(seg.text, w, styled, assistantStyle, roleBorderStyle(m.Role))...)
|
||||
out = append(out, "")
|
||||
out = append(out, make([]string, blockGapBlankLines)...)
|
||||
case "list":
|
||||
flush()
|
||||
out = append(out, listBoxLines(seg, w, styled, assistantStyle, roleBorderStyle(m.Role))...)
|
||||
out = append(out, "")
|
||||
out = append(out, make([]string, blockGapBlankLines)...)
|
||||
default:
|
||||
run = append(run, seg)
|
||||
}
|
||||
@@ -266,7 +338,15 @@ func tableLines(rows [][]string, w int, styled bool, border lipgloss.Style) []st
|
||||
}
|
||||
}
|
||||
}
|
||||
total := w - 4 // the │ pads + edge columns
|
||||
// the row budget: body row = 1 border + Σ(wc+2) + (cols-1)
|
||||
// separators + 1 border = Σw + 3*cols + 1 — so Σw ≤ w-3*cols-1.
|
||||
total := w - 3*cols - 1
|
||||
if total < cols*3 {
|
||||
// the pane is too narrow for a 3-wide-minimum table: the
|
||||
// block drops honestly rather than overflowing the pane (the
|
||||
// D53 format law — no rendered line ever exceeds the width)
|
||||
return nil
|
||||
}
|
||||
if sum := sumInt(widths); sum > total {
|
||||
scale := float64(total) / float64(sum)
|
||||
for c := range widths {
|
||||
@@ -302,7 +382,11 @@ func tableLines(rows [][]string, w int, styled bool, border lipgloss.Style) []st
|
||||
}
|
||||
out = append(out, emit(edge("┌", "┬", "┐")))
|
||||
for i, r := range rows {
|
||||
var line string
|
||||
// the row is ONE leading │, the cells separated by single │
|
||||
// columns, ONE trailing │ — the same geometry as the ┌/┬/┐
|
||||
// edge rows (the D53 format law: box rows and box edges share
|
||||
// one width, the corner misalignment defect never returns).
|
||||
cells := make([]string, cols)
|
||||
for c := 0; c < cols; c++ {
|
||||
v := cell(r, c)
|
||||
if styled {
|
||||
@@ -312,9 +396,10 @@ func tableLines(rows [][]string, w int, styled bool, border lipgloss.Style) []st
|
||||
v = assistantStyle.Render(v)
|
||||
}
|
||||
}
|
||||
line += "│ " + v + " "
|
||||
cells[c] = " " + v + " "
|
||||
}
|
||||
out = append(out, emit(line+"│"))
|
||||
line := "│" + strings.Join(cells, "│") + "│"
|
||||
out = append(out, emit(line))
|
||||
if i == 0 {
|
||||
out = append(out, emit(edge("├", "┼", "┤")))
|
||||
}
|
||||
@@ -423,10 +508,10 @@ func boxAround(rows []string, w int, styled bool, border lipgloss.Style) []strin
|
||||
func semantic_stripANSI(s string) string { return stripANSI(s) }
|
||||
|
||||
var assistantHeaderStyle = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#7EE787")).Bold(true)
|
||||
Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#7EE787")).Bold(true)
|
||||
|
||||
// assistantRailStyle is the rail's speaker-coloured bar (B2.3).
|
||||
var assistantRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
|
||||
var assistantRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
|
||||
// roleBorderStyle is the SPEAKER's outline colour (B2.1/2.2: the
|
||||
// bubble outline and the table border both carry the speaker's
|
||||
@@ -445,4 +530,4 @@ func roleBorderStyle(role string) lipgloss.Style {
|
||||
}
|
||||
|
||||
// narrationBorderStyle is the narration family's dim outline.
|
||||
var narrationBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#78788A"))
|
||||
var narrationBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
|
||||
@@ -174,8 +174,11 @@ func TestLongAnswerNeverBreaksBubble(t *testing.T) {
|
||||
bottoms++
|
||||
}
|
||||
}
|
||||
if tops != 1 || bottoms != 1 {
|
||||
t.Fatalf("a long answer must NEVER break the bubble (#2530a): %d tops, %d bottoms", tops, bottoms)
|
||||
// D54 summary-first: the 20-block answer opens with its one-line
|
||||
// summary box (a second top), but the BUBBLE itself is never
|
||||
// broken — exactly one content bubble beyond the summary.
|
||||
if tops != 2 || bottoms != 2 {
|
||||
t.Fatalf("a long answer must NEVER break the bubble (#2530a; the D54 summary box rides beside it): %d tops, %d bottoms", tops, bottoms)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+100
-15
@@ -34,8 +34,10 @@ package chatpane
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"math/rand"
|
||||
"os"
|
||||
"rune.dev/rune/tui/themes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -105,7 +107,7 @@ type Style struct {
|
||||
|
||||
// DefaultStyle is the D41 amalgamation.
|
||||
func DefaultStyle() Style {
|
||||
return Style{Bubbles: true, Vocab: Vocab, Accent: "#FF79C6"}
|
||||
return Style{Bubbles: true, Vocab: Vocab, Accent: themes.RoleHex(themes.RoleSpeaker)}
|
||||
}
|
||||
|
||||
// ── D43: the direction law + the timestamp law ────────────────────────
|
||||
@@ -177,6 +179,11 @@ type Message struct {
|
||||
Content string
|
||||
Ts string
|
||||
Importance int
|
||||
// streaming marks the progressive-render bubble (Gitea #2556):
|
||||
// ONE pending assistant bubble whose content is replaced as the
|
||||
// model's bytes arrive; the durable final Append replaces it —
|
||||
// never a duplicate.
|
||||
streaming bool
|
||||
}
|
||||
|
||||
// Pane is the chat lane's bounded transcript.
|
||||
@@ -200,6 +207,10 @@ type Pane struct {
|
||||
cycle []rune
|
||||
shuffles int64
|
||||
// scrollState is the scrollback law's display state (scroll.go).
|
||||
// streamOpen/streamIdx mark the ONE progressive-render bubble
|
||||
// (Gitea #2556) while a streamed turn is in flight.
|
||||
streamOpen bool
|
||||
streamIdx int
|
||||
scrollState
|
||||
// scrollFade is the scrollbar layer's fade countdown (scroll.go's
|
||||
// scrollbar layer): visible-on-activity, transparent-at-rest.
|
||||
@@ -214,7 +225,43 @@ type Pane struct {
|
||||
}
|
||||
|
||||
// New returns an empty pane in the default (amalgamation) style.
|
||||
// ApplyCalm selects the presentation mode (D54 #2571 item 5): vivid
|
||||
// is the DEFAULT; calm mutes the chroma AT THE TOKEN LAYER (one file:
|
||||
// tui/themes/roles.go) and re-resolves this package's style sites
|
||||
// from the role tokens.
|
||||
func ApplyCalm(on bool) {
|
||||
themes.SetCalm(on)
|
||||
refreshRoleStyles()
|
||||
}
|
||||
|
||||
// refreshRoleStyles re-resolves the package-level role styles from
|
||||
// the themes tokens (they are package vars, resolved once at init —
|
||||
// a presentation flip must re-resolve them).
|
||||
func refreshRoleStyles() {
|
||||
bg := lipgloss.Color(themes.RoleHex(themes.RoleBackground))
|
||||
dim := lipgloss.Color(themes.RoleHex(themes.RoleMetaDim))
|
||||
userStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#7FD4FF"))
|
||||
assistantStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#7EE787"))
|
||||
systemStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#A0A0B2"))
|
||||
confirmStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#E3B341"))
|
||||
researchStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#58A6FF"))
|
||||
errorStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#F87171"))
|
||||
toolStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#B8B85E"))
|
||||
dimStyle = lipgloss.NewStyle().Background(bg).Foreground(dim)
|
||||
runeStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#C4A0FF"))
|
||||
tsStyle = lipgloss.NewStyle().Background(bg).Foreground(dim)
|
||||
accentLiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
userBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF9ED8"))
|
||||
assistantBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
confirmBorderStyle = lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
assistantHeaderStyle = lipgloss.NewStyle().
|
||||
Background(bg).Foreground(lipgloss.Color("#7EE787")).Bold(true)
|
||||
assistantRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
narrationBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
|
||||
}
|
||||
|
||||
func New() *Pane {
|
||||
refreshRoleStyles()
|
||||
p := &Pane{seen: map[string]bool{}, open: map[string]bool{}, expanded: map[string]bool{}}
|
||||
p.SetStyle(DefaultStyle())
|
||||
return p
|
||||
@@ -247,6 +294,34 @@ func (p *Pane) rebuildCycle() {
|
||||
// Len reports the retained message count.
|
||||
func (p *Pane) Len() int { return len(p.msgs) }
|
||||
|
||||
// UpsertStream installs or extends the progressive-render assistant
|
||||
// bubble (Gitea #2556): the model's bytes render AS THEY ARRIVE — the
|
||||
// bubble's content is replaced with the running total, the transcript
|
||||
// grows no duplicates. It reports whether the transcript changed.
|
||||
func (p *Pane) UpsertStream(content string) bool {
|
||||
if p.streamOpen && p.streamIdx >= 0 && p.streamIdx < len(p.msgs) {
|
||||
p.msgs[p.streamIdx].Content = content
|
||||
return true
|
||||
}
|
||||
p.msgs = append(p.msgs, Message{Role: RoleAssistant, Content: content, streaming: true})
|
||||
p.streamIdx = len(p.msgs) - 1
|
||||
p.streamOpen = true
|
||||
return true
|
||||
}
|
||||
|
||||
// DropStream retires the streaming bubble without a durable answer
|
||||
// (the dispatch failed): the partial render goes with the honest
|
||||
// error bubble the caller appends. Reports whether one was open.
|
||||
func (p *Pane) DropStream() bool {
|
||||
if !p.streamOpen || p.streamIdx < 0 || p.streamIdx >= len(p.msgs) {
|
||||
return false
|
||||
}
|
||||
p.msgs = append(p.msgs[:p.streamIdx], p.msgs[p.streamIdx+1:]...)
|
||||
p.streamOpen = false
|
||||
p.streamIdx = 0
|
||||
return true
|
||||
}
|
||||
|
||||
// Messages returns a copy of the retained transcript, oldest first.
|
||||
func (p *Pane) Messages() []Message {
|
||||
return append([]Message(nil), p.msgs...)
|
||||
@@ -264,6 +339,16 @@ func (p *Pane) Messages() []Message {
|
||||
// thinking stream per message). It reports whether the transcript
|
||||
// changed.
|
||||
func (p *Pane) Append(m Message) bool {
|
||||
// the streaming bubble's durable adoption (Gitea #2556): the
|
||||
// final assistant append REPLACES the progressive-render bubble —
|
||||
// one fact, never a duplicate.
|
||||
if m.Role == RoleAssistant && m.ID == "" && p.streamOpen {
|
||||
p.msgs[p.streamIdx].Content = m.Content
|
||||
p.msgs[p.streamIdx].Ts = m.Ts
|
||||
p.msgs[p.streamIdx].streaming = false
|
||||
p.streamOpen = false
|
||||
return true
|
||||
}
|
||||
key := "id:" + m.ID
|
||||
if m.ID == "" {
|
||||
key = "rc:" + m.Role + "\x00" + m.Content
|
||||
@@ -679,7 +764,7 @@ func (p *Pane) spin() string {
|
||||
func (p *Pane) accentPhases() []lipgloss.Style {
|
||||
hex := strings.TrimPrefix(p.style.Accent, "#")
|
||||
if hex == "" {
|
||||
hex = "FF79C6"
|
||||
hex = strings.TrimPrefix(themes.RoleHex(themes.RoleSpeaker), "#")
|
||||
}
|
||||
v, err := strconv.ParseUint(hex, 16, 32)
|
||||
if err != nil || len(hex) != 6 {
|
||||
@@ -958,35 +1043,35 @@ var (
|
||||
// assistant effectively invisible). Families kept: cyan=you,
|
||||
// green=rune/results (D35), grey=system, gold=confirm,
|
||||
// blue=research, red=errors, olive=tool.
|
||||
userStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#7FD4FF"))
|
||||
assistantStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#7EE787"))
|
||||
systemStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#A0A0B2"))
|
||||
confirmStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#E3B341"))
|
||||
researchStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#58A6FF"))
|
||||
errorStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#F87171"))
|
||||
toolStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#B8B85E"))
|
||||
dimStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#78788A"))
|
||||
userStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#7FD4FF"))
|
||||
assistantStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#7EE787"))
|
||||
systemStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#A0A0B2"))
|
||||
confirmStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#E3B341"))
|
||||
researchStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#58A6FF"))
|
||||
errorStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#F87171"))
|
||||
toolStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#B8B85E"))
|
||||
dimStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
|
||||
// runeStyle is Rune's identity marker (D35): the runic glyph rides
|
||||
// the existing purple accent family (135 — the focused-border tone).
|
||||
runeStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#C4A0FF"))
|
||||
runeStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color("#C4A0FF"))
|
||||
// tsStyle is the dim timestamp beside each message (D35; the D43
|
||||
// addendum's emphasis pass: Faint on top of the floor-legal dim
|
||||
// tone — the terminal's own de-emphasis keeps the stamp unobtrusive
|
||||
// while the floor-legal tone keeps it legible when attended to).
|
||||
tsStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#78788A")).Faint(true)
|
||||
tsStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleMetaDim)))
|
||||
// accentLiveStyle is the default accent (D41 pink #FF79C6, 6.9:1)
|
||||
// — the breathing marker's full-brightness phase.
|
||||
accentLiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
|
||||
accentLiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
// D43.9 bubble borders: SOLID (the rounded glyphs are solid) and
|
||||
// the PINK FAMILY — the same family as the composer's lit outline;
|
||||
// identity keeps speaking by side and content color (D35). Both
|
||||
// tones ≥4.5:1 on the base (the readability floor).
|
||||
userBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF9ED8"))
|
||||
assistantBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
|
||||
assistantBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
// confirmBorderStyle: the D43.9 law — ALL boxes carry the solid
|
||||
// PINK FAMILY outline (the earlier gold was the outline-color
|
||||
// defect); the confirm's warning text keeps its gold tone inside.
|
||||
confirmBorderStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#FF79C6"))
|
||||
confirmBorderStyle = lipgloss.NewStyle().Background(lipgloss.Color(themes.RoleHex(themes.RoleBackground))).Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
)
|
||||
|
||||
// stampLine renders one text line with its timestamp in the FAR-LEFT
|
||||
|
||||
@@ -73,8 +73,8 @@ func TestUserLeftAssistantRight(t *testing.T) {
|
||||
for len(lines) > 0 && strings.TrimRight(lines[0], " ") == "" {
|
||||
lines = lines[1:]
|
||||
}
|
||||
if len(lines) != 6 { // 3 bubble rows each
|
||||
t.Fatalf("lines = %d, want 6 (two rounded bubbles):\n%v", len(lines), lines)
|
||||
if len(lines) != 7 { // 3 bubble rows each + the D54 spacing law's trailing block gap
|
||||
t.Fatalf("lines = %d, want 7 (two rounded bubbles + one block gap):\n%v", len(lines), lines)
|
||||
}
|
||||
if got := lines[1]; !strings.HasPrefix(got, "│ left") {
|
||||
t.Errorf("user must hug the LEFT (D43.4 LTR), bare content, got %q", got)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package chatpane
|
||||
|
||||
// d53_longanswer_test.go — the LONG-ANSWER FORMAT BREAK wave (Gitea
|
||||
// #2568 item 1, red-first): a LONG multi-block answer (20+ blocks,
|
||||
// mixed types) driven through the render path at narrow widths must
|
||||
// hold format everywhere. The synthesis law it asserts (research/
|
||||
// factory-nuerodivergent-presentation): CHUNKED, CONSISTENT GEOMETRY
|
||||
// — every rendered line fits the pane width (no clipping, no
|
||||
// overflow), every box is well-formed (its top edge, its rows and its
|
||||
// bottom edge agree on one width), the summary-first box opens the
|
||||
// answer at ANY length, and appending later blocks never reflows a
|
||||
// completed block above (the never-reflow law's long-answer leg).
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
// longAnswer builds a 24-block mixed answer: prose, lists, a table,
|
||||
// headings — the shapes the parser produces for a real long turn.
|
||||
func longAnswer() string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < 6; i++ {
|
||||
fmt.Fprintf(&b, "## Section %d\n\n", i)
|
||||
fmt.Fprintf(&b, "Paragraph %d walks through the reasoning at length, carrying enough words to wrap several times at a narrow pane width so the wrap boundary math is exercised.\n\n", i)
|
||||
fmt.Fprintf(&b, "- item alpha %d with detail\n", i)
|
||||
fmt.Fprintf(&b, "- item beta %d with more detail\n\n", i)
|
||||
if i == 2 {
|
||||
b.WriteString("| col one | col two | col three |\n| --- | --- | --- |\n| r1a | r1b | r1c |\n| r2a | r2b | r2c |\n\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// lineWidths measures each rendered line's display width.
|
||||
func lineWidths(lines []string) []int {
|
||||
out := make([]int, len(lines))
|
||||
for i, l := range lines {
|
||||
out[i] = len([]rune(semantic.StripANSI(l)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// boxEdgesCount checks every box row's width matches its top edge:
|
||||
// the box's corner glyphs delimit the box; a row that runs past its
|
||||
// top edge's width is a broken box (the corner misalignment the owner
|
||||
// sees as format breaking on long answers).
|
||||
func boxTopWidths(lines []string) map[int]int {
|
||||
w := map[int]int{}
|
||||
for i, l := range lines {
|
||||
plain := semantic.StripANSI(l)
|
||||
if strings.HasPrefix(plain, "╭") && strings.HasSuffix(plain, "╮") {
|
||||
w[i] = len([]rune(plain))
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func TestLongAnswerHoldsFormatAtNarrowWidths(t *testing.T) {
|
||||
p := New()
|
||||
p.Append(Message{ID: "a1", Role: RoleAssistant, Content: longAnswer()})
|
||||
for _, w := range []int{24, 28, 32, 40, 60, 80, 100} {
|
||||
lines := p.answerPresentation(p.msgs[0], false, "", w, false)
|
||||
for i, lw := range lineWidths(lines) {
|
||||
if lw > w {
|
||||
t.Fatalf("width %d: rendered line %d is %d columns (%q)", w, i, lw, lines[i])
|
||||
}
|
||||
}
|
||||
// box integrity: for every box top at line i, the box's rows
|
||||
// and its bottom edge must not run past the top edge's width.
|
||||
tops := boxTopWidths(lines)
|
||||
for i := range tops {
|
||||
if i+1 >= len(lines) {
|
||||
continue
|
||||
}
|
||||
tw := tops[i]
|
||||
for j := i + 1; j < len(lines); j++ {
|
||||
plain := semantic.StripANSI(lines[j])
|
||||
if strings.HasPrefix(plain, "╰") && strings.HasSuffix(plain, "╯") {
|
||||
if len([]rune(plain)) != tw {
|
||||
t.Fatalf("width %d: box bottom (line %d) is %d cols, top %d", w, j, len([]rune(plain)), tw)
|
||||
}
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(plain, "╭") && strings.HasSuffix(plain, "╮") {
|
||||
break // a new box opened: the previous one ended
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongAnswerSummaryFirstAtAnyLength(t *testing.T) {
|
||||
p := New()
|
||||
p.Append(Message{ID: "a1", Role: RoleAssistant, Content: longAnswer()})
|
||||
lines := p.answerPresentation(p.msgs[0], false, "", 60, false)
|
||||
head := strings.Join(lines[:min(len(lines), 3)], "\n")
|
||||
if !strings.Contains(head, "Summary:") {
|
||||
t.Fatalf("a 20+ block answer must open with the summary box, got %q", head)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongAnswerNoReflowOnAppend(t *testing.T) {
|
||||
p := New()
|
||||
p.Append(Message{ID: "a1", Role: RoleAssistant, Content: longAnswer()})
|
||||
before := p.answerPresentation(p.msgs[0], false, "", 60, false)
|
||||
p.Append(Message{ID: "a2", Role: RoleAssistant, Content: "A short later answer."})
|
||||
after := p.answerPresentation(p.msgs[0], false, "", 60, false)
|
||||
if len(before) == 0 || len(after) < len(before) {
|
||||
t.Fatalf("append must not shrink the completed blocks")
|
||||
}
|
||||
for i := range before {
|
||||
if before[i] != after[i] {
|
||||
t.Fatalf("completed block line %d reflowed on append:\nbefore %q\nafter %q", i, before[i], after[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package chatpane
|
||||
|
||||
import "testing"
|
||||
|
||||
// D54 item 1 (Gitea #2571): THE NEVER-ANIMATE LAW of the chat pane —
|
||||
// the pane holds the reading content, so it never animates. The
|
||||
// append-only assertion: appending a message must NOT change a single
|
||||
// already-rendered line of the completed blocks above it (stream
|
||||
// append never reflows completed blocks; the top-anchored stay-put
|
||||
// scroll law in scroll.go rides the same contract).
|
||||
|
||||
func TestAppendNeverReflowsCompletedBlocks(t *testing.T) {
|
||||
p := New()
|
||||
p.Append(Message{Role: RoleAssistant, Content: "First answer, a complete paragraph of prose.\n\n- one point\n- another point"})
|
||||
p.Append(Message{Role: RoleUser, Content: "A user reply."})
|
||||
|
||||
w := 60
|
||||
before, _, _ := p.renderLines(w, true)
|
||||
p.Append(Message{Role: RoleAssistant, Content: "Second answer with entirely different content.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```go\ncode\n```"})
|
||||
after, _, _ := p.renderLines(w, true)
|
||||
|
||||
if len(after) <= len(before) {
|
||||
t.Fatalf("append must grow the transcript: %d <= %d", len(after), len(before))
|
||||
}
|
||||
for i := 0; i < len(before); i++ {
|
||||
// completed blocks keep their exact rendered lines; the tail
|
||||
// of the stream may show newly appended content only.
|
||||
if before[i] != after[i] {
|
||||
t.Fatalf("append reflowed completed block line %d — the never-animate law forbids it:\nbefore: %q\nafter: %q", i, before[i], after[i])
|
||||
}
|
||||
}
|
||||
p2 := New()
|
||||
p2.Append(Message{Role: RoleAssistant, Content: "First answer, a complete paragraph of prose.\n\n- one point\n- another point"})
|
||||
p2.Append(Message{Role: RoleUser, Content: "A user reply."})
|
||||
plainBefore, _, _ := p2.renderLines(w, false)
|
||||
p2.Append(Message{Role: RoleAssistant, Content: "Second answer with entirely different content."})
|
||||
plainAfter, _, _ := p2.renderLines(w, false)
|
||||
for i := 0; i < len(plainBefore); i++ {
|
||||
if plainBefore[i] != plainAfter[i] {
|
||||
t.Fatalf("plain-path append reflowed completed block line %d: %q vs %q", i, plainBefore[i], plainAfter[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package chatpane
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D54 item 3 (Gitea #2571): THE SPACING LAW — exactly ONE blank line
|
||||
// between blocks; block types never merge (a list is a list, a
|
||||
// paragraph a paragraph; the parser never joins them; the renderer
|
||||
// never abuts their boxes).
|
||||
|
||||
func TestOneBlankLineBetweenBlocks(t *testing.T) {
|
||||
p := New()
|
||||
out := p.answerPresentation(Message{Role: RoleAssistant, Content: "Intro paragraph before the list.\n\n- first item\n- second item\n\n| h1 | h2 |\n| --- | --- |\n| a | b |\n\nClosing paragraph after the table."}, false, "", 60, false)
|
||||
// no two box borders ever abut, and never two blanks in a row
|
||||
for i := 1; i < len(out); i++ {
|
||||
a, b := strings.TrimSpace(out[i-1]), strings.TrimSpace(out[i])
|
||||
if a == "" && b == "" {
|
||||
t.Fatalf("two blank lines adjacent at %d — the spacing law allows exactly one:\n%q", i, strings.Join(out, "\n"))
|
||||
}
|
||||
if (strings.HasPrefix(a, "╭") && strings.HasPrefix(b, "╭")) ||
|
||||
(strings.HasPrefix(a, "┌") && strings.HasPrefix(b, "┌")) {
|
||||
t.Fatalf("two block boxes abut at %d — blocks never merge:\n%q", i, strings.Join(out, "\n"))
|
||||
}
|
||||
}
|
||||
// and between every pair of block regions there is a blank line
|
||||
joined := strings.Join(out, "\n")
|
||||
for _, sep := range []string{"┘\n╭", "╯\n╭", "╯\n┌", "┘\n┌"} {
|
||||
if strings.Contains(joined, sep) {
|
||||
t.Fatalf("blocks abut without the one-blank-line gap (%q):\n%s", sep, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockTypesNeverMerge(t *testing.T) {
|
||||
blocks := mdBlocks("- alpha\n- beta\nplain paragraph text\n- gamma\n- delta")
|
||||
var sawList, sawPara bool
|
||||
for _, b := range blocks {
|
||||
switch b.kind {
|
||||
case "list":
|
||||
sawList = true
|
||||
for _, it := range b.items {
|
||||
if it == "plain paragraph text" {
|
||||
t.Fatal("a paragraph merged into a list block — types never merge")
|
||||
}
|
||||
}
|
||||
case "para":
|
||||
sawPara = true
|
||||
if strings.Contains(b.text, "alpha") || strings.Contains(b.text, "delta") {
|
||||
t.Fatal("a list merged into the paragraph block — types never merge")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawList || !sawPara {
|
||||
t.Fatalf("expected distinct list and para blocks, got %+v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockGapIsPureLayoutConstant(t *testing.T) {
|
||||
if blockGapBlankLines != 1 {
|
||||
t.Fatalf("the spacing law fixes ONE blank line between blocks, got %d", blockGapBlankLines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package chatpane
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D54 item 4 (Gitea #2571): SUMMARY-FIRST — an answer longer than the
|
||||
// threshold opens with a ONE-LINE plain-english summary box: what the
|
||||
// answer contains, counted literally, before the blocks (COGA 4.4.8;
|
||||
// the generalize of the "code (n lines)" collapse).
|
||||
|
||||
func TestLongAnswerOpensWithSummaryBox(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < 9; i++ {
|
||||
b.WriteString("Paragraph block of prose the reader has not read yet.\n\n")
|
||||
}
|
||||
p := New()
|
||||
out := p.answerPresentation(Message{Role: RoleAssistant, Content: b.String()}, false, "", 70, false)
|
||||
joined := strings.Join(out, "\n")
|
||||
if !strings.Contains(joined, "Summary:") {
|
||||
t.Fatalf("a 9-block answer must open with a one-line plain-english summary:\n%s", joined)
|
||||
}
|
||||
// the summary rides FIRST — before the first content box
|
||||
if out[0] != "╭"[:1]+"" {
|
||||
if !strings.HasPrefix(out[0], "╭") {
|
||||
t.Fatalf("the summary box must be the first block, got %q", out[0])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out[1], "Summary:") {
|
||||
t.Fatalf("summary must be one line, got %q", out[1])
|
||||
}
|
||||
// literal wording: the count is in the line
|
||||
if !strings.Contains(out[1], "9") {
|
||||
t.Fatalf("the summary must count the blocks literally, got %q", out[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortAnswerHasNoSummary(t *testing.T) {
|
||||
p := New()
|
||||
out := p.answerPresentation(Message{Role: RoleAssistant, Content: "One short paragraph."}, false, "", 70, false)
|
||||
if strings.Contains(strings.Join(out, "\n"), "Summary:") {
|
||||
t.Fatalf("a 1-block answer must not carry a summary box:\n%s", strings.Join(out, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryThresholdConstant(t *testing.T) {
|
||||
if summaryBlockThreshold != 8 {
|
||||
t.Fatalf("the summary threshold is tuned to 8 blocks, got %d", summaryBlockThreshold)
|
||||
}
|
||||
}
|
||||
+35
-13
@@ -23,6 +23,10 @@ package chatpane
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"rune.dev/rune/tui/themes"
|
||||
)
|
||||
|
||||
// scrollState is the pane's scroll display state (embedded in Pane).
|
||||
@@ -284,11 +288,32 @@ func (p *Pane) ScrollbarVisible() bool {
|
||||
// transcript. Returns "" when the layer is not visible (transparent at
|
||||
// rest).
|
||||
func (p *Pane) ScrollbarRow(w int) string {
|
||||
if !p.ScrollbarVisible() {
|
||||
rows := p.ScrollbarRows()
|
||||
if rows == nil {
|
||||
return ""
|
||||
}
|
||||
out := ""
|
||||
for _, r := range rows {
|
||||
if r == "" {
|
||||
out += " "
|
||||
continue
|
||||
}
|
||||
out += "▐"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ScrollbarRows renders the scrollbar layer as ONE string per pane row
|
||||
// (the D53 #2568 repair): the thumb's rows carry the RoleScroll token
|
||||
// colour (the D54 colour law — the bar is scheme-matched yet distinct,
|
||||
// a role of its own), every other row is "" (no overlay). Nil when the
|
||||
// layer is not visible.
|
||||
func (p *Pane) ScrollbarRows() []string {
|
||||
if !p.ScrollbarVisible() {
|
||||
return nil
|
||||
}
|
||||
if p.scrollState.lastLines <= 0 || p.scrollState.lastRows <= 0 {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
total := max(p.scrollState.lastLines, 1)
|
||||
visible := max(p.scrollState.lastRows-1, 1)
|
||||
@@ -302,16 +327,13 @@ func (p *Pane) ScrollbarRow(w int) string {
|
||||
maxOff := max(total-visible, 1)
|
||||
pos = int(float64(p.scrollState.lastRows-thumb) * (float64(p.scrollState.offset) / float64(maxOff)))
|
||||
}
|
||||
row := make([]rune, p.scrollState.lastRows)
|
||||
for i := range row {
|
||||
row[i] = ' '
|
||||
rows := make([]string, p.scrollState.lastRows)
|
||||
for i := pos; i < pos+thumb && i < len(rows); i++ {
|
||||
rows[i] = scrollThumbStyle.Render("▐")
|
||||
}
|
||||
for i := pos; i < pos+thumb && i < len(row); i++ {
|
||||
row[i] = '▐'
|
||||
}
|
||||
out := string(row)
|
||||
if w > 0 && len(out) > w {
|
||||
out = out[:w]
|
||||
}
|
||||
return out
|
||||
return rows
|
||||
}
|
||||
|
||||
// scrollThumbStyle is the thumb's token colour: RoleScroll, defined
|
||||
// once in themes (the D54 law — one hex per role).
|
||||
var scrollThumbStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleScroll)))
|
||||
@@ -0,0 +1,58 @@
|
||||
package chatpane
|
||||
|
||||
// stream_render_test.go — the progressive-render bubble's frozen law
|
||||
// (Gitea #2556, red-first): one streaming bubble grows with the
|
||||
// running total, the durable final append REPLACES it (never a
|
||||
// duplicate), and a failed dispatch retires it.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpsertStreamGrowsOneBubble(t *testing.T) {
|
||||
p := New()
|
||||
if !p.UpsertStream("hel") {
|
||||
t.Fatal("first upsert reported no change")
|
||||
}
|
||||
p.UpsertStream("hello")
|
||||
if p.Len() != 1 {
|
||||
t.Fatalf("transcript = %d messages, want ONE streaming bubble", p.Len())
|
||||
}
|
||||
if p.Messages()[0].Content != "hello" {
|
||||
t.Fatalf("content = %q", p.Messages()[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalAppendAdoptsStreamBubble(t *testing.T) {
|
||||
p := New()
|
||||
p.UpsertStream("hel")
|
||||
p.UpsertStream("hello wo")
|
||||
ok := p.Append(Message{Role: RoleAssistant, Content: "hello world", Ts: "t"})
|
||||
if !ok {
|
||||
t.Fatal("final append reported no change")
|
||||
}
|
||||
if p.Len() != 1 {
|
||||
t.Fatalf("transcript = %d messages, want the adopted ONE", p.Len())
|
||||
}
|
||||
m := p.Messages()[0]
|
||||
if m.Content != "hello world" || m.Ts != "t" || m.streaming {
|
||||
t.Fatalf("adopted bubble = %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropStreamRetiresPartial(t *testing.T) {
|
||||
p := New()
|
||||
p.UpsertStream("par")
|
||||
if !p.DropStream() {
|
||||
t.Fatal("drop reported no change")
|
||||
}
|
||||
if p.Len() != 0 {
|
||||
t.Fatalf("transcript = %d, want empty", p.Len())
|
||||
}
|
||||
if p.DropStream() {
|
||||
t.Fatal("second drop reported change")
|
||||
}
|
||||
// a fresh stream can open after the drop
|
||||
p.UpsertStream("new")
|
||||
if p.Len() != 1 {
|
||||
t.Fatalf("reopen = %d messages", p.Len())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
// d50_pty_test.go — the D50-residual PTY proofs (#2560): the REAL face
|
||||
// binary on a REAL pseudo-terminal (the boot_smoke method) proves the
|
||||
// two owner-critical behaviors:
|
||||
//
|
||||
// 1. ENTER DISPATCH (D50 residual 3): with the tasks pane focused and
|
||||
// the tree region OPEN (the /tree road — the click road's keyboard
|
||||
// parity), typing a work demand and pressing enter DISPATCHES it
|
||||
// through the task-queue admission surface (the honest transport
|
||||
// receipt renders) — the region never steals enter.
|
||||
// 2. PIN PERSISTENCE (D50 residual 4): a pin recorded in the
|
||||
// hot-state store (tasks_pins.json) before boot renders in the
|
||||
// reserved pinned area after a REAL boot — the pins survive.
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
func TestD50PtyEnterDispatchAndPinPersistence(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("boots the real face (build + pty); skipped in -short")
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skipf("pty boot method is linux (/dev/ptmx); not %s", runtime.GOOS)
|
||||
}
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("no go toolchain in PATH — cannot build the face")
|
||||
}
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("cannot locate module root")
|
||||
}
|
||||
tuiRoot := filepath.Dir(filepath.Dir(filepath.Dir(thisFile)))
|
||||
repoRoot := filepath.Dir(tuiRoot)
|
||||
|
||||
bin := filepath.Join(t.TempDir(), "rune-tui")
|
||||
build := exec.Command("go", "build", "-o", bin, "./cmd/rune-tui")
|
||||
build.Dir = tuiRoot
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("face build failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
master, slave, err := openPty()
|
||||
if err != nil {
|
||||
t.Fatalf("pty: %v", err)
|
||||
}
|
||||
defer master.Close()
|
||||
|
||||
sandbox := t.TempDir()
|
||||
// the pre-written pin record (the hot-state store's boot leg): a
|
||||
// pin that must render after the REAL boot.
|
||||
pin := `{"pins":[{"id":"pin_1","task_id":"task_1","summary":"keep the reflow squash test green","ts":"2026-10-22T12:00:00Z"}]}` + "\n"
|
||||
if err := os.WriteFile(filepath.Join(sandbox, "tasks_pins.json"), []byte(pin), 0o600); err != nil {
|
||||
t.Fatalf("pin record: %v", err)
|
||||
}
|
||||
if err := saveProviderSelection(sandbox, providerSelection{
|
||||
Provider: "local", Model: "test-model", SelectedAt: "2026-10-19T00:00:00Z"}); err != nil {
|
||||
t.Fatalf("provider record: %v", err)
|
||||
}
|
||||
cmd := exec.Command(bin)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
|
||||
cmd.Env = append(os.Environ(),
|
||||
"RUNE_LIB_REPO="+repoRoot,
|
||||
"RUNE_HOME="+sandbox,
|
||||
"RUNE_MEMORY_DIR="+filepath.Join(sandbox, "memory"),
|
||||
"TERM=xterm-256color",
|
||||
)
|
||||
if err := cmd.Start(); err != nil {
|
||||
slave.Close()
|
||||
t.Fatalf("face start: %v", err)
|
||||
}
|
||||
slave.Close()
|
||||
exited := make(chan error, 1)
|
||||
go func() { exited <- cmd.Wait() }()
|
||||
|
||||
var seen strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
waitFor := func(markers []string, budget time.Duration) {
|
||||
deadline := time.Now().Add(budget)
|
||||
for time.Now().Before(deadline) {
|
||||
plainNow := semantic.StripANSI(seen.String())
|
||||
found := false
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(plainNow, marker) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case err := <-exited:
|
||||
t.Fatalf("face exited (rc=%v) waiting for %v — output:\n%.2000s",
|
||||
err, markers, semantic.StripANSI(seen.String()))
|
||||
default:
|
||||
}
|
||||
if err := master.SetReadDeadline(time.Now().Add(1 * time.Second)); err != nil {
|
||||
t.Fatalf("pty read deadline: %v", err)
|
||||
}
|
||||
n, err := master.Read(buf)
|
||||
if n > 0 {
|
||||
seen.Write(buf[:n])
|
||||
}
|
||||
if err != nil && !os.IsTimeout(err) {
|
||||
t.Fatalf("pty read: %v — output:\n%.2000s", err,
|
||||
semantic.StripANSI(seen.String()))
|
||||
}
|
||||
}
|
||||
_ = cmd.Process.Kill()
|
||||
<-exited
|
||||
t.Fatalf("never saw any of %v within %v — output:\n%.2000s",
|
||||
markers, budget, semantic.StripANSI(seen.String()))
|
||||
}
|
||||
|
||||
waitFor([]string{"┌"}, 240*time.Second)
|
||||
|
||||
// 2. PIN PERSISTENCE (boot leg): open the tasks pane's region via
|
||||
// the /tree road — the pre-written pin must render.
|
||||
if _, err := master.Write([]byte("/tree\r")); err != nil {
|
||||
t.Fatalf("/tree write: %v", err)
|
||||
}
|
||||
waitFor([]string{"pinned (1)"}, 60*time.Second)
|
||||
plain := semantic.StripANSI(seen.String())
|
||||
if !strings.Contains(plain, "keep the reflow squash test green") {
|
||||
t.Fatalf("the pin's plain-english summary must render:\n%.2000s", plain)
|
||||
}
|
||||
|
||||
// 1. ENTER DISPATCH under the OPEN region: focus tasks, type the
|
||||
// demand, enter. The admission surface is real (the library seam);
|
||||
// its honest verdict — delegated, queued, or the honest transport
|
||||
// refusal — must land. Silence would be the stolen enter.
|
||||
if _, err := master.Write([]byte("2")); err != nil {
|
||||
t.Fatalf("focus write: %v", err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
waitFor([]string{"task entry"}, 30*time.Second)
|
||||
if _, err := master.Write([]byte("wire the stream\r")); err != nil {
|
||||
t.Fatalf("demand write: %v", err)
|
||||
}
|
||||
waitFor([]string{"work queued (", "work started:", "work demand not admitted", "no task-queue surface on this seam"}, 60*time.Second)
|
||||
_ = cmd.Process.Kill()
|
||||
<-exited
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
// d52_picker_window_test.go — the D52 model-picker WINDOW law (#2567,
|
||||
// owner spec): clicking the info bar's model token opens a SEPARATE
|
||||
// CENTERED window that is fully selectable and searchable, split
|
||||
// CLOUD and LOCAL sections — type-to-filter, up/down through results,
|
||||
// enter selects, the sections labelled. Keyboard parity: ctrl+m keeps
|
||||
// working; the window road is the click. NO crossfire: a model-token
|
||||
// click never opens the activity (thinking) window, and a + click
|
||||
// never opens the picker.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"rune.dev/rune/tui/runtime/lib"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
func pickerFixture(t *testing.T) model {
|
||||
t.Helper()
|
||||
m := sized(t, initialModel(), 80, 24)
|
||||
m.prov = &providerSelection{Provider: "local", Model: "qwen3.6-27b"}
|
||||
m.provOpts = []lib.ModelRow{
|
||||
{ID: "gpt-4o-mini", OwnedBy: "openai"},
|
||||
{ID: "claude-sonnet-4", OwnedBy: "anthropic"},
|
||||
{ID: "qwen2.5:7b", OwnedBy: "ollama"},
|
||||
{ID: "llava:7b", OwnedBy: "ollama"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestPickerWindowSplitsCloudAndLocal — the picker's rendered rows
|
||||
// carry the CLOUD and LOCAL section labels, the cloud rows first in
|
||||
// the cloud section, locals under LOCAL, and the selection cursor
|
||||
// walks BOTH sections as one list.
|
||||
func TestPickerWindowSplitsCloudAndLocal(t *testing.T) {
|
||||
m := pickerFixture(t)
|
||||
m.openPicker()
|
||||
rows := m.face.MenuSnapshot()
|
||||
joined := strings.Join(rows, "\n")
|
||||
cloudIdx := strings.Index(joined, "CLOUD")
|
||||
localIdx := strings.Index(joined, "LOCAL")
|
||||
if cloudIdx < 0 || localIdx < 0 {
|
||||
t.Fatalf("the picker window must carry CLOUD and LOCAL sections:\n%s", joined)
|
||||
}
|
||||
if cloudIdx > localIdx {
|
||||
t.Fatal("CLOUD section renders before LOCAL")
|
||||
}
|
||||
gpt := strings.Index(joined, "gpt-4o-mini")
|
||||
qwen := strings.Index(joined, "qwen2.5:7b")
|
||||
if !(cloudIdx < gpt && gpt < localIdx && localIdx < qwen) {
|
||||
t.Fatalf("cloud rows must sit in the CLOUD section, locals in LOCAL:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickerWindowFilterWalksEnterChooses — the window is fully
|
||||
// selectable and searchable: typing filters across both sections,
|
||||
// up/down walks the filtered rows, enter records the pick and closes.
|
||||
func TestPickerWindowFilterWalksEnterChooses(t *testing.T) {
|
||||
m := pickerFixture(t)
|
||||
m.openPicker()
|
||||
// type to filter
|
||||
next, _ := m.Update(tea.KeyPressMsg{Code: 'q'})
|
||||
m2 := next.(model)
|
||||
rows := m2.pickerRows()
|
||||
if len(rows) == 0 || !strings.Contains(rows[0].ID, "qwen") {
|
||||
t.Fatalf("typing must filter the rows, got %+v", rows)
|
||||
}
|
||||
// enter chooses the filtered row
|
||||
pick := rows[m2.pickerSel]
|
||||
next, _ = m2.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
m3 := next.(model)
|
||||
if m3.pickerOpen {
|
||||
t.Fatal("enter must close the picker window")
|
||||
}
|
||||
if m3.prov == nil || m3.prov.Model != pick.ID {
|
||||
t.Fatalf("enter must record the pick, got %+v", m3.prov)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelTokenClickNoCrossfire — the D52 dispatch law: a click on
|
||||
// the model token opens the PICKER and NEVER the activity window; a
|
||||
// click on the + affordance opens the ACTIVITY window and never the
|
||||
// picker. Both roads, both windows, one assertion each way.
|
||||
func TestModelTokenClickNoCrossfire(t *testing.T) {
|
||||
m := pickerFixture(t)
|
||||
x0, w := modelTokenXRange(m)
|
||||
y := m.face.Snapshot().H
|
||||
next, _ := m.Update(tea.MouseClickMsg{X: x0 + w/2, Y: y})
|
||||
m2 := next.(model)
|
||||
if !m2.pickerOpen {
|
||||
t.Fatal("the model-token click must open the picker window")
|
||||
}
|
||||
if m2.face.ActivityWindowOpen() {
|
||||
t.Fatal("CROSSFIRE: the model-token click must NOT open the activity (thinking) window")
|
||||
}
|
||||
// the + road: the face's + region click toggles the activity
|
||||
// window and must not open the picker
|
||||
m3 := pickerFixture(t)
|
||||
fr := m3.face.Render()
|
||||
var reg semantic.Region
|
||||
for _, r := range fr.Regions {
|
||||
if r.ID == "mouse.activity.plus" {
|
||||
reg = r
|
||||
}
|
||||
}
|
||||
next, _ = m3.Update(tea.MouseClickMsg{X: reg.X, Y: reg.Y})
|
||||
m4 := next.(model)
|
||||
if !m4.face.ActivityWindowOpen() {
|
||||
t.Fatal("the + click must open the activity window")
|
||||
}
|
||||
if m4.pickerOpen {
|
||||
t.Fatal("CROSSFIRE: the + click must NOT open the picker")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCtrlMParity — keyboard parity: ctrl+m still opens the picker
|
||||
// window (the window road is the click; ctrl+m keeps working).
|
||||
func TestCtrlMParity(t *testing.T) {
|
||||
m := pickerFixture(t)
|
||||
next, _ := m.Update(tea.KeyPressMsg{Code: 'm', Mod: tea.ModCtrl})
|
||||
m2 := next.(model)
|
||||
if !m2.pickerOpen && !m2.menuOpen {
|
||||
t.Fatal("ctrl+m must keep opening the model chooser")
|
||||
}
|
||||
// its rendered chooser carries the split sections too
|
||||
m3 := pickerFixture(t)
|
||||
m3.openPicker()
|
||||
joined := strings.Join(m3.face.MenuSnapshot(), "\n")
|
||||
if !strings.Contains(joined, "CLOUD") || !strings.Contains(joined, "LOCAL") {
|
||||
t.Fatalf("the ctrl+m road shares the split window:\n%s", joined)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
// d53_agents_shell_test.go — the SUB-AGENTS wave's shell leg (Gitea
|
||||
// #2568 item 7): keyboard parity ('w' opens the centered window when
|
||||
// agents are in flight, the window is modal: esc/up/down/enter,
|
||||
// any other key closes) and the flight-free no-op.
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/lanes"
|
||||
)
|
||||
|
||||
func withAgentsInFlight(m model) model {
|
||||
m.face.SetWaveAgents(2, []lanes.AgentView{
|
||||
{ID: "w1", Title: "land the scrollbar wave", State: "BUILD",
|
||||
Detail: []string{"journal: admit green"}},
|
||||
{ID: "w2", Title: "polish the composer hints", State: "VERIFY"},
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestCtrlWOpensAgentsWindowInFlight(t *testing.T) {
|
||||
m := withAgentsInFlight(initialModel())
|
||||
nx, _ := m.Update(ctrlKey("w"))
|
||||
if !nx.(model).face.AgentsWindowOpen() {
|
||||
t.Fatal("ctrl+w must open the agents window while agents are in flight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlWNoopWithoutFlight(t *testing.T) {
|
||||
m := initialModel()
|
||||
nx, _ := m.Update(ctrlKey("w"))
|
||||
if nx.(model).face.AgentsWindowOpen() {
|
||||
t.Fatal("ctrl+w without a flight must not open any window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentsWindowModalKeys(t *testing.T) {
|
||||
m := withAgentsInFlight(initialModel())
|
||||
nx, _ := m.Update(ctrlKey("w"))
|
||||
m2 := nx.(model)
|
||||
// down walks the cursor
|
||||
nx, _ = m2.Update(keyPress("down"))
|
||||
m2 = nx.(model)
|
||||
// esc closes
|
||||
nx, _ = m2.Update(keyPress("esc"))
|
||||
m2 = nx.(model)
|
||||
if m2.face.AgentsWindowOpen() {
|
||||
t.Fatal("esc must close the agents window")
|
||||
}
|
||||
// any other key closes the modal too
|
||||
nx, _ = withAgentsInFlight(m).Update(keyPress("w"))
|
||||
m2 = nx.(model)
|
||||
nx, _ = m2.Update(keyPress("x"))
|
||||
m2 = nx.(model)
|
||||
if m2.face.AgentsWindowOpen() {
|
||||
t.Fatal("any other key must close the modal window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentsWindowOverlayCentersInFrame(t *testing.T) {
|
||||
m := withAgentsInFlight(initialModel())
|
||||
nx, _ := m.Update(ctrlKey("w"))
|
||||
m2 := nx.(model)
|
||||
v := m2.View()
|
||||
content := v.Content
|
||||
if !strings.Contains(content, "sub agents") || !strings.Contains(content, "land the scrollbar wave") {
|
||||
t.Fatalf("the rendered frame must carry the centered agents window")
|
||||
}
|
||||
if !strings.Contains(content, "journal: admit green") {
|
||||
t.Fatalf("the selected agent's detail must render")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
// d53_quithint_test.go — the QUIT WARNING (Gitea #2568 item 3,
|
||||
// red-first): after the FIRST ctrl+c of the D46 exit gesture a soft
|
||||
// hint "press ctrl+c again to quit" rides near the shift+enter key
|
||||
// hint (the composer's hint line). The hint disappears when the arm
|
||||
// window times out or ANY other key arrives. The SECOND ctrl+c never
|
||||
// shows it — that press quits.
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const wantQuitHint = "press ctrl+c again to quit"
|
||||
|
||||
func composerHintOf(m model) string {
|
||||
return m.face.Hint()
|
||||
}
|
||||
|
||||
func TestFirstCtrlCShowsQuitHint(t *testing.T) {
|
||||
nx, _ := initialModel().Update(ctrlKey("c"))
|
||||
got := composerHintOf(nx.(model))
|
||||
if !strings.Contains(got, wantQuitHint) {
|
||||
t.Fatalf("after the first ctrl+c the composer hint must carry the quit warning, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuitHintClearsOnTimeout(t *testing.T) {
|
||||
nx, _ := initialModel().Update(ctrlKey("c"))
|
||||
// the arm window's own reset message (the 1s tea.Tick payload)
|
||||
nx, _ = nx.Update(ctrlCArmReset{})
|
||||
got := composerHintOf(nx.(model))
|
||||
if strings.Contains(got, wantQuitHint) {
|
||||
t.Fatalf("the quit hint must disappear when the arm window times out, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuitHintClearsOnAnyOtherKey(t *testing.T) {
|
||||
nx, _ := initialModel().Update(ctrlKey("c"))
|
||||
nx, _ = nx.Update(keyPress("x"))
|
||||
got := composerHintOf(nx.(model))
|
||||
if strings.Contains(got, wantQuitHint) {
|
||||
t.Fatalf("the quit hint must disappear on any other key, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondCtrlCQuitsWithoutHint(t *testing.T) {
|
||||
nx, _ := initialModel().Update(ctrlKey("c"))
|
||||
if !quits(t, nx.(model), ctrlKey("c")) {
|
||||
t.Fatal("the second ctrl+c must quit (the D46 gesture holds)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
// d53_reflow_test.go — the DYNAMIC REFLOW wave (Gitea #2568 item 4,
|
||||
// red-first): driving a resize event through the face chain must
|
||||
// re-wrap every bubble and box at the new width — the squash test: NO
|
||||
// rendered line may exceed the new width and NO stale width may
|
||||
// survive (a line wider than the pane, or a box edge left at the old
|
||||
// geometry, is a clip).
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"rune.dev/rune/tui/chatpane"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
// frameLinesWidths renders the model's face and reports any line
|
||||
// wider than w.
|
||||
func overwideLines(t *testing.T, m model, w int) []string {
|
||||
t.Helper()
|
||||
f := m.face.Render()
|
||||
var bad []string
|
||||
for i, ln := range strings.Split(f.Content, "\n") {
|
||||
if n := len([]rune(semantic.StripANSI(ln))); n > w {
|
||||
bad = append(bad, fmt.Sprintf("line %d is %d cols: %q", i, n, ln[:min(60, len(ln))]))
|
||||
}
|
||||
}
|
||||
return bad
|
||||
}
|
||||
|
||||
func TestWindowSquashReflowsEveryBlock(t *testing.T) {
|
||||
m := initialModel()
|
||||
long := ""
|
||||
for i := 0; i < 10; i++ {
|
||||
long += fmt.Sprintf("## Heading %d\n\nParagraph %d with plenty of words to wrap several times at any pane width the squash test picks.\n\n- bullet one %d\n- bullet two %d\n\n", i, i, i, i)
|
||||
}
|
||||
m.face.AppendChat(chatpane.Message{ID: "a1", Role: chatpane.RoleAssistant, Content: long})
|
||||
m.face.Resize(120, 30)
|
||||
if bad := overwideLines(t, m, 120); len(bad) > 0 {
|
||||
t.Fatalf("at width 120: %v", bad)
|
||||
}
|
||||
// the squash: drive the resize event through the face chain
|
||||
nx, _ := m.Update(tea.WindowSizeMsg{Width: 44, Height: 20})
|
||||
m2 := nx.(model)
|
||||
if bad := overwideLines(t, m2, 44); len(bad) > 0 {
|
||||
t.Fatalf("after squash to 44: %v", bad)
|
||||
}
|
||||
// and back out: the geometry must follow the new width again
|
||||
nx, _ = m2.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
m3 := nx.(model)
|
||||
if bad := overwideLines(t, m3, 100); len(bad) > 0 {
|
||||
t.Fatalf("after regrow to 100: %v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowSquashKeepsScrollStateLegal(t *testing.T) {
|
||||
m := initialModel()
|
||||
for i := 0; i < 40; i++ {
|
||||
m.face.AppendChat(chatpane.Message{ID: fmt.Sprintf("a%d", i), Role: chatpane.RoleAssistant,
|
||||
Content: fmt.Sprintf("body line of transcript prose %d", i)})
|
||||
}
|
||||
m.face.Resize(120, 30)
|
||||
m.face.Render() // establish the scroll geometry (the last-render state)
|
||||
m.face.ScrollChatUp(10)
|
||||
if got := m.face.ChatScrollOffset(); got != 10 {
|
||||
t.Fatalf("offset must survive the pre-squash scroll, got %d", got)
|
||||
}
|
||||
nx, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 16})
|
||||
m2 := nx.(model)
|
||||
// the reflow changes line counts; the offset must normalize to a
|
||||
// legal window (never past the oldest held line, never negative)
|
||||
if off := m2.face.ChatScrollOffset(); off < 0 {
|
||||
t.Fatalf("offset went negative after squash: %d", off)
|
||||
}
|
||||
if bad := overwideLines(t, m2, 40); len(bad) > 0 {
|
||||
t.Fatalf("after squash: %v", bad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D54 item 6 (Gitea #2571): THE LITERAL-LANGUAGE AUDIT — face strings
|
||||
// (affordances, notices, placeholders, hints) use literal language:
|
||||
// no idioms, no figures of speech. This frozen audit scans the TUI's
|
||||
// user-facing string sites for the idioms the audit found and
|
||||
// rewrote:
|
||||
//
|
||||
// BEFORE: "consent enqueue in flight — wait for its receipt"
|
||||
// AFTER: "consent enqueue being processed now — wait for its receipt"
|
||||
// BEFORE: "one completion in flight — phase 1 sends one at a time"
|
||||
// AFTER: "one completion being processed now — ..."
|
||||
// BEFORE: "approval resolution in flight — wait for its receipt"
|
||||
// AFTER: "approval resolution being processed now — ..."
|
||||
// BEFORE: "the engine answered without a verdict" (legal metaphor)
|
||||
// AFTER: "the engine answered without a clear result"
|
||||
// BEFORE: "↑/↓ walk · enter choose" ("walk" a list)
|
||||
// AFTER: "↑/↓ move · enter choose"
|
||||
// BEFORE: "(none in flight)" (the + window's empty sections)
|
||||
// AFTER: "(none now)"
|
||||
// BEFORE: "state %s · %02d:%02d in flight" (the wave detail line)
|
||||
// AFTER: "state %s · running %02d:%02d"
|
||||
//
|
||||
// Kept (term of art, not idiom): "seam" (the wiring seam — the
|
||||
// synthesis itself cites "no task seam wired — work demand not sent"
|
||||
// as good literal style), "brief" (the work record's name), "card"
|
||||
// (the approval component's name).
|
||||
func TestFaceStringsAreLiteral(t *testing.T) {
|
||||
banned := []string{"in flight", "without a verdict", "walk · enter"}
|
||||
sites := []string{
|
||||
"main.go",
|
||||
"../../lanes/activity.go",
|
||||
"../../chatpane/chatpane.go",
|
||||
"../../chatpane/blocks.go",
|
||||
}
|
||||
strLit := regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
|
||||
for _, site := range sites {
|
||||
b, err := os.ReadFile(site)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", site, err)
|
||||
}
|
||||
for lineNo, line := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "//") {
|
||||
continue // comments are not face
|
||||
}
|
||||
for _, m := range strLit.FindAllString(line, -1) {
|
||||
low := strings.ToLower(m)
|
||||
for _, phrase := range banned {
|
||||
if strings.Contains(low, phrase) {
|
||||
t.Fatalf("%s:%d: idiom %q in a face string — rewrite literally (%q)", site, lineNo+1, phrase, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"rune.dev/rune/tui/runtime/lib"
|
||||
"rune.dev/rune/tui/taskqueue"
|
||||
)
|
||||
|
||||
// D50 residual 3 (#2560, owner ruling): the tasks pane's row-selection/
|
||||
// navigation mode is REMOVED — the walker (up/down/j/k over tree rows,
|
||||
// the cursor, the row boxes) is gone, and the tree region is a VIEW
|
||||
// that never owns the keys. Enter on tasks-pane focus starts direct
|
||||
// task execution (D44 doctrine) — EVEN after a click-focus that opened
|
||||
// the tree region.
|
||||
func TestD50TasksFocusEnterDispatchesAfterClickFocus(t *testing.T) {
|
||||
configuredHome(t)
|
||||
bootCompleter = &queueSeamFake{}
|
||||
defer func() { bootCompleter = nil }()
|
||||
|
||||
m := sized(t, initialModel(), 110, 30)
|
||||
m2, _ := m.Update(keyPress("2"))
|
||||
m = m2.(model)
|
||||
// the click-focus road: the tree region shows (the pane click /
|
||||
// palette road opened it). The walker is gone — the region is a
|
||||
// view, and it must never own the keyboard.
|
||||
m.face.SetTreeOpen(true)
|
||||
if m.composerActive() != true {
|
||||
t.Fatal("the tree region is a view now (no selectable rows): it must not own the keyboard — the composer stays active")
|
||||
}
|
||||
for _, r := range "wire the stream" {
|
||||
next, _ := m.Update(keyPress(string(r)))
|
||||
m = next.(model)
|
||||
}
|
||||
if got := m.taskInput.Text(); got != "wire the stream" {
|
||||
t.Fatalf("typing under the open tree region must reach the shared box, got %q", m.taskInput.Text())
|
||||
}
|
||||
m2, cmd := m.Update(enterPress())
|
||||
m = m2.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("enter with tasks focus must dispatch the work demand immediately, even with the tree region open")
|
||||
}
|
||||
var msg tea.Msg
|
||||
for _, mm := range runBatch(cmd) {
|
||||
msg = mm
|
||||
}
|
||||
if _, ok := msg.(taskDemandMsg); !ok {
|
||||
t.Fatalf("the cmd must answer a taskDemandMsg, got %T", msg)
|
||||
}
|
||||
m2, _ = m.Update(msg)
|
||||
m = m2.(model)
|
||||
if got := m.face.QueueNotice(); !strings.Contains(got, "work queued (") {
|
||||
t.Fatalf("the honest queue receipt missing, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// up/down under tasks focus no longer walk rows (the walker is
|
||||
// removed) — they scroll the tasks panel's own scroll window.
|
||||
func TestD50TasksArrowsScrollNotWalk(t *testing.T) {
|
||||
configuredHome(t)
|
||||
m := sized(t, initialModel(), 110, 30)
|
||||
m2, _ := m.Update(keyPress("2"))
|
||||
m = m2.(model)
|
||||
m.face.SetTreeOpen(true)
|
||||
// seed panel overflow so the scroll window has room (40 fresh rows)
|
||||
queue := make([]taskqueue.Entry, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
queue = append(queue, taskqueue.Entry{EntryID: fmt.Sprintf("q%02d", i),
|
||||
Text: fmt.Sprintf("demand %02d", i),
|
||||
QueuedAt: time.Now().Add(-time.Duration(i) * time.Minute)})
|
||||
}
|
||||
m.face.SetLiveWork(nil, queue, time.Now())
|
||||
m.face.Render()
|
||||
before := m.face.TasksScrollOffset()
|
||||
m2, _ = m.Update(keyPress("up"))
|
||||
m = m2.(model)
|
||||
if m.face.TasksScrollOffset() == before {
|
||||
t.Fatal("up under tasks focus must scroll the tasks panel (the row walker is removed)")
|
||||
}
|
||||
m2, _ = m.Update(keyPress("down"))
|
||||
m = m2.(model)
|
||||
if m.face.TasksScrollOffset() != before {
|
||||
t.Fatalf("down must return the scroll window to rest, got %d want %d", m.face.TasksScrollOffset(), before)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = lib.TaskEnqueueRequest{}
|
||||
@@ -191,3 +191,16 @@ func TestOSCDetectionDenylist(t *testing.T) {
|
||||
t.Error("KONSOLE_VERSION must stay a carrier fast path")
|
||||
}
|
||||
}
|
||||
|
||||
// D53 #2568 item 5: the context readout carries the usage percentage
|
||||
// in brackets, computed from the existing used/limit pair.
|
||||
func TestCtxPercent(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
used, limit int64
|
||||
want int
|
||||
}{{4200, 32768, 12}, {16384, 32768, 50}, {32768, 32768, 100}, {40000, 32768, 100}, {0, 32768, 0}, {100, 0, 0}} {
|
||||
if got := ctxPercent(c.used, c.limit); got != c.want {
|
||||
t.Errorf("ctxPercent(%d,%d) = %d, want %d", c.used, c.limit, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+406
-53
@@ -157,16 +157,19 @@ type model struct {
|
||||
// is the dichotomy build wave). The box renders the FOCUSED
|
||||
// machine's view; typed keys keep routing by focus exactly as
|
||||
// before (composerActive: only chat focus types).
|
||||
composer *nav.Input3
|
||||
taskInput *nav.Input3
|
||||
km *nav.KeyMap
|
||||
disp *laneconc.Dispatcher
|
||||
chatBusy bool
|
||||
lastCtrlC bool // the exit-gesture arm: second ctrl+c quits (D46)
|
||||
chatSt string // "" (never sent) | sending | waiting | received | error
|
||||
pendingSent string // the in-flight text (the waiting leg's match)
|
||||
spin int // the animation frame (the glyph cycle's index)
|
||||
blink int // the composer cursor's blink phase (D43.14)
|
||||
composer *nav.Input3
|
||||
taskInput *nav.Input3
|
||||
km *nav.KeyMap
|
||||
disp *laneconc.Dispatcher
|
||||
chatBusy bool
|
||||
lastCtrlC bool // the exit-gesture arm: second ctrl+c quits (D46)
|
||||
chatSt string // "" (never sent) | sending | waiting | received | error
|
||||
// chatStreamBuf is the running total of the streamed turn's text
|
||||
// (Gitea #2556): each delta renders into the progressive bubble.
|
||||
chatStreamBuf string
|
||||
pendingSent string // the in-flight text (the waiting leg's match)
|
||||
spin int // the animation frame (the glyph cycle's index)
|
||||
blink int // the composer cursor's blink phase (D43.14)
|
||||
// turnStart feeds the identity line's mm:ss turn duration (D41:
|
||||
// durations — how long things take — stay visible while a turn
|
||||
// flies).
|
||||
@@ -188,6 +191,7 @@ type model struct {
|
||||
styleIdx int
|
||||
tickArmed bool
|
||||
reducedMotion bool
|
||||
calm bool
|
||||
|
||||
// THE SCAFFOLDING STATUS LINE layer's shell state (D43 addendum 5):
|
||||
// the walk frame (advanced on the shared ~500ms heartbeat) and the
|
||||
@@ -281,6 +285,14 @@ type model struct {
|
||||
// (selection.go) — anchor/head/pane. Shell-owned display state,
|
||||
// never face state.
|
||||
sel selState
|
||||
|
||||
// hintWord is the slash word the cursor is parked on (a new word
|
||||
// resets the selection to the first row).
|
||||
hintWord string
|
||||
// hintSel is the '/'-command submenu's selection cursor (D50 item
|
||||
// 5, #2555-7): up/down move it over the matching hint rows, enter
|
||||
// runs the highlighted command. 0 rests on the first row.
|
||||
hintSel int
|
||||
}
|
||||
|
||||
func initialModel() model {
|
||||
@@ -318,6 +330,14 @@ func initialModel() model {
|
||||
// style as the default variant.
|
||||
m.gaugesView = gauges.New(time.Now().UnixNano())
|
||||
m.reducedMotion = os.Getenv("RUNE_REDUCED_MOTION") != ""
|
||||
// D54 item 5 (#2571): the sensory setting — VIVID is the DEFAULT
|
||||
// (the owner's ruling); calm mutes the chroma at the token layer
|
||||
// and closes the activity wave's motion gate. The settings store
|
||||
// owns the key (themes.sensory); the env seam RUNE_SENSORY=calm
|
||||
// is the boot-time carrier, same shape as RUNE_REDUCED_MOTION.
|
||||
m.calm = os.Getenv("RUNE_SENSORY") == "calm"
|
||||
chatpane.ApplyCalm(m.calm)
|
||||
lanes.ApplyCalm(m.calm)
|
||||
if root := lib.DefaultRepoRoot(); root != "" {
|
||||
m.fwave = factorywave.New(root, factorywave.DefaultEvery).Start()
|
||||
m.syncWaves()
|
||||
@@ -334,6 +354,9 @@ func initialModel() model {
|
||||
// absent or corrupt record answers the old TUI's default 8; a
|
||||
// deeper record deepens the bridge's poll window with it.
|
||||
m.bootDepth = loadHistoryDepth(m.home)
|
||||
// the PINNED WORK hot-state record (D50 residual 4): the pins
|
||||
// survive the boot — the record loads beside the history depth.
|
||||
m.face.SetPins(loadPins(m.home))
|
||||
if m.rt != nil {
|
||||
m.rt.SetChatDepth(m.bootDepth)
|
||||
}
|
||||
@@ -357,6 +380,22 @@ type chatCompletionMsg struct {
|
||||
text string
|
||||
}
|
||||
|
||||
// chatStreamStartMsg opens the progressive-render leg of a dispatch
|
||||
// (Gitea #2556): deltas arrive over the channel while the completion
|
||||
// goroutine runs; the report lands through done when the turn settles.
|
||||
type chatStreamStartMsg struct {
|
||||
deltas <-chan string
|
||||
done <-chan laneconc.LaneReport
|
||||
text string
|
||||
}
|
||||
|
||||
// chatStreamDeltaMsg is one streamed text chunk: the running total
|
||||
// renders into the chat pane's progressive bubble immediately.
|
||||
type chatStreamDeltaMsg struct {
|
||||
start chatStreamStartMsg
|
||||
delta string
|
||||
}
|
||||
|
||||
// approvalReceiptMsg is one answered approval decision (the K20
|
||||
// pattern's resolve leg): the Update thread armed the decision, the
|
||||
// goroutine POSTed /v1/approvals/resolve off-thread, this message
|
||||
@@ -478,21 +517,55 @@ func dispatchProviderClass(m model) string {
|
||||
}
|
||||
|
||||
// chatDispatch runs one composer send through the lane spine off the
|
||||
// Update thread (the K20 pattern): the ctx carries the WHOLE-TURN
|
||||
// budget for the provider class (D47 defect 3 — a dispatched
|
||||
// completion runs the core's tool loop, up to ToolLoopIterations model
|
||||
// calls; #2536: locals get the extended class budget, cloud the
|
||||
// shorter one). No face mutation happens here; the goroutine only
|
||||
// touches the seam.
|
||||
// Update thread (the K20 pattern) — STREAMING (Gitea #2556, the
|
||||
// streaming root-cause wave): the dispatch rides stream=true, the
|
||||
// answer's bytes surface through the lane dispatcher's delta sink, and
|
||||
// the Update thread renders each chunk as it lands. The streamed turn
|
||||
// retires the flat whole-turn budgets: the ctx binds the 30-minute
|
||||
// backstop only — the CORE's inter-chunk idle timeout is the real
|
||||
// deadline (no bytes = genuinely dead). No face mutation happens in
|
||||
// the goroutines; they only touch the seam.
|
||||
func chatDispatch(m model, text string) tea.Cmd {
|
||||
deltas := make(chan string, 256)
|
||||
done := make(chan laneconc.LaneReport, 1)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), lib.TurnBudgetFor(dispatchProviderClass(m)))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), streamDispatchBackstop)
|
||||
defer cancel()
|
||||
rep, _ := m.disp.Dispatch(ctx, laneconc.Chat, text)
|
||||
return chatCompletionMsg{rep: rep, text: text}
|
||||
m.disp.SetDeltaSink(func(lane laneconc.Lane, s string) {
|
||||
if lane == laneconc.Chat {
|
||||
select {
|
||||
case deltas <- s:
|
||||
default: // the render thread keeps up; never block the stream
|
||||
}
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
defer close(deltas)
|
||||
rep, _ := m.disp.Dispatch(ctx, laneconc.Chat, text)
|
||||
done <- rep
|
||||
}()
|
||||
return chatStreamStartMsg{deltas: deltas, done: done, text: text}
|
||||
}
|
||||
}
|
||||
|
||||
// awaitStream is the streaming receive loop as tea commands: each
|
||||
// delta returns its message and re-arms; the closing report lands as
|
||||
// the classic chatCompletionMsg.
|
||||
func awaitStream(start chatStreamStartMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
delta, ok := <-start.deltas
|
||||
if ok {
|
||||
return chatStreamDeltaMsg{start: start, delta: delta}
|
||||
}
|
||||
return chatCompletionMsg{rep: <-start.done, text: start.text}
|
||||
}
|
||||
}
|
||||
|
||||
// streamDispatchBackstop mirrors the core's StreamTurnBackstop: the
|
||||
// TUI's dispatch ctx on the streamed path is a runaway guard only —
|
||||
// the inter-chunk idle timeout lives at the core (Gitea #2556).
|
||||
const streamDispatchBackstop = 30 * time.Minute
|
||||
|
||||
// composerActive reports whether the composer owns the next key: no
|
||||
// chrome boundary (tree region, promote region, rail takeover, modal)
|
||||
// owns the keys. D36 owner fix 2026-10-19: the shared box is the SINGLE
|
||||
@@ -560,6 +633,23 @@ type ctrlCArmReset struct{}
|
||||
|
||||
const spinPeriod = 500 * time.Millisecond
|
||||
|
||||
// quitHint is the D53 quit warning (Gitea #2568 item 3): the soft
|
||||
// hint the FIRST ctrl+c installs near the shift+enter line — literal
|
||||
// wording, no idiom (the D54 literal-language law). Cleared by the
|
||||
// arm timeout or any other key; the SECOND press quits and never
|
||||
// shows it.
|
||||
const quitHint = "press ctrl+c again to quit"
|
||||
|
||||
// restoreComposerHint puts the live key hint back after the quit
|
||||
// warning clears (the hint stays live across rebinds — the spec's
|
||||
// discoverability law).
|
||||
func (m *model) restoreComposerHint() {
|
||||
if m.composer == nil {
|
||||
return
|
||||
}
|
||||
m.face.SetComposer(composerView(m.composer), m.composer.Placeholder(), m.composer.Hint(m.km))
|
||||
}
|
||||
|
||||
// spinTick schedules the next shared-tick frame (the thinking walk,
|
||||
// the gauges shimmer, the wave marker, and the composer cursor's
|
||||
// blink all ride this one heartbeat — D41).
|
||||
@@ -664,7 +754,7 @@ func (m *model) applyTaskDemand(msg taskDemandMsg) {
|
||||
m.face.SetQueueNotice(fmt.Sprintf("work queued (#%d): %s", msg.res.Position, msg.dem))
|
||||
default:
|
||||
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
|
||||
Content: "work demand neither delegated nor queued — the engine answered without a verdict"})
|
||||
Content: "work demand neither delegated nor queued — the engine answered without a clear result"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,7 +769,7 @@ func (m *model) consentAnswer() tea.Cmd {
|
||||
}
|
||||
if m.consentBusy {
|
||||
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleSystem,
|
||||
Content: "consent enqueue in flight — wait for its receipt"})
|
||||
Content: "consent enqueue being processed now — wait for its receipt"})
|
||||
return nil
|
||||
}
|
||||
b, ok := m.consentHub.Answer()
|
||||
@@ -762,7 +852,7 @@ func (m *model) applyConsentEnqueue(msg consentEnqueueMsg) {
|
||||
msg.res.Position, msg.brief.SnapshotID, refs, msg.brief.BriefText))
|
||||
default:
|
||||
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
|
||||
Content: "consented work neither delegated nor queued — the engine answered without a verdict"})
|
||||
Content: "consented work neither delegated nor queued — the engine answered without a clear result"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,7 +889,7 @@ func (m *model) dispatchSend(sent string) tea.Cmd {
|
||||
if m.chatBusy {
|
||||
m.face.DropChatThinking()
|
||||
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
|
||||
Content: "one completion in flight — phase 1 sends one at a time; wait for the reply"})
|
||||
Content: "one completion being processed now — phase 1 sends one at a time; wait for the reply"})
|
||||
return nil
|
||||
}
|
||||
m.chatBusy = true
|
||||
@@ -825,6 +915,7 @@ func (m *model) applyChatReport(msg chatCompletionMsg) {
|
||||
if msg.rep.Err != nil {
|
||||
m.chatSt = "error"
|
||||
m.face.DropChatThinking()
|
||||
m.face.DropStreamChat() // the partial render goes with the honest error
|
||||
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
|
||||
Content: fmt.Sprintf("dispatch failed: %v", msg.rep.Err)})
|
||||
m.syncActivity() // the turn ended: the pane rests (idle)
|
||||
@@ -902,6 +993,12 @@ func (m *model) syncActivity() {
|
||||
}
|
||||
}
|
||||
m.face.SetActivity(sig, m.spin)
|
||||
// MOTION LAW (D54 #2571 item 1): the activity pane's wave runs
|
||||
// only when motion is allowed — reduced motion closes the gate
|
||||
// (calm mode closes it too; D54 item 5). The chat pane never
|
||||
// animates; at most ONE region of the face animates per frame
|
||||
// (the law is written at the Update loop's heartbeat).
|
||||
m.face.SetActivityMotionAllowed(m.activityMotionAllowed())
|
||||
label := ""
|
||||
if st := m.face.ActivityState(); st != lanes.ActivityIdle {
|
||||
label = string(st)
|
||||
@@ -1217,27 +1314,59 @@ func (m model) pickerRows() []lib.ModelRow {
|
||||
}
|
||||
|
||||
// refreshPicker installs the picker's overlay lines (view-only above
|
||||
// the composer — the band slot the menu used).
|
||||
// the composer — the band slot the menu used). The D52 window law
|
||||
// (#2567): the centered picker is split into labelled CLOUD and LOCAL
|
||||
// sections — cloud rows first, locals under LOCAL — one walkable list
|
||||
// over both.
|
||||
func (m *model) refreshPicker() {
|
||||
rows := m.pickerRows()
|
||||
if m.pickerSel >= len(rows) {
|
||||
m.pickerSel = max(len(rows)-1, 0)
|
||||
}
|
||||
var out []string
|
||||
out = append(out, fmt.Sprintf("pick a model · type to filter (%d) · ↑/↓ walk · enter choose · esc keeps your text", len(rows)))
|
||||
out = append(out, fmt.Sprintf("pick a model · type to filter (%d) · ↑/↓ move · enter choose · esc keeps your text", len(rows)))
|
||||
if len(rows) == 0 {
|
||||
out = append(out, " no models match — backspace to widen")
|
||||
}
|
||||
for i, r := range rows {
|
||||
// the walkable index counts ALL rows (both sections) so ↑/↓/enter
|
||||
// behave exactly as before — the sections are presentation.
|
||||
walked := 0
|
||||
cloud, local := splitRowsCloudLocal(rows)
|
||||
out = append(out, "CLOUD")
|
||||
for _, r := range cloud {
|
||||
mark := " "
|
||||
if i == m.pickerSel {
|
||||
if walked == m.pickerSel {
|
||||
mark = "> "
|
||||
}
|
||||
out = append(out, mark+fmt.Sprintf("%d) %s", i+1, r.ID))
|
||||
out = append(out, mark+fmt.Sprintf("%d) %s", walked+1, r.ID))
|
||||
walked++
|
||||
}
|
||||
out = append(out, "LOCAL")
|
||||
for _, r := range local {
|
||||
mark := " "
|
||||
if walked == m.pickerSel {
|
||||
mark = "> "
|
||||
}
|
||||
out = append(out, mark+fmt.Sprintf("%d) %s", walked+1, r.ID))
|
||||
walked++
|
||||
}
|
||||
m.face.SetMenu(out)
|
||||
}
|
||||
|
||||
// splitRowsCloudLocal partitions the picker rows into the cloud
|
||||
// provider family and the local binding family (the D52 window law's
|
||||
// two labelled sections).
|
||||
func splitRowsCloudLocal(rows []lib.ModelRow) (cloud, local []lib.ModelRow) {
|
||||
for _, r := range rows {
|
||||
if isCloudProvider(r.OwnedBy) {
|
||||
cloud = append(cloud, r)
|
||||
} else {
|
||||
local = append(local, r)
|
||||
}
|
||||
}
|
||||
return cloud, local
|
||||
}
|
||||
|
||||
// pickerKeys handles the keys while the picker holds them: typing
|
||||
// filters, arrows walk, enter chooses, esc dismisses-keeps-draft,
|
||||
// digits jump. ok=false lets the key fall through (the composer types
|
||||
@@ -1527,15 +1656,21 @@ func slashMatches(text string) []int {
|
||||
// slashHintLines renders the '/'-command overlay above the input box:
|
||||
// the filtered candidates with the top match marked, or the honest
|
||||
// no-match line.
|
||||
func slashHintLines(text string) []string {
|
||||
func slashHintLines(text string, sel int) []string {
|
||||
matches := slashMatches(text)
|
||||
if len(matches) == 0 {
|
||||
return []string{"no matching command — backspace to edit"}
|
||||
}
|
||||
if sel < 0 {
|
||||
sel = 0
|
||||
}
|
||||
if sel >= len(matches) {
|
||||
sel = len(matches) - 1
|
||||
}
|
||||
var out []string
|
||||
for j, i := range matches {
|
||||
mark := " "
|
||||
if j == 0 {
|
||||
if j == sel {
|
||||
mark = "> "
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s%s — %s", mark, slashVocabulary[i].slash, slashVocabulary[i].desc))
|
||||
@@ -1543,6 +1678,23 @@ func slashHintLines(text string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// slashHintSelected returns the vocabulary id under the submenu's
|
||||
// selection cursor for the typed slash text ("" when nothing matches).
|
||||
func (m *model) slashHintSelected(text string) string {
|
||||
matches := slashMatches(strings.SplitN(text, " ", 2)[0])
|
||||
if len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
sel := m.hintSel
|
||||
if sel < 0 {
|
||||
sel = 0
|
||||
}
|
||||
if sel >= len(matches) {
|
||||
sel = len(matches) - 1
|
||||
}
|
||||
return slashVocabulary[matches[sel]].id
|
||||
}
|
||||
|
||||
// runSlash executes one '/'-leading send: an exact or unique-prefix
|
||||
// match runs through the SAME executor as the palette; anything else
|
||||
// restores the text for editing and says so in the overlay (the send
|
||||
@@ -1721,9 +1873,9 @@ func (m *model) refreshOverlay() {
|
||||
default:
|
||||
m.face.SetMenu(nil)
|
||||
}
|
||||
text := m.composer.Text()
|
||||
text := m.focusedInput().Text()
|
||||
if !m.paletteOpen() && !m.menuOpen && strings.HasPrefix(text, "/") {
|
||||
m.face.SetHints(slashHintLines(strings.SplitN(text, " ", 2)[0]))
|
||||
m.face.SetHints(slashHintLines(strings.SplitN(text, " ", 2)[0], m.hintSel))
|
||||
return
|
||||
}
|
||||
m.face.SetHints(nil)
|
||||
@@ -1808,7 +1960,7 @@ func (m *model) resolveApproval(approved bool) tea.Cmd {
|
||||
}
|
||||
if m.apprvBusy {
|
||||
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleSystem,
|
||||
Content: "approval resolution in flight — wait for its receipt"})
|
||||
Content: "approval resolution being processed now — wait for its receipt"})
|
||||
return nil
|
||||
}
|
||||
if bootResolver == nil {
|
||||
@@ -1880,7 +2032,23 @@ func (m *model) closeApprovalCard() {
|
||||
m.refreshOverlay()
|
||||
}
|
||||
|
||||
// activityMotionAllowed reports whether the activity wave may run
|
||||
// (the D54 MOTION LAW gate, #2571 item 1): reduced motion closes it;
|
||||
// calm mode closes it too (D54 item 5).
|
||||
func (m model) activityMotionAllowed() bool {
|
||||
return !m.reducedMotion && !m.calm
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
// THE ONE-ANIMATING-REGION LAW (D54 #2571 item 1, documented at
|
||||
// the face chain's Update loop, which owns the frame tick): at
|
||||
// most ONE region of the face animates per frame. The activity
|
||||
// strip is that region while it waves (gated by
|
||||
// activityMotionAllowed); when the scaffold line animates it
|
||||
// holds the region and the other animations rest (the
|
||||
// arbitration bit, canon P3). The chat pane NEVER animates —
|
||||
// its law is written at tui/chatpane/blocks.go and asserted
|
||||
// append-only in the scroll/blocks tests.
|
||||
if m.reducedMotion {
|
||||
return nil // reduced motion: the cursor rests lit, no heartbeat
|
||||
}
|
||||
@@ -1978,9 +2146,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case chatStreamStartMsg:
|
||||
// the streamed turn opens: the progressive bubble installs and
|
||||
// the receive loop arms (Gitea #2556).
|
||||
m.face.StreamChat("")
|
||||
return m, awaitStream(msg)
|
||||
case chatStreamDeltaMsg:
|
||||
// the answer renders AS IT ARRIVES: the running total replaces
|
||||
// the streaming bubble's content.
|
||||
m.chatStreamBuf += msg.delta
|
||||
m.face.StreamChat(m.chatStreamBuf)
|
||||
return m, awaitStream(msg.start)
|
||||
case chatCompletionMsg:
|
||||
// the lane spine answered a chat dispatch: the reply (or the
|
||||
// honest error) lands in the transcript; the state clears.
|
||||
m.chatStreamBuf = ""
|
||||
m.applyChatReport(msg)
|
||||
return m, nil
|
||||
case taskDemandMsg:
|
||||
@@ -2012,6 +2192,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.refreshComposer()
|
||||
}
|
||||
return m, nil
|
||||
case ctrlCArmReset:
|
||||
// THE ARM TIMEOUT (D46 + D53 #2568 item 3): the exit window
|
||||
// closed — the gesture disarms and the quit warning clears.
|
||||
// (This case must live at the switch top level: the message is
|
||||
// not a KeyPressMsg, and the in-case check the D46 code carried
|
||||
// was unreachable — the warning never cleared on timeout.)
|
||||
if m.lastCtrlC {
|
||||
nm := m
|
||||
nm.lastCtrlC = false
|
||||
nm.restoreComposerHint()
|
||||
return nm, nil
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
// THE EXIT GESTURE (owner ruling D46 addendum): the FIRST
|
||||
@@ -2024,24 +2217,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
nm.lastCtrlC = true
|
||||
if m.composer != nil {
|
||||
nm.composer.SetText("")
|
||||
nm.face.SetComposer(composerView(nm.composer), nm.composer.Placeholder(), nm.composer.Hint(nm.km))
|
||||
// THE QUIT WARNING (D53 #2568 item 3): the first press
|
||||
// swaps the key hint for the soft quit warning near the
|
||||
// shift+enter line; any other key or the timeout
|
||||
// restores it. The second press never shows it — that
|
||||
// press quits.
|
||||
nm.face.SetComposer(composerView(nm.composer), nm.composer.Placeholder(), quitHint)
|
||||
}
|
||||
return nm, tea.Tick(time.Second, func(time.Time) tea.Msg {
|
||||
return ctrlCArmReset{}
|
||||
})
|
||||
}
|
||||
if _, ok := any(msg).(ctrlCArmReset); ok {
|
||||
if m.lastCtrlC {
|
||||
nm := m
|
||||
nm.lastCtrlC = false
|
||||
return nm, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
{ // any non-ctrl+c key disarms the double-press window
|
||||
if m.lastCtrlC {
|
||||
nm := m
|
||||
nm.lastCtrlC = false
|
||||
nm.restoreComposerHint()
|
||||
m = nm
|
||||
}
|
||||
}
|
||||
@@ -2056,6 +2247,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// THE SUB-AGENTS WINDOW (D53 #2568 item 7): keyboard parity
|
||||
// with the section's click road. ctrl+w opens the centered
|
||||
// window when agents are in flight (a CONTROL key — plain
|
||||
// letters always type, the q-law); while it is open it is MODAL —
|
||||
// esc closes, up/down walk the rows, enter toggles the
|
||||
// selected agent's detail — and every other key closes it
|
||||
// (a modal that traps typing is a trap).
|
||||
if m.face.AgentsWindowOpen() {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.face.CloseAgentsWindow()
|
||||
case "up":
|
||||
m.face.AgentsSelMove(-1)
|
||||
case "down":
|
||||
m.face.AgentsSelMove(1)
|
||||
default:
|
||||
m.face.CloseAgentsWindow()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if msg.String() == "ctrl+w" && m.face.WaveAgentsCount() > 0 {
|
||||
m.face.ToggleAgentsWindow()
|
||||
return m, nil
|
||||
}
|
||||
// THE ACTIVITY + WINDOW (A6, keyboard parity per the
|
||||
// tabs/windows canon): ctrl+a toggles the centered turn
|
||||
// window; esc closes it while it holds the keyboard. A modal
|
||||
@@ -2090,6 +2305,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.face.SetSteerOpen(true)
|
||||
return m, nil
|
||||
}
|
||||
// THE PIN VERB's keyboard parity (D50 residual 4): ctrl+b pins
|
||||
// the top live work block into the tasks pane's reserved pinned
|
||||
// area, persisted through the hot-state store (tasks_pins.json).
|
||||
// A control key per the D36 letter law, reachable while composing.
|
||||
if msg.String() == "ctrl+b" {
|
||||
if _, ok := m.face.PinTopWork(); ok {
|
||||
if err := savePins(m.home, m.face.Pins()); err != nil {
|
||||
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleError,
|
||||
Content: "pin not persisted: " + err.Error()})
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
// ctrl+g raises/lowers the GAUGES window (D41: progress is a
|
||||
// callable overlay — never embedded in the task pane); esc
|
||||
// closes it (handled before anything else can claim the key)
|
||||
@@ -2277,6 +2505,48 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
// THE '/'-COMMAND SUBMENU (D50 item 5, #2555-7): while the
|
||||
// slash hints show (a '/'-leading draft, no other overlay),
|
||||
// up/down move the selection over the matching rows and
|
||||
// enter runs the HIGHLIGHTED command — the palette's
|
||||
// ↑/↓/enter law, carried to the inline '/' road.
|
||||
if !strings.HasPrefix(m.focusedInput().Text(), "/") {
|
||||
m.hintSel = 0
|
||||
} else if word := strings.SplitN(m.focusedInput().Text(), " ", 2)[0]; word != m.hintWord {
|
||||
m.hintWord = word
|
||||
m.hintSel = 0
|
||||
} else if m.hintSel > 0 && len(slashMatches(word)) == 0 {
|
||||
m.hintSel = 0
|
||||
}
|
||||
if !m.paletteOpen() && !m.menuOpen &&
|
||||
strings.HasPrefix(m.focusedInput().Text(), "/") &&
|
||||
len(slashMatches(strings.SplitN(m.focusedInput().Text(), " ", 2)[0])) > 0 {
|
||||
switch msg.String() {
|
||||
case "up":
|
||||
m.hintSel--
|
||||
if m.hintSel < 0 {
|
||||
m.hintSel = 0
|
||||
}
|
||||
m.refreshOverlay()
|
||||
return m, nil
|
||||
case "down":
|
||||
if n := len(slashMatches(strings.SplitN(m.focusedInput().Text(), " ", 2)[0])); m.hintSel < n-1 {
|
||||
m.hintSel++
|
||||
}
|
||||
m.refreshOverlay()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if id := m.slashHintSelected(m.focusedInput().Text()); id != "" {
|
||||
arg := ""
|
||||
if _, rest, ok := strings.Cut(m.focusedInput().Text(), " "); ok {
|
||||
arg = rest
|
||||
}
|
||||
m.focusedInput().Clear()
|
||||
m.refreshOverlay()
|
||||
return m, m.runCommand(id, arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
// SCROLLBACK (#2535, D50-1: each pane scrolls INDIVIDUALLY
|
||||
// with its own scroll state): ctrl+up/ctrl+down walk the
|
||||
// FOCUSED pane's scroll window line-wise, pgup/pgdown
|
||||
@@ -2322,8 +2592,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if k, ok := inputEditKey(msg.String()); ok {
|
||||
if m.face.Snapshot().Focus == semantic.FocusTasks &&
|
||||
(msg.String() == "up" || msg.String() == "down") {
|
||||
// the tree's arrows (the tree region swallows or
|
||||
// walks them; ChromeOpen gates that)
|
||||
// D50 residual 3: the row walker is removed — the
|
||||
// arrows scroll the tasks panel's own scroll window
|
||||
// (#2535) instead of walking rows.
|
||||
if msg.String() == "up" {
|
||||
m.face.TasksScrollUp(1)
|
||||
} else {
|
||||
m.face.TasksScrollDown(1)
|
||||
}
|
||||
m.face.TouchScroll()
|
||||
return m, nil
|
||||
} else {
|
||||
m.focusedInput().Input(k, m.km)
|
||||
m.refreshComposer()
|
||||
@@ -2339,7 +2617,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// (both gated above this block), so only the letter
|
||||
// case reaches here. Other chain-owned chrome keys
|
||||
// (rail r, promote p/s/u, tree t, ...) route the chain.
|
||||
if key.String() != "y" && chainOwnsKey(m, key) {
|
||||
if key.String() != "y" && chainOwnsKey(m, key) &&
|
||||
!(m.composerActive() && composerOwnsRune(m, key)) {
|
||||
// chain-owned chrome key: fall through to the chain
|
||||
} else {
|
||||
m.focusedInput().Input(key, m.km)
|
||||
@@ -2582,6 +2861,12 @@ func (m model) View() tea.View {
|
||||
if m.face.ActivityWindowOpen() {
|
||||
content = m.overlayActivityWindow(content)
|
||||
}
|
||||
// THE SUB-AGENTS WINDOW (D53 #2568 item 7): the centered overlay
|
||||
// listing all in-flight agents, the selected row's detail beneath
|
||||
// (the D52 picker window pattern; the + window's centered shape).
|
||||
if m.face.AgentsWindowOpen() {
|
||||
content = m.overlayAgentsWindow(content)
|
||||
}
|
||||
// the #2532 drag highlight overlays the frame while the drag is
|
||||
// in flight (selection.go — shell display state, never a pane).
|
||||
content = m.overlaySelection(content)
|
||||
@@ -2628,10 +2913,12 @@ func semverOf(v string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
// ctxToken renders the context-window readout (D43.12): "context 4.2k/32k"
|
||||
// — the engine's used tokens against the model's registry limit. Owner
|
||||
// ruling 2026-10-19: the full word, not the abbreviation. An engine that
|
||||
// has not reported (offline face, unknown model) renders nothing — the
|
||||
// ctxToken renders the context-window readout (D43.12): "context
|
||||
// 4.2k/32k (13%)" — the engine's used tokens against the model's
|
||||
// registry limit, with the usage percentage in brackets (D53 #2568
|
||||
// item 5, computed from the existing used/limit pair). Owner ruling
|
||||
// 2026-10-19: the full word, not the abbreviation. An engine that has
|
||||
// not reported (offline face, unknown model) renders nothing — the
|
||||
// bar never guesses.
|
||||
func ctxToken(m model) string {
|
||||
used, limit := int64(0), int64(0)
|
||||
@@ -2641,7 +2928,22 @@ func ctxToken(m model) string {
|
||||
if used <= 0 || limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" · context %s/%s", compactTokens(used), compactTokens(limit))
|
||||
return fmt.Sprintf(" · context %s/%s (%d%%)", compactTokens(used), compactTokens(limit), ctxPercent(used, limit))
|
||||
}
|
||||
|
||||
// ctxPercent is the usage percentage: integer 0-100, floor-rounded.
|
||||
func ctxPercent(used, limit int64) int {
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
p := used * 100 / limit
|
||||
if p < 0 {
|
||||
return 0
|
||||
}
|
||||
if p > 100 {
|
||||
return 100
|
||||
}
|
||||
return int(p)
|
||||
}
|
||||
|
||||
// versionToken renders the info bar's version segment (D43 addendum 6
|
||||
@@ -3034,6 +3336,46 @@ func (m model) overlayActivityWindow(content string) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// overlayAgentsWindow draws THE SUB-AGENTS WINDOW (D53 #2568 item 7)
|
||||
// CENTERED over the face: all in-flight agents, the selected row's
|
||||
// thought process / recent activity beneath — the D52 picker window
|
||||
// pattern (centered, sections, walkable) on the + window's shape.
|
||||
func (m model) overlayAgentsWindow(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
w := 0
|
||||
for _, l := range lines {
|
||||
if n := len([]rune(semantic.StripANSI(l))); n > w {
|
||||
w = n
|
||||
}
|
||||
}
|
||||
ww := min(72, max(w-8, 20))
|
||||
rows := m.face.AgentsWindowLines(ww)
|
||||
if len(rows) == 0 {
|
||||
return content
|
||||
}
|
||||
title := " sub agents · esc closes "
|
||||
box := make([]string, 0, len(rows)+2)
|
||||
topRow := "╭" + title + strings.Repeat("─", max(ww+2-len([]rune(title)), 0)) + "╮"
|
||||
box = append(box, topRow)
|
||||
for _, r := range rows {
|
||||
r = cutDisplay(r, ww)
|
||||
pad := max(ww-len([]rune(semantic.StripANSI(r))), 0)
|
||||
box = append(box, "│ "+r+strings.Repeat(" ", pad)+" │")
|
||||
}
|
||||
box = append(box, "╰"+strings.Repeat("─", ww+2)+"╯")
|
||||
bh := len(box)
|
||||
y0 := max((len(lines)-1-bh)/2, 1)
|
||||
left := max((w-(ww+4))/2, 0)
|
||||
for i, row := range box {
|
||||
y := y0 + i
|
||||
if y < 0 || y >= len(lines) {
|
||||
continue
|
||||
}
|
||||
lines[y] = cutDisplay(lines[y], left) + padDisplay(row, left+displayWidth(row), w)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// clipActivity keeps the first n display columns of a window row,
|
||||
// closing any SGR run the cut interrupts.
|
||||
func clipActivity(line string, n int) string { return cutDisplay(line, n) }
|
||||
@@ -3135,6 +3477,17 @@ func (m *model) syncWaves() {
|
||||
Count: fl.Count,
|
||||
Rows: waveRows(lib.DefaultRepoRoot(), fl),
|
||||
})
|
||||
// THE SUB-AGENTS SECTION (D53 #2568 item 7): the SAME flight
|
||||
// observation installs the activity pane's "sub agents being
|
||||
// processed now" section — the section can never disagree with
|
||||
// the rail marker or the wave tab (one poller, one number). The
|
||||
// rows carry the journal + observations detail waveRows derived.
|
||||
rows := waveRows(lib.DefaultRepoRoot(), fl)
|
||||
avs := make([]lanes.AgentView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
avs = append(avs, lanes.AgentView{ID: r.ID, Title: r.Title, State: r.State, Detail: r.Detail})
|
||||
}
|
||||
m.face.SetWaveAgents(fl.Count, avs)
|
||||
}
|
||||
|
||||
// waveRows derives one row per in-flight wave from the durable
|
||||
@@ -3193,7 +3546,7 @@ func waveDetail(w factory.Wave, events []factory.JournalEvent, obs map[string][]
|
||||
var out []string
|
||||
if t, err := time.Parse("2006-01-02T15:04:05Z", w.CreatedAt); err == nil {
|
||||
d := time.Since(t).Round(time.Second)
|
||||
out = append(out, fmt.Sprintf("state %s · %02d:%02d in flight", w.Phase,
|
||||
out = append(out, fmt.Sprintf("state %s · running %02d:%02d", w.Phase,
|
||||
int(d.Minutes()), int(d.Seconds())%60))
|
||||
} else {
|
||||
out = append(out, "state "+w.Phase)
|
||||
@@ -3288,8 +3641,8 @@ var styleVariants = []struct {
|
||||
style chatpane.Style
|
||||
}{
|
||||
{"amalgamation", chatpane.DefaultStyle()},
|
||||
{"quiet typeset", chatpane.Style{Bubbles: false, Vocab: chatpane.Vocab, Accent: "#FF79C6"}},
|
||||
{"full futhark", chatpane.Style{Bubbles: true, Vocab: chatpane.VocabFull(), Accent: "#FF79C6"}},
|
||||
{"quiet typeset", chatpane.Style{Bubbles: false, Vocab: chatpane.Vocab, Accent: themes.RoleHex(themes.RoleSpeaker)}},
|
||||
{"full futhark", chatpane.Style{Bubbles: true, Vocab: chatpane.VocabFull(), Accent: themes.RoleHex(themes.RoleSpeaker)}},
|
||||
}
|
||||
|
||||
// applyStyle installs variant i on the transcript.
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"rune.dev/rune/tui/chatpane"
|
||||
"rune.dev/rune/tui/runtime/lib"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
"rune.dev/rune/tui/tasks"
|
||||
)
|
||||
|
||||
func keyPress(s string) tea.KeyPressMsg {
|
||||
@@ -52,6 +53,15 @@ func sized(t *testing.T, m model, w, h int) model {
|
||||
return next.(model)
|
||||
}
|
||||
|
||||
// labSeeded swaps the shell's production face for the LAB-seeded one
|
||||
// (D52 supersession, #2566): production boots EMPTY now, so the
|
||||
// selection-law proofs that drive the canonical three-box substrate
|
||||
// ask for the seed explicitly — the seeds stay confined to the lab.
|
||||
func labSeeded(m model) model {
|
||||
m.face = tasks.NewSeeded(80, 24)
|
||||
return m
|
||||
}
|
||||
|
||||
// quits reports whether sending msg to the model produces a quit command.
|
||||
func quits(t *testing.T, m model, msg tea.Msg) bool {
|
||||
t.Helper()
|
||||
@@ -243,7 +253,9 @@ func TestTabSwitchesLaneFocus(t *testing.T) {
|
||||
// on the LAST task row (reverse ring entry), further presses walk the
|
||||
// ring, and the wrap returns to the chat pane.
|
||||
func TestShiftTabCyclesBoxes(t *testing.T) {
|
||||
m := sized(t, initialModel(), 80, 24)
|
||||
// D52 supersession (#2566): the box-selection law drives the
|
||||
// canonical seed substrate — the lab seed is explicit here.
|
||||
m := labSeeded(sized(t, initialModel(), 80, 24))
|
||||
next, _ := m.Update(shiftTabPress())
|
||||
st := next.(model).face.Snapshot()
|
||||
if st.Focus != semantic.FocusTasks || st.Sel != 2 {
|
||||
@@ -272,7 +284,8 @@ func TestShiftTabCyclesBoxes(t *testing.T) {
|
||||
// through the face chain's own regions and selects that row; typing
|
||||
// then lands on the lit row (y approves the clicked task).
|
||||
func TestMouseClickSelectsBox(t *testing.T) {
|
||||
m := sized(t, initialModel(), 80, 24)
|
||||
// D52 supersession (#2566): the seed substrate is explicit (lab).
|
||||
m := labSeeded(sized(t, initialModel(), 80, 24))
|
||||
reg := semantic.Region{}
|
||||
for _, r := range m.face.Render().Regions {
|
||||
if r.ID == "mouse.nav.jump.2" {
|
||||
@@ -302,24 +315,16 @@ func TestMouseClickSelectsBox(t *testing.T) {
|
||||
if st = mm.face.Snapshot(); st.Tasks[2].Approved {
|
||||
t.Fatalf("y typed a letter (no card open) — it must not approve: %+v", st.Tasks)
|
||||
}
|
||||
// empty the composer — the keys hand back to the tree (the
|
||||
// letter-stealing law's other half: an emptied composer is not
|
||||
// input territory, so the arrows can walk the cursor)
|
||||
// D50 residual 3 (owner ruling): the row walker is REMOVED — the
|
||||
// arrows no longer walk tree rows; the semantic selection stands.
|
||||
next, _ = next.(model).Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
||||
mm = next.(model)
|
||||
if got := mm.taskInput.Text(); got != "" {
|
||||
t.Fatalf("backspace did not empty the task composer: %q", got)
|
||||
}
|
||||
// arrows move the tree cursor — REAL arrow keys (the synthetic-
|
||||
// spelling law: the old keyPress("down") spelled Code='d' with
|
||||
// Text "down" and its tail branch asserted nothing at all)
|
||||
next, _ = mm.Update(arrowPress(tea.KeyUp))
|
||||
if st = next.(model).face.Snapshot(); st.Sel != 1 {
|
||||
t.Fatalf("up arrow: sel=%d, want 1 (the tree cursor walks back)", st.Sel)
|
||||
}
|
||||
next, _ = next.(model).Update(arrowPress(tea.KeyDown))
|
||||
if st = next.(model).face.Snapshot(); st.Sel != 2 {
|
||||
t.Fatalf("down arrow: sel=%d, want 2 (the tree cursor walks forward)", st.Sel)
|
||||
t.Fatalf("up arrow: sel=%d, want 2 (the row walker is removed; selection stands)", st.Sel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +391,9 @@ func TestRailOpensTakeoverAndRestoresLanes(t *testing.T) {
|
||||
// once the region is closed. (Setup: the composer owns typed runes on
|
||||
// the chat pane, so p runs from the tasks focus — 2.)
|
||||
func TestPromoteRegionWalk(t *testing.T) {
|
||||
m := sized(t, initialModel(), 80, 24)
|
||||
// D52 supersession (#2566): the hold/queue walk drives the seed
|
||||
// substrate — the lab seed is explicit here.
|
||||
m := labSeeded(sized(t, initialModel(), 80, 24))
|
||||
m2, _ := m.Update(keyPress("2"))
|
||||
// D36 owner fix: p is a letter now — the region opens
|
||||
// programmatically (the shell's ctrl+p is the palette, frozen D34).
|
||||
@@ -775,7 +782,7 @@ func TestComposerOneInFlight(t *testing.T) {
|
||||
if cmd2 != nil {
|
||||
t.Fatal("second send during flight must NOT dispatch")
|
||||
}
|
||||
if txt := chatBubbleText(m); !strings.Contains(txt, "one completion in flight") {
|
||||
if txt := chatBubbleText(m); !strings.Contains(txt, "one completion being processed now") {
|
||||
t.Fatalf("busy bubble missing:\n%s", txt)
|
||||
}
|
||||
}
|
||||
@@ -830,13 +837,24 @@ func TestComposerStaysOffChromeKeys(t *testing.T) {
|
||||
t.Fatalf("t must type into the shared box (D36 owner fix), got %q", got)
|
||||
}
|
||||
m.face.SetTreeOpen(true)
|
||||
// D50 residual 3: the tree region is a VIEW (the walker is removed)
|
||||
// — it never owns the keys, the composer stays active under it.
|
||||
if m.face.ChromeOpen() {
|
||||
t.Fatal("the tree region must not count as chrome (D50: no selectable rows, nothing owns the keys)")
|
||||
}
|
||||
if !m.composerActive() {
|
||||
t.Fatal("the composer must stay active under the open tree region")
|
||||
}
|
||||
// genuine chrome still owns the keys: the rail takeover.
|
||||
m.face.SetTakeoverOpen(true)
|
||||
if !m.face.ChromeOpen() {
|
||||
t.Fatal("tree region must be open after SetTreeOpen(true)")
|
||||
t.Fatal("the rail takeover must still count as chrome")
|
||||
}
|
||||
if m.composerActive() {
|
||||
t.Fatal("composer must not own keys while chrome is open")
|
||||
}
|
||||
next, cmd := m.Update(keyPress("h")) // a plain rune during chrome
|
||||
m.face.SetTakeoverOpen(false)
|
||||
next, cmd := m.Update(keyPress("h")) // a plain rune with the view region open
|
||||
m = next.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("no dispatch may leave chrome-owned keys")
|
||||
@@ -1552,7 +1570,22 @@ func runBatch(cmd tea.Cmd) []tea.Msg {
|
||||
case nil:
|
||||
return nil
|
||||
default:
|
||||
return []tea.Msg{msg}
|
||||
out := []tea.Msg{msg}
|
||||
// the streaming dispatch leg (Gitea #2556): the start message
|
||||
// opens the receive loop — the batch settles it to the closing
|
||||
// report so the classic completion-scanning assertions hold.
|
||||
if start, ok := msg.(chatStreamStartMsg); ok {
|
||||
for {
|
||||
next := awaitStream(start)()
|
||||
out = append(out, next)
|
||||
if cm, ok := next.(chatCompletionMsg); ok {
|
||||
_ = cm
|
||||
break
|
||||
}
|
||||
start = next.(chatStreamDeltaMsg).start
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2042,14 +2075,15 @@ func TestTypingRoutesWithFocus(t *testing.T) {
|
||||
m = next.(model)
|
||||
sel := m.face.Snapshot().Sel
|
||||
// D36 owner fix 2026-10-19: j/k are LETTERS now (they type into the
|
||||
// shared box in both focuses); the tree cursor moves with the
|
||||
// ARROW keys exclusively.
|
||||
// shared box in both focuses). D50 residual 3: the arrows no longer
|
||||
// walk tree rows (the walker is removed) — the semantic selection
|
||||
// stands and the arrows scroll the tasks panel instead.
|
||||
for _, poke := range []rune{tea.KeyDown, tea.KeyDown} {
|
||||
next, _ := m.Update(arrowPress(poke))
|
||||
m = next.(model)
|
||||
}
|
||||
if got := m.face.Snapshot().Sel; got != sel+2 {
|
||||
t.Fatalf("tasks focus: arrows moved sel %d -> %d, want %d (the tree walks on the arrows)", sel, got, sel+2)
|
||||
if got := m.face.Snapshot().Sel; got != sel {
|
||||
t.Fatalf("tasks focus: arrows moved sel %d -> %d, want %d (the row walker is removed)", sel, got, sel)
|
||||
}
|
||||
if got := m.composer.Text(); got != "hi" {
|
||||
t.Fatalf("tasks-focus keys must not disturb the chat draft, got %q", got)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// pins.go — the PINNED WORK hot-state record (D50 residual 4, #2560):
|
||||
// what the owner pinned, persisted across boots. RUNE_HOME law, the
|
||||
// history-depth record's exact pattern: one JSON document at the state
|
||||
// home's root (tasks_pins.json beside history_depth.json), written
|
||||
// atomically (write-beside, rename over). The record never guesses —
|
||||
// absent or corrupt answers an empty pin set.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"rune.dev/rune/tui/tasks"
|
||||
)
|
||||
|
||||
func pinsPath(home string) string {
|
||||
return filepath.Join(home, "tasks_pins.json")
|
||||
}
|
||||
|
||||
// loadPins returns the persisted pins — absent or corrupt is empty,
|
||||
// never a guess.
|
||||
func loadPins(home string) []tasks.Pin {
|
||||
raw, err := os.ReadFile(pinsPath(home))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var doc struct {
|
||||
Pins []tasks.Pin `json:"pins"`
|
||||
}
|
||||
if json.Unmarshal(raw, &doc) != nil {
|
||||
return nil
|
||||
}
|
||||
return doc.Pins
|
||||
}
|
||||
|
||||
// savePins persists the pin set atomically (write-beside, rename
|
||||
// over — the depth-record pattern).
|
||||
func savePins(home string, pins []tasks.Pin) error {
|
||||
if err := os.MkdirAll(home, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(struct {
|
||||
Pins []tasks.Pin `json:"pins"`
|
||||
}{pins}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst := pinsPath(home)
|
||||
tmp := dst + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"rune.dev/rune/tui/semantic"
|
||||
"rune.dev/rune/tui/taskqueue"
|
||||
"rune.dev/rune/tui/tasks"
|
||||
)
|
||||
|
||||
// D50 residual 4 (#2560): PINNED WORK — the pin verb's keyboard parity
|
||||
// is ctrl+b (the control-key family: ctrl+q steers the queue, ctrl+g
|
||||
// raises gauges, ctrl+b pins the top live work block). Pins persist
|
||||
// across boots through the hot-state store (tasks_pins.json beside
|
||||
// history_depth.json — the depth record's exact pattern).
|
||||
func TestD50PinKeyboardParityPersistsAcrossBoots(t *testing.T) {
|
||||
configuredHome(t)
|
||||
home := os.Getenv("RUNE_HOME")
|
||||
bootCompleter = &queueSeamFake{}
|
||||
defer func() { bootCompleter = nil }()
|
||||
|
||||
m := sized(t, initialModel(), 110, 30)
|
||||
// live work to pin: one running block
|
||||
blocks := tasks.DeriveWorkBlocks(tasks.LiveInput{
|
||||
Tasks: []tasks.LiveTask{{ID: "task_1", Title: "migrate the store", Status: "running",
|
||||
Updated: time.Now().UTC().Format(time.RFC3339)}},
|
||||
})
|
||||
m.face.SetLiveWork(blocks, []taskqueue.Entry{{EntryID: "q1", Text: "x",
|
||||
QueuedAt: time.Now()}}, time.Now())
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("one live block expected, got %+v", blocks)
|
||||
}
|
||||
m2, _ := m.Update(tea.KeyPressMsg{Code: 'b', Mod: tea.ModCtrl})
|
||||
m = m2.(model)
|
||||
if n := len(m.face.Pins()); n != 1 {
|
||||
t.Fatalf("ctrl+b must pin the top live work block, got %d pins", n)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(home, "tasks_pins.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("the pin must persist through the hot-state store: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "migrate the store") {
|
||||
t.Fatalf("the persisted record must carry the plain-english summary: %s", raw)
|
||||
}
|
||||
// THE BOOT LEG: a fresh model loads the pins — they survive the boot
|
||||
m3 := sized(t, initialModel(), 110, 30)
|
||||
if n := len(m3.face.Pins()); n != 1 {
|
||||
t.Fatalf("pins must persist across boots, got %d", n)
|
||||
}
|
||||
// the pinned area lives in the tasks pane's region view (opened by
|
||||
// the click road / palette); open it and read the pane.
|
||||
m3.face.SetTreeOpen(true)
|
||||
v := semantic.StripANSI(m3.face.Render().Content)
|
||||
if !strings.Contains(v, "pinned (1)") {
|
||||
t.Fatalf("the fresh boot's tasks pane must render the pinned area:\n%s", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// D50 item 5 (Gitea #2555 item 7): the '/'-command submenu is
|
||||
// SELECTABLE — while the slash hints show, up/down move the selection
|
||||
// over the matching rows and enter runs the HIGHLIGHTED command (not
|
||||
// just an exact or unique prefix). The palette's ↑/↓/enter law, carried
|
||||
// to the inline '/' road.
|
||||
func TestD50SlashHintsSelectableUpDownEnter(t *testing.T) {
|
||||
configuredHome(t)
|
||||
bootCompleter = &fakeChatCompleter{answer: "ok"}
|
||||
defer func() { bootCompleter = nil }()
|
||||
|
||||
m := sized(t, initialModel(), 110, 30)
|
||||
m2, _ := m.Update(keyPress("/"))
|
||||
m = m2.(model)
|
||||
hints := slashHintLines("/", m.hintSel)
|
||||
if len(hints) < 3 {
|
||||
t.Fatalf("the bare slash must list the vocabulary, got %d rows", len(hints))
|
||||
}
|
||||
// down once: the selection moves OFF the first row
|
||||
m2, _ = m.Update(arrowPress(tea.KeyDown))
|
||||
m = m2.(model)
|
||||
marked := 0
|
||||
for _, ln := range slashHintLines("/", m.hintSel) {
|
||||
if strings.HasPrefix(ln, "> ") {
|
||||
marked++
|
||||
}
|
||||
}
|
||||
if marked != 1 {
|
||||
t.Fatalf("exactly one hint row must carry the selection mark, got %d", marked)
|
||||
}
|
||||
if !strings.Contains(slashHintLines("/", m.hintSel)[1], "> ") {
|
||||
t.Fatalf("down must move the mark to the second row:\n%s", strings.Join(slashHintLines("/", m.hintSel), "\n"))
|
||||
}
|
||||
// enter runs the HIGHLIGHTED command (/history, whose no-arg leg
|
||||
// reports the boot depth in the transcript)
|
||||
m2, _ = m.Update(enterPress())
|
||||
m = m2.(model)
|
||||
txt := chatBubbleText(m)
|
||||
if !strings.Contains(txt, "history depth:") {
|
||||
t.Fatalf("enter must run the highlighted hint command (/history):\n%s", txt)
|
||||
}
|
||||
if got := m.composer.Text(); got != "" {
|
||||
t.Fatalf("a run hint command clears the slash text, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -235,6 +235,15 @@ func New(w, h int) *Inputs { return newInputs(semantic.ModeRich, w, h) }
|
||||
// NewLinear returns the linear-projection inputs face (zero-ANSI law).
|
||||
func NewLinear(w, h int) *Inputs { return newInputs(semantic.ModeLinear, w, h) }
|
||||
|
||||
// NewSeeded is the LAB harness's constructor: the canonical seeds
|
||||
// under the integrations chrome (D52 seed-substrate law, #2566 —
|
||||
// production faces boot empty; a test that needs the seed asks for it
|
||||
// explicitly here).
|
||||
func NewSeeded(w, h int) *Inputs {
|
||||
return &Inputs{inner: tasks.NewSeeded(w, h), mode: semantic.ModeRich,
|
||||
connected: map[string]Scope{}, blocked: map[string]Scope{}}
|
||||
}
|
||||
|
||||
func newInputs(mode string, w, h int) *Inputs {
|
||||
inner := tasks.New(w, h)
|
||||
if mode == semantic.ModeLinear {
|
||||
|
||||
@@ -20,7 +20,8 @@ var _ semantic.Face = (*Inputs)(nil)
|
||||
// state so divergence-hiding mutants cannot land on zero values).
|
||||
func seededInputs(t *testing.T) *Inputs {
|
||||
t.Helper()
|
||||
f := New(80, 24)
|
||||
// D52 supersession (#2566): the seed substrate is explicit (lab).
|
||||
f := NewSeeded(80, 24)
|
||||
if a, _ := f.Apply(semantic.Rune('2')); a != "focus.tasks" {
|
||||
t.Fatalf("seed key 2 produced %q", a)
|
||||
}
|
||||
@@ -32,7 +33,9 @@ func seededInputs(t *testing.T) *Inputs {
|
||||
|
||||
func seededTasksFace(t *testing.T) *tasks.Tasks {
|
||||
t.Helper()
|
||||
f := tasks.New(80, 24)
|
||||
// D52 supersession (#2566): production New boots EMPTY — the
|
||||
// delegation oracle needs the canonical seed on both sides.
|
||||
f := tasks.NewSeeded(80, 24)
|
||||
f.Apply(semantic.Rune('2'))
|
||||
f.Apply(semantic.Rune('j'))
|
||||
return f
|
||||
@@ -141,16 +144,20 @@ func TestInputsModalOutranksChrome(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputsInnerRegionOutranksChrome: an open inner region (task tree)
|
||||
// swallows i — the inputs chrome never bypasses an inner boundary.
|
||||
// TestInputsInnerRegionOutranksChrome (re-pinned D50 residual 3): the
|
||||
// task tree region is a VIEW now — it does not own keys, so the inputs
|
||||
// chrome routes exactly as with the tree closed. Genuine inner chrome
|
||||
// (the promote region) still outranks the inputs layer.
|
||||
func TestInputsInnerRegionOutranksChrome(t *testing.T) {
|
||||
f := seededInputs(t)
|
||||
f.inner.SetTreeOpen(true) // D36 owner fix: t is a letter now
|
||||
if !f.inner.Open() {
|
||||
t.Fatal("the tree must be open programmatically")
|
||||
}
|
||||
if a, _ := f.Apply(semantic.Rune('i')); a != tasks.TasksSwallow {
|
||||
t.Errorf("i during open tree produced %q, want %q", a, tasks.TasksSwallow)
|
||||
a, _ := f.Apply(semantic.Rune('i'))
|
||||
ref, _ := f.inner.Apply(semantic.Rune('i'))
|
||||
if a != ref {
|
||||
t.Errorf("i during open tree produced %q, want the delegated %q (the tree region is a view)", a, ref)
|
||||
}
|
||||
if f.Open() {
|
||||
t.Error("view opened through an open inner region")
|
||||
|
||||
@@ -39,6 +39,9 @@ import (
|
||||
// the I3 library seam's dispatch half satisfies the dispatcher
|
||||
var _ Completer = (*lib.Repo)(nil)
|
||||
|
||||
// the streaming leg too (Gitea #2556)
|
||||
var _ StreamCompleter = (*lib.Repo)(nil)
|
||||
|
||||
// Lane names the two permanent lanes of the dual-lane interface.
|
||||
type Lane string
|
||||
|
||||
@@ -202,6 +205,14 @@ type Completer interface {
|
||||
Complete(ctx context.Context, req lib.Completion) (lib.CompletionResult, error)
|
||||
}
|
||||
|
||||
// StreamCompleter is the streaming leg of the seam (Gitea #2556):
|
||||
// satisfied by *lib.Repo's CompleteStream. A dispatcher whose
|
||||
// completer streams uses it so the answer's bytes surface AS THEY
|
||||
// ARRIVE; fakes that don't implement it keep the non-streaming path.
|
||||
type StreamCompleter interface {
|
||||
CompleteStream(ctx context.Context, req lib.Completion, onDelta func(string)) (lib.CompletionResult, error)
|
||||
}
|
||||
|
||||
// LaneReport is one lane's dispatch outcome: the seam's result, the
|
||||
// shared-memory write that committed it, or the honest error (one
|
||||
// lane's failure never disturbs the other lane).
|
||||
@@ -223,6 +234,29 @@ type Dispatcher struct {
|
||||
comp Completer
|
||||
model string
|
||||
shared *Shared
|
||||
// deltaSinkMu guards deltaSinkFn: the TUI installs the streaming
|
||||
// delta sink just before dispatch (the composer's goroutine).
|
||||
deltaSinkMu sync.Mutex
|
||||
deltaSinkFn func(lane Lane, text string)
|
||||
}
|
||||
|
||||
// SetDeltaSink installs the streaming delta sink (Gitea #2556): each
|
||||
// lane's streamed text delta lands here AS IT ARRIVES. nil clears it
|
||||
// (the non-streaming fallback callers).
|
||||
func (d *Dispatcher) SetDeltaSink(fn func(lane Lane, text string)) {
|
||||
d.deltaSinkMu.Lock()
|
||||
defer d.deltaSinkMu.Unlock()
|
||||
d.deltaSinkFn = fn
|
||||
}
|
||||
|
||||
func (d *Dispatcher) deltaSink(lane Lane) func(string) {
|
||||
d.deltaSinkMu.Lock()
|
||||
defer d.deltaSinkMu.Unlock()
|
||||
if d.deltaSinkFn == nil {
|
||||
return nil
|
||||
}
|
||||
fn := d.deltaSinkFn
|
||||
return func(text string) { fn(lane, text) }
|
||||
}
|
||||
|
||||
// New returns a dispatcher over the seam (model "" = the core's own
|
||||
@@ -271,12 +305,26 @@ func (d *Dispatcher) Dispatch(ctx context.Context, lane Lane, input string) (Lan
|
||||
|
||||
// dispatchLane runs one lane's model call and commits its result. A
|
||||
// failed dispatch commits nothing and reports its error honestly.
|
||||
// The streaming leg (Gitea #2556) forwards the answer's text deltas
|
||||
// to the installed sink (never nil) as they arrive; the non-streaming
|
||||
// completer keeps the classic path.
|
||||
func (d *Dispatcher) dispatchLane(ctx context.Context, lane Lane, input string) LaneReport {
|
||||
res, err := d.comp.Complete(ctx, lib.Completion{
|
||||
req := lib.Completion{
|
||||
Model: d.model,
|
||||
Channel: string(lane),
|
||||
Messages: []lib.CompletionMessage{{Role: "user", Content: input}},
|
||||
})
|
||||
}
|
||||
var res lib.CompletionResult
|
||||
var err error
|
||||
if sink := d.deltaSink(lane); sink != nil {
|
||||
if sc, ok := d.comp.(StreamCompleter); ok {
|
||||
res, err = sc.CompleteStream(ctx, req, sink)
|
||||
} else {
|
||||
res, err = d.comp.Complete(ctx, req)
|
||||
}
|
||||
} else {
|
||||
res, err = d.comp.Complete(ctx, req)
|
||||
}
|
||||
if err != nil {
|
||||
return LaneReport{Err: err}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package laneconc
|
||||
|
||||
// stream_dispatch_test.go — the lane dispatcher's streaming leg
|
||||
// (Gitea #2556, red-first): a StreamCompleter's deltas flow to the
|
||||
// installed sink as they arrive; a completer without the streaming
|
||||
// interface keeps the non-streaming path; no sink = the classic path.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/runtime/lib"
|
||||
)
|
||||
|
||||
type fakeStreamRepo struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeStreamRepo) Complete(ctx context.Context, req lib.Completion) (lib.CompletionResult, error) {
|
||||
return lib.CompletionResult{ID: "id", Content: "whole answer"}, f.err
|
||||
}
|
||||
|
||||
func (f *fakeStreamRepo) CompleteStream(ctx context.Context, req lib.Completion, onDelta func(string)) (lib.CompletionResult, error) {
|
||||
if onDelta != nil {
|
||||
onDelta("he")
|
||||
onDelta("llo")
|
||||
}
|
||||
return lib.CompletionResult{ID: "id", Content: "hello"}, f.err
|
||||
}
|
||||
|
||||
func TestDispatcherStreamsDeltasToSink(t *testing.T) {
|
||||
d := New(&fakeStreamRepo{})
|
||||
var got []string
|
||||
d.SetDeltaSink(func(lane Lane, text string) { got = append(got, string(lane)+":"+text) })
|
||||
rep, err := d.Dispatch(context.Background(), Chat, "hi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.Err != nil {
|
||||
t.Fatal(rep.Err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "chat:he" || got[1] != "chat:llo" {
|
||||
t.Fatalf("deltas = %v", got)
|
||||
}
|
||||
if rep.Result.Content != "hello" {
|
||||
t.Fatalf("content = %q", rep.Result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherWithoutSinkKeepsClassicPath(t *testing.T) {
|
||||
d := New(&fakeStreamRepo{})
|
||||
rep, err := d.Dispatch(context.Background(), Task, "hi")
|
||||
if err != nil || rep.Err != nil {
|
||||
t.Fatal(err, rep.Err)
|
||||
}
|
||||
if rep.Result.Content != "whole answer" {
|
||||
t.Fatalf("content = %q — the classic path must hold", rep.Result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherStreamErrorReportsHonest(t *testing.T) {
|
||||
d := New(&fakeStreamRepo{err: errors.New("wire down")})
|
||||
d.SetDeltaSink(func(Lane, string) {})
|
||||
rep, _ := d.Dispatch(context.Background(), Chat, "hi")
|
||||
if rep.Err == nil {
|
||||
t.Fatal("want the honest error")
|
||||
}
|
||||
}
|
||||
+153
-26
@@ -33,6 +33,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"rune.dev/rune/tui/chatpane"
|
||||
"rune.dev/rune/tui/themes"
|
||||
)
|
||||
|
||||
// ActivityState is one activity the pane can name. Exactly the spec's
|
||||
@@ -108,11 +109,11 @@ func DeriveActivity(sig ActivitySignal) ActivityState {
|
||||
// activityColors is the per-activity palette (A2): each activity its
|
||||
// OWN colour, every tone a floor-legal family colour on the base.
|
||||
var activityColors = map[ActivityState]string{
|
||||
ActivityIdle: "#78788A", // the dim family — the pane rests
|
||||
ActivityThinking: "#C4A0FF", // rune's purple family
|
||||
ActivityCoding: "#7EE787", // the assistant green family
|
||||
ActivityPlanning: "#E3B341", // the gold family
|
||||
ActivityResearching: "#58A6FF", // the blue research family
|
||||
ActivityIdle: themes.RoleHex(themes.RoleMetaDim), // the dim family — the pane rests
|
||||
ActivityThinking: "#C4A0FF", // rune's purple family
|
||||
ActivityCoding: "#7EE787", // the assistant green family
|
||||
ActivityPlanning: "#E3B341", // the gold family
|
||||
ActivityResearching: "#58A6FF", // the blue research family
|
||||
}
|
||||
|
||||
// activityWord names the state in the pane (A1's labels verbatim).
|
||||
@@ -138,15 +139,29 @@ type ActivityPane struct {
|
||||
open bool
|
||||
turn ActivityTurn
|
||||
reduced bool
|
||||
// motionAllowed is the D54 MOTION LAW gate (#2571 item 1): every
|
||||
// render frame of this pane passes through it. When it is false
|
||||
// the strip is frame-frozen — no wave, no rainbow advance, no
|
||||
// walking runes — the same rest idle already takes (A5). The
|
||||
// chat pane never animates; this pane is the face's ONE
|
||||
// animating region.
|
||||
motionAllowed bool
|
||||
}
|
||||
|
||||
func newActivityPane() *ActivityPane {
|
||||
return &ActivityPane{
|
||||
state: ActivityIdle,
|
||||
seed: rand.New(rand.NewSource(time.Now().UnixNano())).Int63(),
|
||||
state: ActivityIdle,
|
||||
seed: rand.New(rand.NewSource(time.Now().UnixNano())).Int63(),
|
||||
motionAllowed: true,
|
||||
}
|
||||
}
|
||||
|
||||
// SetMotionAllowed opens (true) or closes (false) the motion gate.
|
||||
func (a *ActivityPane) SetMotionAllowed(on bool) { a.motionAllowed = on }
|
||||
|
||||
// MotionAllowed reports the gate's state.
|
||||
func (a *ActivityPane) MotionAllowed() bool { return a.motionAllowed }
|
||||
|
||||
// SetSignal folds one real-signal reading: the state derives, and the
|
||||
// turn-active bit drives the chat pane's live-thinking suppression
|
||||
// (the D43 addendum 6 handoff — the pane declutters to conversation
|
||||
@@ -185,6 +200,12 @@ func (a *ActivityPane) WindowOpen() bool { return a.open }
|
||||
// activityStripWidth reserves the + affordance's right-edge columns.
|
||||
const activityStripWidth = 5
|
||||
|
||||
// activityPlusWidth is the + affordance's click-region columns — the
|
||||
// last 3 columns of the strip (mirrors lanes.render's
|
||||
// activityPlusWidth; the region and the render MUST agree, the D52
|
||||
// click-mapping law).
|
||||
const activityPlusWidth = 3
|
||||
|
||||
// Render renders the strip's one row at width w: the runic marks
|
||||
// (walking, per-rune coloured, while active; ABSENT at idle per the
|
||||
// owner boot ruling), the activity word (rainbow while active, dim at
|
||||
@@ -207,6 +228,12 @@ func (a *ActivityPane) Render(w int, styled bool) string {
|
||||
if styled {
|
||||
if a.state == ActivityIdle {
|
||||
wordOut = activityIdleStyle.Render(word)
|
||||
} else if !a.motionAllowed {
|
||||
// MOTION LAW (#2571 item 1): the gate is closed — the
|
||||
// word rests in its per-activity colour, frame-frozen.
|
||||
for _, r := range word {
|
||||
wordOut += activityStyle(a.state).Render(string(r))
|
||||
}
|
||||
} else {
|
||||
for _, run := range a.snakeWord(word) {
|
||||
wordOut += run
|
||||
@@ -219,8 +246,13 @@ func (a *ActivityPane) Render(w int, styled bool) string {
|
||||
line = line + " " + marks
|
||||
}
|
||||
line += strings.Repeat(" ", max(inner-len([]rune(stripANSI(line))), 0))
|
||||
plus := activityPlusStyle.Render("+")
|
||||
return line + strings.Repeat(" ", max(activityStripWidth-len(plus)-2, 0)) + plus
|
||||
// D52 fix (#2566 item 3): pad by the + GLYPH's display width
|
||||
// (1 column), never the styled string's byte length — the SGR
|
||||
// bytes once pushed the glyph 3 columns left of its declared
|
||||
// click region (mouse.activity.plus covers the last
|
||||
// activityPlusWidth columns) and the owner's click missed.
|
||||
return line + strings.Repeat(" ", max(activityPlusWidth-1, 0)) +
|
||||
activityPlusStyle.Render("+")
|
||||
}
|
||||
line := word
|
||||
if a.state != ActivityIdle {
|
||||
@@ -230,29 +262,102 @@ func (a *ActivityPane) Render(w int, styled bool) string {
|
||||
return line + strings.Repeat(" ", activityStripWidth-2) + " +"
|
||||
}
|
||||
|
||||
// snakeWord renders the rainbow word with a SNAKE-LIKE SWEEP (owner
|
||||
// live ruling 2026-10-22): a brightness crest travels from the word's
|
||||
// first letter through its last and BACK — the t to the g and home —
|
||||
// while every character keeps its own hue on the wheel. The frame
|
||||
// advances the sweep one cell per tick.
|
||||
// snakeWord renders the word with the owner's WAVE motion (D52, #2566
|
||||
// item 2 — replaces the snake sweep): the first letter rises up and
|
||||
// settles, then the second rises and settles, ... sequentially to the
|
||||
// last letter, then the wave REVERSES back to the start, looping. The
|
||||
// rise is smooth (framesPerLetter graded steps, not a hop). Every
|
||||
// letter keeps its own hue on the wheel (A3), the per-activity
|
||||
// palette holds the resting letters' tone, and the MOVING letter
|
||||
// carries a vibrant rainbow hue that advances with the frame.
|
||||
func (a *ActivityPane) snakeWord(word string) []string {
|
||||
runes := []rune(word)
|
||||
n := len(runes)
|
||||
sweep := a.frame % max(2*n-2, 1)
|
||||
out := make([]string, 0, n)
|
||||
var out []string
|
||||
for i, r := range runes {
|
||||
dist := sweep - i
|
||||
if dist < 0 {
|
||||
dist = -dist
|
||||
o := a.waveOffset(n, a.frame, i)
|
||||
if a.state == ActivityIdle {
|
||||
// A5: idle rests dim — no wave, no rainbow (the Render path
|
||||
// styles idle with activityIdleStyle; snakeWord stays honest
|
||||
// when called directly).
|
||||
out = append(out, activityIdleStyle.Render(string(r)))
|
||||
continue
|
||||
}
|
||||
baseHue := (i*36 + a.frame*12) % 360
|
||||
hue := baseHue
|
||||
sat := 0.85
|
||||
light := 0.62
|
||||
if o > 0 {
|
||||
// the moving letter: vibrant rainbow on the crest — higher
|
||||
// saturation, a hue that sweeps with the frame, brighter at
|
||||
// the peak (the vibrancy ruling). Idle NEVER waves.
|
||||
hue = (baseHue + a.frame*24) % 360
|
||||
sat = 1.0
|
||||
light = 0.68 + 0.06*float64(o)
|
||||
}
|
||||
l := 0.62 + 0.1*float64(min(dist, 3))
|
||||
hue := (i*36 + a.frame*12) % 360
|
||||
out = append(out, lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(hslHex(hue, 0.85, l))).Render(string(r)))
|
||||
Foreground(lipgloss.Color(hslHex(hue, sat, light))).Render(string(r)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wavePeak is the moving letter's top offset (display rows above the
|
||||
// baseline the renderer lifts the glyph by — one row on a one-row
|
||||
// strip, so the visible physics are the styled brightness crest the
|
||||
// offset drives; the pane's own geometry stays one row).
|
||||
const wavePeak = 3
|
||||
|
||||
// waveFramesPerLetter — more frames for a smooth rise and settle
|
||||
// (D52 ruling): the graded ramp is 4 frames per letter.
|
||||
const waveFramesPerLetter = 4
|
||||
|
||||
// framesPerLetter reports the wave's frames-per-letter step (test
|
||||
// seam + the shell's pacing reference).
|
||||
func (a *ActivityPane) framesPerLetter() int { return waveFramesPerLetter }
|
||||
|
||||
// waveOffset returns letter i's vertical offset (0..wavePeak) at
|
||||
// frame f: a triangular crest of waveFramesPerLetter frames walking
|
||||
// the letters 0..n-1 and BACK (2n-3 .. 1), looping (the reverse wave).
|
||||
func (a *ActivityPane) waveOffset(n, f, i int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
steps := 2*n - 2 // the crest positions: forward 0..n-1, back n-2..1
|
||||
if steps <= 0 {
|
||||
steps = 1
|
||||
}
|
||||
pos := (f / waveFramesPerLetter) % steps
|
||||
// the crest is at letter pos; letter i is lit only at its own
|
||||
// crest frame (the sequential rise-settle law: letters before the
|
||||
// crest have SETTLED, letters after have not risen).
|
||||
if pos != i {
|
||||
return 0
|
||||
}
|
||||
// the smooth ramp within the crest's frames: 1,2,3,2 (rise, hold
|
||||
// the peak, settle — graded, not a hop)
|
||||
switch k := f % waveFramesPerLetter; {
|
||||
case k == 0:
|
||||
return 1
|
||||
case k == 1:
|
||||
return 2
|
||||
case k == 2:
|
||||
return wavePeak
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// waveCycleLen reports the wave's full loop length in frames (test
|
||||
// seam): one crest frame-set per position over the forward+reverse
|
||||
// walk.
|
||||
func (a *ActivityPane) waveCycleLen(n int) int {
|
||||
steps := 2*n - 2
|
||||
if steps <= 0 {
|
||||
steps = 1
|
||||
}
|
||||
return steps * waveFramesPerLetter
|
||||
}
|
||||
|
||||
// marksRun renders the walking runic marks (A2: the runic animations
|
||||
// are KEPT): glyphs from the one shared vocabulary, each coloured
|
||||
// within the activity's palette (per-rune randomisation, deterministic
|
||||
@@ -263,6 +368,10 @@ func (a *ActivityPane) marksRun(room int) string {
|
||||
fit := max(min((room-16)/2, 3), 1)
|
||||
n := 1 + a.frame%fit
|
||||
restart := a.frame / fit
|
||||
if !a.motionAllowed {
|
||||
// MOTION LAW: the runes rest — one mark, frame-frozen.
|
||||
n, restart = 1, 0
|
||||
}
|
||||
palette := activityPalette(a.state)
|
||||
var b strings.Builder
|
||||
for i := 0; i < n; i++ {
|
||||
@@ -305,7 +414,7 @@ func (a *ActivityPane) WindowLines(w int) []string {
|
||||
out = append(out, activitySectionStyle.Render(title))
|
||||
}
|
||||
if len(body) == 0 {
|
||||
out = append(out, " "+activityIdleStyle.Render(clip("(none in flight)", inner-2)))
|
||||
out = append(out, " "+activityIdleStyle.Render(clip("(none now)", inner-2)))
|
||||
} else {
|
||||
out = append(out, body...)
|
||||
}
|
||||
@@ -385,11 +494,29 @@ func activityStyle(s ActivityState) lipgloss.Style {
|
||||
}
|
||||
|
||||
var (
|
||||
activityIdleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#78788A"))
|
||||
activityPlusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
|
||||
activitySectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C4A0FF"))
|
||||
activityIdleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleStateIdle)))
|
||||
activityPlusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
activitySectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleStateActivity)))
|
||||
)
|
||||
|
||||
// ApplyCalm selects the activity layer's presentation mode (D54
|
||||
// #2571 item 5): the styles re-resolve from the themes role tokens.
|
||||
func ApplyCalm(on bool) {
|
||||
themes.SetCalm(on)
|
||||
refreshRoleStyles()
|
||||
}
|
||||
|
||||
// refreshRoleStyles re-resolves the package-level role styles from
|
||||
// the themes tokens (package vars resolve once at init; a
|
||||
// presentation flip must re-resolve them).
|
||||
func refreshRoleStyles() {
|
||||
activityColors[ActivityIdle] = themes.RoleHex(themes.RoleStateIdle)
|
||||
activityIdleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleStateIdle)))
|
||||
composerHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleHintPink)))
|
||||
activityPlusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleSpeaker)))
|
||||
activitySectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleStateActivity)))
|
||||
}
|
||||
|
||||
// wrapANSI word-wraps one text run at w (the wrap law, word
|
||||
// boundaries; a lone long word hard-wraps).
|
||||
func wrapANSI(text string, w int) []string {
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestActivityStripReflectsState(t *testing.T) {
|
||||
func chatVocabRunes() []rune { return []rune("ᚠᚢᚦᚨᚱᚲᛉᛏᛒᛗ⍜⏃⌬☾✇") }
|
||||
|
||||
func TestActivityRainbowWordActive(t *testing.T) {
|
||||
a := &ActivityPane{state: ActivityCoding, frame: 3}
|
||||
a := &ActivityPane{state: ActivityCoding, frame: 3, motionAllowed: true}
|
||||
row := a.Render(80, true)
|
||||
// the rainbow word (A3): the word's characters carry per-character
|
||||
// colour runs — multiple distinct SGR hues on one row
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package lanes
|
||||
|
||||
// d52_clickmap_test.go — the D52 click-region mapping law (#2566 item
|
||||
// 3, #2567): every region a face declares must lie ON the rendered
|
||||
// glyph it names. The + affordance's live defect: Render pads the
|
||||
// strip with the ANSI-STYLED + string's BYTE length (8 SGR bytes), so
|
||||
// the glyph landed 3 columns left of its declared region — the owner
|
||||
// clicked the visible + and missed. The law here is the debuggable
|
||||
// assertion: for every region, StripANSI of the row the region sits
|
||||
// on carries the region's marker INSIDE the region's columns.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
// assertRegionOnGlyph renders the face, walks every declared region,
|
||||
// and reports which rendered column each region's marker actually
|
||||
// sits at (the click→region mapping, testable without the flaky PTY
|
||||
// input leg). Returns a diagnostic map for the failing assertion.
|
||||
func regionGlyphColumns(l *Lanes) map[string]string {
|
||||
fr := l.Render()
|
||||
lines := strings.Split(semantic.StripANSI(fr.Content), "\n")
|
||||
out := map[string]string{}
|
||||
for _, r := range fr.Regions {
|
||||
if r.Y < 0 || r.Y >= len(lines) {
|
||||
out[r.ID] = "REGION OFF-SCREEN"
|
||||
continue
|
||||
}
|
||||
row := lines[r.Y]
|
||||
end := min(r.X+r.W, len(row))
|
||||
seg := ""
|
||||
if r.X < len(row) && end > r.X {
|
||||
seg = row[r.X:end]
|
||||
}
|
||||
out[r.ID] = strings.TrimSpace(seg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestPlusRegionLandsOnTheGlyph — the + region's columns must contain
|
||||
// a rendered '+' (the owner's click target).
|
||||
func TestPlusRegionLandsOnTheGlyph(t *testing.T) {
|
||||
for _, size := range [][2]int{{80, 24}, {120, 30}, {60, 20}} {
|
||||
l := New(size[0], size[1])
|
||||
l.SetComposer([]string{}, "type in chat", "hint")
|
||||
cols := regionGlyphColumns(l)
|
||||
seg, ok := cols["mouse.activity.plus"]
|
||||
if !ok {
|
||||
t.Fatalf("no + region declared at %dx%d", size[0], size[1])
|
||||
}
|
||||
if !strings.Contains(seg, "+") {
|
||||
t.Fatalf("the + region (%dx%d) must lie ON the rendered '+', its columns carry %q — the region and the glyph diverged",
|
||||
size[0], size[1], seg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlusClickTogglesAtTheGlyphColumn — the click at the GLYPH's
|
||||
// rendered column (not the region's declared-left edge) toggles the
|
||||
// window: the exact miss the owner reported.
|
||||
func TestPlusClickTogglesAtTheGlyphColumn(t *testing.T) {
|
||||
l := New(80, 24)
|
||||
l.SetComposer([]string{}, "type in chat", "hint")
|
||||
fr := l.Render()
|
||||
row := -1
|
||||
for y, ln := range strings.Split(semantic.StripANSI(fr.Content), "\n") {
|
||||
if strings.Contains(ln, "idle") {
|
||||
row = y
|
||||
}
|
||||
}
|
||||
if row < 0 {
|
||||
t.Fatal("no activity row rendered")
|
||||
}
|
||||
plain := strings.Split(semantic.StripANSI(fr.Content), "\n")[row]
|
||||
glyphX := strings.Index(plain, "+")
|
||||
if glyphX < 0 {
|
||||
t.Fatal("no + glyph on the strip row")
|
||||
}
|
||||
if a := l.Click(glyphX, row); a != semantic.ActionID("mouse.activity.plus") {
|
||||
t.Fatalf("clicking the VISIBLE + at column %d must toggle the window, got %q", glyphX, a)
|
||||
}
|
||||
if !l.ActivityWindowOpen() {
|
||||
t.Fatal("the glyph-column click must OPEN the window")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package lanes
|
||||
|
||||
// d52_wave_test.go — the owner's WAVE ruling (D52, #2566 item 2,
|
||||
// replacing the snake sweep): the activity word animates per-letter as
|
||||
// a WAVE — the first letter rises up and settles, then the second
|
||||
// rises and settles, ... sequentially to the last letter, then the
|
||||
// wave REVERSES back to the start, looping. More frames (a smooth
|
||||
// rise/settle, not a 2-step hop) and more vibrant colour while in the
|
||||
// thinking/activity state: brighter saturation plus the rainbow sweep
|
||||
// on the MOVING letter (the per-activity palette stays, D48 rulings).
|
||||
// Layer Law: own state, own tests; the frame timing respects the
|
||||
// arbitration law (canon P3) — the shell already rests the other
|
||||
// animations while the activity pane moves.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/semantic"
|
||||
"rune.dev/rune/tui/themes"
|
||||
)
|
||||
|
||||
// letterOffsets extracts the per-letter VERTICAL offset phase from the
|
||||
// rendered word's row structure. The pane renders the moving letter's
|
||||
// offset as a modifier marker (an SGR italic/reverse run is display
|
||||
// noise on a one-row strip — the wave's physics live in the pane's
|
||||
// offsets method, which Render consumes; the test pins the METHOD's
|
||||
// offsets over time and the render's consumption of them).
|
||||
func TestWaveOffsetsRiseSettleReverse(t *testing.T) {
|
||||
a := &ActivityPane{state: ActivityThinking}
|
||||
word := activityWord(ActivityThinking) // "thinking" — 8 letters, g last
|
||||
n := len([]rune(word))
|
||||
cycle := a.waveCycleLen(n)
|
||||
|
||||
// helper: offset of letter i at frame f
|
||||
off := func(f, i int) int { return a.waveOffset(n, f, i) }
|
||||
|
||||
// PHASE 1 — the sequential rise: at the frame the crest reaches
|
||||
// letter i, letter i reaches its PEAK within the crest's frames
|
||||
// (the graded ramp tops at wavePeak); letters j < i have SETTLED
|
||||
// (back to 0) and letters j > i have not risen yet.
|
||||
for i := 0; i < n; i++ {
|
||||
crest := i*a.framesPerLetter() + 2 // the peak frame of the ramp
|
||||
if off(crest, i) != wavePeak {
|
||||
t.Fatalf("letter %d must be at peak %d at its crest frame %d, got %d",
|
||||
i, wavePeak, crest, off(crest, i))
|
||||
}
|
||||
for j := 0; j < i; j++ {
|
||||
if off(crest, j) != 0 {
|
||||
t.Fatalf("letter %d must have settled to 0 by letter %d's crest (frame %d), got %d",
|
||||
j, i, crest, off(crest, j))
|
||||
}
|
||||
}
|
||||
for j := i + 1; j < n; j++ {
|
||||
if off(crest, j) != 0 {
|
||||
t.Fatalf("letter %d must not have risen at letter %d's crest (frame %d), got %d",
|
||||
j, i, crest, off(crest, j))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 2 — the reverse: after the last letter settles, the crest
|
||||
// walks BACK (n-2, n-3, ... 0), then the cycle restarts.
|
||||
lastCrest := (n-1)*a.framesPerLetter() + 2
|
||||
first := (2*n-2)*a.framesPerLetter() + 2 // the reverse crest on letter 0
|
||||
if off(first, 0) != wavePeak {
|
||||
t.Fatalf("the reverse must crest letter 0 at frame %d, got %d", first, off(first, 0))
|
||||
}
|
||||
if off(lastCrest, n-1) != wavePeak {
|
||||
t.Fatal("the forward crest reaches the last letter")
|
||||
}
|
||||
// the cycle loops: frame 0 and frame cycle render the same offsets
|
||||
for i := 0; i < n; i++ {
|
||||
if off(0, i) != off(cycle, i) {
|
||||
t.Fatalf("the wave must loop: letter %d differs at frame 0 vs cycle %d", i, cycle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaveSmoothRise — more frames: the rise is a graded ramp through
|
||||
// intermediate offsets, not a 2-step hop.
|
||||
func TestWaveSmoothRise(t *testing.T) {
|
||||
a := &ActivityPane{state: ActivityThinking}
|
||||
n := len([]rune(activityWord(ActivityThinking)))
|
||||
if a.framesPerLetter() < 3 {
|
||||
t.Fatalf("the rise must be smooth (multiple frames per letter), got %d frames/letter", a.framesPerLetter())
|
||||
}
|
||||
// letter 0's journey passes through an intermediate height
|
||||
seen := map[int]bool{}
|
||||
for f := 0; f < a.framesPerLetter(); f++ {
|
||||
seen[a.waveOffset(n, f, 0)] = true
|
||||
}
|
||||
if len(seen) < 3 {
|
||||
t.Fatalf("letter 0's rise must pass through >=3 distinct offsets, saw %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaveVibrantThinking — the thinking/activity state is VIBRANT:
|
||||
// higher saturation than the resting palette tone, and the MOVING
|
||||
// letter carries a rainbow hue that advances with the frame (the
|
||||
// per-activity palette keeps the hue family for the resting letters).
|
||||
func TestWaveVibrantThinking(t *testing.T) {
|
||||
a := &ActivityPane{state: ActivityThinking, frame: 2}
|
||||
runs := a.snakeWord("thinking")
|
||||
joined := strings.Join(runs, "")
|
||||
if got := strings.Count(joined, "\x1b[38;2"); got < 2 {
|
||||
t.Fatalf("the active word must stay multi-colour (the D48 palette + rainbow mover): %d runs", got)
|
||||
}
|
||||
// the moving letter's hue differs from the resting letter's hue at
|
||||
// the same frame (the rainbow sweep rides the crest)
|
||||
mover := 1 * a.framesPerLetter()
|
||||
restA := &ActivityPane{state: ActivityThinking, frame: mover}
|
||||
runsA := restA.snakeWord("thinking")
|
||||
hue := func(runs []string, i int) string {
|
||||
s := semantic.StripANSI(runs[i])
|
||||
_ = s
|
||||
return runs[i]
|
||||
}
|
||||
if hue(runsA, 1) == hue(runsA, 0) {
|
||||
t.Fatal("the moving letter must carry its own rainbow hue (distinct from the resting letter)")
|
||||
}
|
||||
// vibrancy: the moving letter's saturation exceeds the base
|
||||
// palette tone's (the parseHex SGR carry shows a brighter colour
|
||||
// than the activity palette's floor tone)
|
||||
if !strings.Contains(runsA[1], "\x1b[38;2") {
|
||||
t.Fatal("the moving letter must be true-colour styled")
|
||||
}
|
||||
// the frame advances the mover's colour (animated, not frozen)
|
||||
restB := &ActivityPane{state: ActivityThinking, frame: mover + 1}
|
||||
runsB := restB.snakeWord("thinking")
|
||||
if runsA[1] == runsB[1] {
|
||||
t.Fatal("the moving letter's rainbow hue must advance with the frame")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaveIdleRests — idle keeps the dim rest (A5) — no wave, no
|
||||
// rainbow, no motion.
|
||||
func TestWaveIdleRests(t *testing.T) {
|
||||
a := &ActivityPane{state: ActivityIdle, frame: 5}
|
||||
row := a.Render(80, true)
|
||||
// the word rests in its state tone; the D53 #2568 item 6 owner
|
||||
// ruling SUPERSEDES the D52 dim rest: idle renders in the
|
||||
// RoleStateIdle yellow token, frame-frozen (no wave, no rainbow).
|
||||
idle := themes.RoleHex(themes.RoleStateIdle)
|
||||
var r, g, b int
|
||||
fmt.Sscanf(idle, "#%02x%02x%02x", &r, &g, &b)
|
||||
want := fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
|
||||
if !strings.Contains(row, want) {
|
||||
t.Fatalf("idle's word must rest in the RoleStateIdle token:\n%s", row)
|
||||
}
|
||||
for _, run := range a.snakeWord("idle") {
|
||||
if strings.Count(run, "\x1b[38;2") > 1 || !strings.Contains(run, want[2:]) {
|
||||
t.Fatalf("idle's word runs must rest in the idle token, saw %q", run)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package lanes
|
||||
|
||||
// d53_agents.go — the SUB-AGENTS wave (Gitea #2568 item 7): the
|
||||
// activity pane gains a "sub agents being processed now" section
|
||||
// rendered ONLY while agents are in flight (the shell's syncWaves
|
||||
// flight observation — the same poller the rail's waves marker reads),
|
||||
// clickable AND keyboard-openable (click + keyboard parity), opening
|
||||
// a CENTERED window listing all in-flight agents. Each agent row is
|
||||
// inspectable: the window shows the selected agent's thought
|
||||
// process / recent activity — the factory journal + observations
|
||||
// detail the shell already derives. Render-what-exists honesty: a
|
||||
// row with no recorded process shows the shell's honest gap lines.
|
||||
//
|
||||
// The seam is plain-value (rail's flightView precedent): lanes cannot
|
||||
// import tui/tasks (tasks → rail → lanes is structural), so the rows
|
||||
// arrive as AgentView values. Display state only — never semantic
|
||||
// state, never the fingerprint, rides Clone.
|
||||
import "fmt"
|
||||
|
||||
// AgentView is one in-flight sub-agent as the shell observed it.
|
||||
type AgentView struct {
|
||||
ID string
|
||||
Title string
|
||||
State string
|
||||
Detail []string // thought process / recent activity lines
|
||||
}
|
||||
|
||||
// agentsSection is the section's literal heading (the D54
|
||||
// literal-language law — no idiom, no figure of speech).
|
||||
const agentsSection = "sub agents being processed now"
|
||||
|
||||
// SetWaveAgents installs one flight observation (copied, never
|
||||
// aliased). count is the poller's own number; rows the per-agent
|
||||
// views. count <= 0 clears the section.
|
||||
func (l *Lanes) SetWaveAgents(count int, rows []AgentView) {
|
||||
if count <= 0 {
|
||||
l.agents = nil
|
||||
return
|
||||
}
|
||||
l.agentsCount = count
|
||||
l.agents = append([]AgentView(nil), rows...)
|
||||
}
|
||||
|
||||
// WaveAgentsCount reports the installed flight count (0 = no flight).
|
||||
func (l *Lanes) WaveAgentsCount() int {
|
||||
if l.agents == nil {
|
||||
return 0
|
||||
}
|
||||
return l.agentsCount
|
||||
}
|
||||
|
||||
// WaveAgents returns a copy of the installed per-agent views.
|
||||
func (l *Lanes) WaveAgents() []AgentView {
|
||||
return append([]AgentView(nil), l.agents...)
|
||||
}
|
||||
|
||||
// agentsWindow state: the centered window's open bit, the cursor, and
|
||||
// which rows show their detail.
|
||||
func (l *Lanes) ToggleAgentsWindow() bool {
|
||||
if l.WaveAgentsCount() == 0 {
|
||||
return false // no flight, no window (the section never lies)
|
||||
}
|
||||
l.agentsOpen = !l.agentsOpen
|
||||
return true
|
||||
}
|
||||
|
||||
// CloseAgentsWindow dismisses the window (esc, any key).
|
||||
func (l *Lanes) CloseAgentsWindow() { l.agentsOpen = false }
|
||||
|
||||
// AgentsWindowOpen reports whether the centered window is showing.
|
||||
func (l *Lanes) AgentsWindowOpen() bool { return l.agentsOpen }
|
||||
|
||||
// AgentsSelMove moves the window's cursor (keyboard parity leg).
|
||||
func (l *Lanes) AgentsSelMove(d int) {
|
||||
n := len(l.agents)
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
l.agentsSel += d
|
||||
if l.agentsSel < 0 {
|
||||
l.agentsSel = 0
|
||||
}
|
||||
if l.agentsSel >= n {
|
||||
l.agentsSel = n - 1
|
||||
}
|
||||
}
|
||||
|
||||
// AgentsSelected returns the cursor's agent (ok=false when none).
|
||||
func (l *Lanes) AgentsSelected() (AgentView, bool) {
|
||||
if l.agentsSel < 0 || l.agentsSel >= len(l.agents) {
|
||||
return AgentView{}, false
|
||||
}
|
||||
return l.agents[l.agentsSel], true
|
||||
}
|
||||
|
||||
// AgentsWindowLines renders the centered window's content rows at w:
|
||||
// the section heading, one row per in-flight agent (title + state),
|
||||
// and the SELECTED agent's detail lines beneath — the thought
|
||||
// process / recent activity the shell derived. The D52 picker window
|
||||
// pattern (centered, sections, walkable) is the precedent.
|
||||
func (l *Lanes) AgentsWindowLines(w int) []string {
|
||||
if !l.agentsOpen || len(l.agents) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
out = append(out, agentsSection+fmt.Sprintf(": %d", len(l.agents)))
|
||||
for i, a := range l.agents {
|
||||
cursor := " "
|
||||
if i == l.agentsSel {
|
||||
cursor = "> "
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s%s — %s", cursor, a.Title, a.State))
|
||||
}
|
||||
if sel, ok := l.AgentsSelected(); ok && len(sel.Detail) > 0 {
|
||||
out = append(out, "")
|
||||
out = append(out, "recent activity:")
|
||||
out = append(out, sel.Detail...)
|
||||
}
|
||||
// clip honestly at w (the window clips, the terminal never wraps)
|
||||
for i, ln := range out {
|
||||
if len([]rune(ln)) > w {
|
||||
out[i] = string([]rune(ln)[:w])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package lanes
|
||||
|
||||
// d53_agents_test.go — the SUB-AGENTS wave (Gitea #2568 item 7,
|
||||
// red-first): the activity pane's "sub agents being processed now"
|
||||
// section renders ONLY while agents are in flight; the section row is
|
||||
// clickable and opens the CENTERED agents window listing all
|
||||
// in-flight agents; the selected row is inspectable (its detail
|
||||
// lines — the shell's journal/observations derivation — render
|
||||
// beneath).
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func agentsTestLanes(t *testing.T) *Lanes {
|
||||
t.Helper()
|
||||
l := composerLanes(t)
|
||||
l.SetWaveAgents(2, []AgentView{
|
||||
{ID: "w1", Title: "land the scrollbar wave", State: "BUILD",
|
||||
Detail: []string{"state BUILD · running 00:04", "journal: admit green", "obs: tests red then green"}},
|
||||
{ID: "w2", Title: "polish the composer hints", State: "VERIFY (blocked_on nick)"},
|
||||
})
|
||||
return l
|
||||
}
|
||||
|
||||
func TestAgentsSectionOnlyInFlight(t *testing.T) {
|
||||
l := composerLanes(t)
|
||||
f := l.Render()
|
||||
if strings.Contains(f.Content, agentsSection) {
|
||||
t.Fatal("the section must not render with no agents in flight")
|
||||
}
|
||||
l = agentsTestLanes(t)
|
||||
f = l.Render()
|
||||
if !strings.Contains(f.Content, agentsSection) {
|
||||
t.Fatal("the section must render while agents are in flight")
|
||||
}
|
||||
if !strings.Contains(f.Content, "sub agents being processed now: 2") {
|
||||
t.Fatalf("the section must carry the count, got:\n%s", f.Content)
|
||||
}
|
||||
// landing the last wave removes the section (chrome restored)
|
||||
l.SetWaveAgents(0, nil)
|
||||
f = l.Render()
|
||||
if strings.Contains(f.Content, agentsSection) {
|
||||
t.Fatal("the section must disappear when the flight lands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentsSectionClickOpensCenteredWindow(t *testing.T) {
|
||||
l := agentsTestLanes(t)
|
||||
f := l.Render()
|
||||
var reg *struct{ X, Y, W, H int }
|
||||
for _, r := range f.Regions {
|
||||
if r.ID == "mouse.activity.agents" {
|
||||
rr := r
|
||||
reg = &struct{ X, Y, W, H int }{rr.X, rr.Y, rr.W, rr.H}
|
||||
}
|
||||
}
|
||||
if reg == nil {
|
||||
t.Fatal("the section row must carry the mouse.activity.agents click region")
|
||||
}
|
||||
a := l.Click(reg.X+1, reg.Y)
|
||||
if a != "mouse.activity.agents" {
|
||||
t.Fatalf("clicking the section must route the agents action, got %q", a)
|
||||
}
|
||||
if !l.AgentsWindowOpen() {
|
||||
t.Fatal("the click must open the agents window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentsWindowListsAndInspects(t *testing.T) {
|
||||
l := agentsTestLanes(t)
|
||||
l.ToggleAgentsWindow()
|
||||
lines := l.AgentsWindowLines(72)
|
||||
joined := strings.Join(lines, "\n")
|
||||
for _, want := range []string{"land the scrollbar wave", "BUILD", "polish the composer hints", "VERIFY"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("the window must list every in-flight agent, missing %q in:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
// the selected row's detail (thought process / recent activity)
|
||||
if !strings.Contains(joined, "journal: admit green") {
|
||||
t.Fatalf("the selected agent's detail must render in the window:\n%s", joined)
|
||||
}
|
||||
// walking selects the other agent
|
||||
l.AgentsSelMove(1)
|
||||
joined = strings.Join(l.AgentsWindowLines(72), "\n")
|
||||
if strings.Contains(joined, "journal: admit green") {
|
||||
t.Fatal("the detail must follow the cursor (only the selected row's detail)")
|
||||
}
|
||||
// esc closes
|
||||
l.CloseAgentsWindow()
|
||||
if l.AgentsWindowOpen() || l.AgentsWindowLines(72) != nil {
|
||||
t.Fatal("closing the window dismisses it and its lines")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentsWindowNeedsFlight(t *testing.T) {
|
||||
l := composerLanes(t)
|
||||
if l.ToggleAgentsWindow() {
|
||||
t.Fatal("no flight, no window: the toggle must refuse")
|
||||
}
|
||||
if l.AgentsWindowOpen() {
|
||||
t.Fatal("the window must not open without a flight")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package lanes
|
||||
|
||||
// d53_composerstyle_test.go — the COMPOSER STYLING wave (Gitea #2568
|
||||
// item 6, red-first): the enter-send / shift+enter key hints render
|
||||
// GHOSTED PINK (the themes RoleHintPink token — softer than the
|
||||
// speaker pink, a role of its own per the D54 colour law), and the
|
||||
// activity pane's idle state renders YELLOW (the RoleStateIdle token).
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/themes"
|
||||
)
|
||||
|
||||
func sgrBodyOf(t *testing.T, hex string) string {
|
||||
t.Helper()
|
||||
var r, g, b int
|
||||
if _, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b); err != nil {
|
||||
t.Fatalf("bad hex %q", hex)
|
||||
}
|
||||
return fmt.Sprintf("38;2;%d;%d;%d", r, g, b)
|
||||
}
|
||||
|
||||
func TestComposerHintRendersGhostedPink(t *testing.T) {
|
||||
l := composerLanes(t)
|
||||
f := l.Render()
|
||||
hint := l.Hint()
|
||||
if hint == "" {
|
||||
t.Fatal("the composer hint must be installed")
|
||||
}
|
||||
want := sgrBodyOf(t, themes.RoleHex(themes.RoleHintPink))
|
||||
// the hint line rides the input box's last row: find it and check
|
||||
// its tone is the hint-pink token, not the dim ghost tone.
|
||||
found := false
|
||||
for _, ln := range strings.Split(f.Content, "\n") {
|
||||
if strings.Contains(ln, hint) {
|
||||
found = true
|
||||
if !strings.Contains(ln, want) {
|
||||
t.Fatalf("the key hint must render in the RoleHintPink token (%s), row %q", want, ln)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the hint %q must render in the frame", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityIdleRendersYellow(t *testing.T) {
|
||||
l := composerLanes(t)
|
||||
f := l.Render()
|
||||
want := sgrBodyOf(t, themes.RoleHex(themes.RoleStateIdle))
|
||||
if !strings.Contains(f.Content, want) {
|
||||
t.Fatalf("the activity pane's idle state must render in the RoleStateIdle yellow token (%s)", want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package lanes
|
||||
|
||||
// d53_scrollbar_test.go — the SCROLLBAR wave (Gitea #2568 item 2,
|
||||
// red-first): the fade layer existed but no bar visibly rendered (the
|
||||
// owner: "there is no scroll bar but you can scroll history"). The
|
||||
// diagnosis: the overlay column splice dropped the thumb glyph on
|
||||
// short styled rows — the common transcript shape. These tests freeze
|
||||
// the repaired behaviour: the thumb VISIBLY renders inside the frame
|
||||
// at the pane's right content column, coloured with the RoleScroll
|
||||
// token (the D54 colour law — a role keeps colour-as-meaning intact),
|
||||
// only while scrolling, fading at rest, for BOTH panes; and the
|
||||
// wheel's step math tracks the wheel precisely (one notch = one fixed
|
||||
// small step, no jumpy multi-line hops).
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/chatpane"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
"rune.dev/rune/tui/themes"
|
||||
)
|
||||
|
||||
func longTranscriptLanes(t *testing.T) *Lanes {
|
||||
t.Helper()
|
||||
l := composerLanes(t)
|
||||
for i := 0; i < 40; i++ {
|
||||
l.AppendChat(chatMessage(fmt.Sprintf("a%d", i), "assistant",
|
||||
"body line of transcript prose "+fmt.Sprint(i)))
|
||||
}
|
||||
l.Render()
|
||||
return l
|
||||
}
|
||||
|
||||
func TestChatScrollbarThumbRendersInFrameWhileScrolling(t *testing.T) {
|
||||
l := longTranscriptLanes(t)
|
||||
if !l.ScrollChatUp(5) {
|
||||
t.Fatal("scroll must move")
|
||||
}
|
||||
l.chat.TouchScroll()
|
||||
f := l.Render()
|
||||
lines := strings.Split(f.Content, "\n")
|
||||
seen := 0
|
||||
for _, ln := range lines {
|
||||
if strings.Contains(ln, "▐") {
|
||||
seen++
|
||||
}
|
||||
}
|
||||
if seen == 0 {
|
||||
t.Fatal("the scrollbar thumb must visibly render inside the frame while scrolling")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatScrollbarFadesAtRest(t *testing.T) {
|
||||
l := longTranscriptLanes(t)
|
||||
l.ScrollChatUp(5)
|
||||
l.chat.TouchScroll()
|
||||
for i := 0; i < chatpane.FadeTicks; i++ {
|
||||
l.chat.TickScroll()
|
||||
}
|
||||
f := l.Render()
|
||||
if strings.Contains(f.Content, "▐") {
|
||||
t.Fatal("the settled scrollbar must fade to nothing (visible ONLY while actively scrolling)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatScrollbarColourIsTheScrollRole(t *testing.T) {
|
||||
l := longTranscriptLanes(t)
|
||||
l.ScrollChatUp(5)
|
||||
l.chat.TouchScroll()
|
||||
rows := l.chat.ScrollbarRows()
|
||||
joined := strings.Join(rows, "")
|
||||
// lipgloss renders the hex as its true-colour SGR body; the token
|
||||
// law is about the RESOLVED colour, so assert the SGR body.
|
||||
var r, g, b int
|
||||
fmt.Sscanf(themes.RoleHex(themes.RoleScroll), "#%02x%02x%02x", &r, &g, &b)
|
||||
want := fmt.Sprintf("38;2;%d;%d;%d", r, g, b)
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("the thumb must carry the RoleScroll token colour (%s), got %q",
|
||||
themes.RoleHex(themes.RoleScroll), joined[:min(40, len(joined))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelStepTracksTheWheelPrecisely(t *testing.T) {
|
||||
l := longTranscriptLanes(t)
|
||||
l.ScrollChatUp(3)
|
||||
if got := l.chat.ScrollOffset(); got != 3 {
|
||||
t.Fatalf("one wheel notch must move exactly its 3-line step, moved %d", got)
|
||||
}
|
||||
l.ScrollChatUp(3)
|
||||
if got := l.chat.ScrollOffset(); got != 6 {
|
||||
t.Fatalf("notches accumulate exactly: offset %d, want 6", got)
|
||||
}
|
||||
l.ScrollChatDown(6)
|
||||
if got := l.chat.ScrollOffset(); got != 0 {
|
||||
t.Fatalf("down notches return exactly to the tail, offset %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksScrollbarThumbRendersInFrameWhileScrolling(t *testing.T) {
|
||||
l := composerLanes(t)
|
||||
for i := 0; i < 30; i++ {
|
||||
l.st.Tasks = append(l.st.Tasks, semantic.Task{ID: fmt.Sprintf("t%d", i), Label: "task"})
|
||||
}
|
||||
l.Render()
|
||||
l.TouchTaskScroll()
|
||||
if !l.TaskScrollbarVisible() {
|
||||
t.Fatal("the overflowing task list must arm the scrollbar layer")
|
||||
}
|
||||
l.TouchTaskScroll()
|
||||
f := l.Render()
|
||||
if !strings.Contains(f.Content, "▐") {
|
||||
t.Fatal("the tasks pane's scrollbar thumb must visibly render while scrolling")
|
||||
}
|
||||
}
|
||||
|
||||
// the layer law regression: the thumb overlay never shifts content.
|
||||
func TestScrollbarOverlayKeepsContentColumns(t *testing.T) {
|
||||
got := overlayDisplayCol("\x1b[38;2;138;138;158mhello\x1b[m", 10, "▐")
|
||||
if !strings.Contains(got, "▐") {
|
||||
t.Fatalf("a short styled row must still carry the thumb glyph, got %q", got)
|
||||
}
|
||||
if n := len([]rune(semantic.StripANSI(got))); n != 11 {
|
||||
t.Fatalf("the spliced line must be text+glyph columns (11), got %d", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package lanes
|
||||
|
||||
import "testing"
|
||||
|
||||
// D54 item 1 (Gitea #2571): MOTION LAW — the wave render path is
|
||||
// gated behind one boolean, motionAllowed. When the gate is closed
|
||||
// the activity strip NEVER changes between frames: no wave, no
|
||||
// rainbow advance, no walking runes. Idle already rested (A5); the
|
||||
// gate extends that rest to active states.
|
||||
|
||||
func TestMotionGateFreezesActiveStrip(t *testing.T) {
|
||||
a := newActivityPane()
|
||||
a.SetMotionAllowed(false)
|
||||
a.SetSignal(ActivitySignal{TurnActive: true})
|
||||
a.SetFrame(0)
|
||||
r0 := a.Render(60, true)
|
||||
a.SetFrame(37)
|
||||
r37 := a.Render(60, true)
|
||||
if r0 != r37 {
|
||||
t.Fatalf("motion-gated strip must be frame-frozen (never-animate law):\nframe0: %q\nframe37: %q", r0, r37)
|
||||
}
|
||||
// the plain path too
|
||||
p0 := a.Render(60, false)
|
||||
a.SetFrame(41)
|
||||
p41 := a.Render(60, false)
|
||||
if p0 != p41 {
|
||||
t.Fatalf("motion-gated plain strip must be frame-frozen: %q vs %q", p0, p41)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionGateAllowsWaveWhenOpen(t *testing.T) {
|
||||
a := newActivityPane()
|
||||
a.SetMotionAllowed(true)
|
||||
a.SetSignal(ActivitySignal{TurnActive: true})
|
||||
a.SetFrame(0)
|
||||
r0 := a.Render(60, true)
|
||||
a.SetFrame(a.waveCycleLen(8))
|
||||
rC := a.Render(60, true)
|
||||
if r0 == rC {
|
||||
t.Fatalf("with the gate open the wave must still move (owner's vivid default)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMotionGateDefaultOpen(t *testing.T) {
|
||||
a := newActivityPane()
|
||||
if !a.MotionAllowed() {
|
||||
t.Fatalf("a fresh pane must allow motion (default vivid)")
|
||||
}
|
||||
}
|
||||
+89
-8
@@ -21,8 +21,11 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"rune.dev/rune/tui/chatpane"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
"rune.dev/rune/tui/themes"
|
||||
)
|
||||
|
||||
// Lanes couples one semantic State to the two-lane projection. It
|
||||
@@ -44,6 +47,12 @@ type Lanes struct {
|
||||
composer []string
|
||||
placeholder string
|
||||
hint string
|
||||
// the D53 sub-agents seam (d53_agents.go): the flight observation
|
||||
// the shell's syncWaves installs — display state, never semantic.
|
||||
agents []AgentView
|
||||
agentsCount int
|
||||
agentsOpen bool
|
||||
agentsSel int
|
||||
// menu is the shell-injected model-picker menu (view-only lines
|
||||
// rendered directly ABOVE the input box — never a transcript
|
||||
// message; the owner's 2026-10-18 ruling: model selection is a
|
||||
@@ -88,6 +97,15 @@ func (l *Lanes) SetActivity(sig ActivitySignal, frame int) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetActivityMotionAllowed opens or closes the activity pane's MOTION
|
||||
// LAW gate (D54 #2571 item 1): closed means the strip is frame-frozen.
|
||||
func (l *Lanes) SetActivityMotionAllowed(on bool) {
|
||||
if l.activity == nil {
|
||||
l.activity = newActivityPane()
|
||||
}
|
||||
l.activity.SetMotionAllowed(on)
|
||||
}
|
||||
|
||||
// SetActivityTurn installs the current turn's processing record (the +
|
||||
// window's content source).
|
||||
func (l *Lanes) SetActivityTurn(t ActivityTurn) {
|
||||
@@ -153,6 +171,13 @@ func New(w, h int) *Lanes {
|
||||
return &Lanes{st: semantic.Seed(w, h), activity: newActivityPane()}
|
||||
}
|
||||
|
||||
// NewEmpty returns the two-lane face on the honest PRODUCTION boot
|
||||
// state (D52 seed-substrate law, #2566): no lab tasks, no seeded chat
|
||||
// line — the live data fills it.
|
||||
func NewEmpty(w, h int) *Lanes {
|
||||
return &Lanes{st: semantic.Empty(w, h), activity: newActivityPane()}
|
||||
}
|
||||
|
||||
// NewFromState returns the two-lane face over an existing state (the
|
||||
// caller's copy — clone first when sharing).
|
||||
func NewFromState(s semantic.State) *Lanes {
|
||||
@@ -182,6 +207,24 @@ func (l *Lanes) AppendChat(ms ...chatpane.Message) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// StreamChat installs or extends the progressive-render assistant
|
||||
// bubble with the running content (Gitea #2556 — the answer renders
|
||||
// as its bytes arrive). Reports whether the transcript changed.
|
||||
func (l *Lanes) StreamChat(content string) bool {
|
||||
if l.chat == nil {
|
||||
l.chat = chatpane.New()
|
||||
}
|
||||
return l.chat.UpsertStream(content)
|
||||
}
|
||||
|
||||
// DropStreamChat retires the streaming bubble (a failed dispatch).
|
||||
func (l *Lanes) DropStreamChat() bool {
|
||||
if l.chat == nil {
|
||||
return false
|
||||
}
|
||||
return l.chat.DropStream()
|
||||
}
|
||||
|
||||
// ChatSnapshot returns the transcript (oldest first).
|
||||
func (l *Lanes) ChatSnapshot() []chatpane.Message {
|
||||
if l.chat == nil {
|
||||
@@ -237,6 +280,9 @@ func (l *Lanes) SetComposer(lines []string, placeholder, hint string) {
|
||||
l.hint = hint
|
||||
}
|
||||
|
||||
// Hint returns the installed composer hint (the D53 read-back seam).
|
||||
func (l *Lanes) Hint() string { return l.hint }
|
||||
|
||||
// SetMenu installs the model-picker menu lines (view-only, rendered
|
||||
// above the composer, never appended to the transcript). Empty closes
|
||||
// the menu.
|
||||
@@ -245,6 +291,12 @@ func (l *Lanes) SetMenu(lines []string) {
|
||||
l.card = false
|
||||
}
|
||||
|
||||
// MenuSnapshot returns the injected menu lines (test seam — the D52
|
||||
// picker-window law reads the rendered chooser through it).
|
||||
func (l *Lanes) MenuSnapshot() []string {
|
||||
return append([]string(nil), l.menu...)
|
||||
}
|
||||
|
||||
// SetApprovalCard installs the T5 approval card into the overlay band
|
||||
// (view-only lines above the composer — the pending approval's face,
|
||||
// rendered by tui/approvals). Empty closes the card.
|
||||
@@ -293,9 +345,28 @@ func (l *Lanes) TaskScrollbarVisible() bool {
|
||||
// of h rows: a right-edge thumb proportional to the selection position
|
||||
// over the task count. "" when not visible (transparent at rest).
|
||||
func (l *Lanes) TaskScrollbarRow(h int) string {
|
||||
if !l.TaskScrollbarVisible() || len(l.st.Tasks) == 0 {
|
||||
rows := l.TaskScrollbarRows(h)
|
||||
if rows == nil {
|
||||
return ""
|
||||
}
|
||||
out := ""
|
||||
for _, r := range rows {
|
||||
if r == "" {
|
||||
out += " "
|
||||
continue
|
||||
}
|
||||
out += "▐"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TaskScrollbarRows renders the tasks pane's scrollbar layer as ONE
|
||||
// string per pane row (the D53 #2568 repair): the thumb's rows carry
|
||||
// the RoleScroll token colour, every other row is "" (no overlay).
|
||||
func (l *Lanes) TaskScrollbarRows(h int) []string {
|
||||
if !l.TaskScrollbarVisible() || len(l.st.Tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
n := len(l.st.Tasks)
|
||||
rows := max(h-4, 1)
|
||||
thumb := max(rows*rows/n, 1)
|
||||
@@ -303,16 +374,16 @@ func (l *Lanes) TaskScrollbarRow(h int) string {
|
||||
if n > 1 {
|
||||
pos = (rows - thumb) * l.st.Sel / (n - 1)
|
||||
}
|
||||
row := make([]rune, rows)
|
||||
for i := range row {
|
||||
row[i] = ' '
|
||||
out := make([]string, rows)
|
||||
for i := pos; i < pos+thumb && i < len(out); i++ {
|
||||
out[i] = taskScrollThumbStyle.Render("▐")
|
||||
}
|
||||
for i := pos; i < pos+thumb && i < len(row); i++ {
|
||||
row[i] = '▐'
|
||||
}
|
||||
return string(row)
|
||||
return out
|
||||
}
|
||||
|
||||
// taskScrollThumbStyle is the tasks thumb's token colour: RoleScroll.
|
||||
var taskScrollThumbStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleScroll)))
|
||||
|
||||
// SetComposerCursor sets the D43.14 blinking block cursor's phase on
|
||||
// the empty composer's ghost row (the shell's tick drives the blink;
|
||||
// reduced motion rests the block lit).
|
||||
@@ -399,6 +470,14 @@ func (l *Lanes) Click(x, y int) semantic.ActionID {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// the sub-agents section (D53 #2568 item 7): clicking it opens
|
||||
// the centered agents window (the D52 picker pattern).
|
||||
if r.ID == "mouse.activity.agents" {
|
||||
if l.ToggleAgentsWindow() {
|
||||
return semantic.ActionID(r.ID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
for _, r := range fr.Regions {
|
||||
if r.ID == "region.chat" && inRegion(r, x, y) {
|
||||
@@ -536,6 +615,8 @@ func (l *Lanes) Clone() semantic.Face {
|
||||
n.card = l.card
|
||||
n.hints = append([]string(nil), l.hints...)
|
||||
n.cursorOn = l.cursorOn
|
||||
n.agents = append([]AgentView(nil), l.agents...)
|
||||
n.agentsCount, n.agentsOpen, n.agentsSel = l.agentsCount, l.agentsOpen, l.agentsSel
|
||||
if l.activity != nil {
|
||||
n.activity = &ActivityPane{
|
||||
state: l.activity.state, frame: l.activity.frame, seed: l.activity.seed,
|
||||
|
||||
+67
-14
@@ -9,6 +9,7 @@ import (
|
||||
"rune.dev/rune/tui/approvals"
|
||||
"rune.dev/rune/tui/chatpane"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
"rune.dev/rune/tui/themes"
|
||||
)
|
||||
|
||||
// Render projects the semantic State through the two-lane layout (D36:
|
||||
@@ -88,8 +89,18 @@ func (l *Lanes) Render() semantic.Frame {
|
||||
// the strip's right edge — the window's only click road.
|
||||
if len(activity) > 0 {
|
||||
regions = append(regions, semantic.Region{
|
||||
ID: "mouse.activity.plus", X: s.W - activityPlusWidth(), Y: activityY,
|
||||
W: activityPlusWidth(), H: 1, Marker: "+",
|
||||
ID: "mouse.activity.plus", X: s.W - activityPlusWidth, Y: activityY,
|
||||
W: activityPlusWidth, H: 1, Marker: "+",
|
||||
})
|
||||
}
|
||||
// the sub-agents section's click region (D53 #2568 item 7): the
|
||||
// section row below the strip's activity row, ONLY in flight —
|
||||
// clicking opens the centered agents window (the D52 picker
|
||||
// pattern); keyboard parity rides the shell's 'w' key.
|
||||
if l.WaveAgentsCount() > 0 && len(activity) > 1 {
|
||||
regions = append(regions, semantic.Region{
|
||||
ID: "mouse.activity.agents", X: 0, Y: activityY + 1,
|
||||
W: s.W, H: 1, Marker: "agents",
|
||||
})
|
||||
}
|
||||
// thinking-bubble control regions (D35 + D43.7: the marker, the
|
||||
@@ -239,25 +250,28 @@ func buildComposerLayers(cursorOn bool, placeholder, hint string, inner int) com
|
||||
ghost = ghostStyle.Render(" " + placeholder)
|
||||
}
|
||||
c.Rows = append(c.Rows,
|
||||
composerRow{Left: "│", Core: "", Right: "│"}, // the pad row above
|
||||
composerRow{Left: "│", Core: ghost, Right: "│"}, // the ghost row (cursor cell + ghost)
|
||||
composerRow{Left: "│", Core: "", Right: "│"}, // the pad row below
|
||||
composerRow{Left: "│", Core: " " + hint, Right: "│"}, // the key hint rides the last row
|
||||
composerRow{Left: "│", Core: "", Right: "│"}, // the pad row above
|
||||
composerRow{Left: "│", Core: ghost, Right: "│"}, // the ghost row (cursor cell + ghost)
|
||||
composerRow{Left: "│", Core: "", Right: "│"}, // the pad row below
|
||||
// the key hint rides the last row, GHOSTED PINK (D53 #2568
|
||||
// item 6): the RoleHintPink token — softer than the speaker
|
||||
// pink, a role of its own per the D54 colour law.
|
||||
composerRow{Left: "│", Core: " " + composerHintStyle.Render(hint), Right: "│"},
|
||||
)
|
||||
return c
|
||||
}
|
||||
|
||||
// chatScrollbarOverlay renders the chat pane's scrollbar layer rows for
|
||||
// a pane body of h rows and w columns ("" rows when the layer rests).
|
||||
// a pane body of h rows ("" rows when the layer rests). The rows come
|
||||
// pre-styled from the pane's ScrollbarRows (the RoleScroll token).
|
||||
func (l *Lanes) chatScrollbarOverlay(h, w int) []string {
|
||||
if l.chat == nil {
|
||||
return nil
|
||||
}
|
||||
full := l.chat.ScrollbarRow(w)
|
||||
if full == "" {
|
||||
rows := l.chat.ScrollbarRows()
|
||||
if rows == nil {
|
||||
return nil
|
||||
}
|
||||
rows := strings.Split(full, "")
|
||||
return rows[:min(len(rows), max(h-2, 0))]
|
||||
}
|
||||
|
||||
@@ -291,10 +305,16 @@ func overlayRight(content []string, rows []string, x int) []string {
|
||||
|
||||
// overlayDisplayCol replaces one display column of a (possibly styled)
|
||||
// line: split at the column on display coordinates, keep every ANSI
|
||||
// run intact on both sides, splice the glyph between them.
|
||||
// run intact on both sides, splice the glyph between them. A line
|
||||
// SHORTER than x still carries the glyph at column x (the D53
|
||||
// scrollbar diagnosis: the old splicer wrote the glyph into the left
|
||||
// half only when a content rune sat at column x — a short styled row,
|
||||
// the common transcript shape, lost the thumb entirely and the bar
|
||||
// never visibly rendered).
|
||||
func overlayDisplayCol(line string, x int, glyph string) string {
|
||||
var left, right strings.Builder
|
||||
col := 0
|
||||
wrote := false
|
||||
inEsc := false
|
||||
var esc strings.Builder
|
||||
for _, r := range line {
|
||||
@@ -321,7 +341,12 @@ func overlayDisplayCol(line string, x int, glyph string) string {
|
||||
if col < x {
|
||||
left.WriteRune(r)
|
||||
} else if col == x {
|
||||
if esc.Len() > 0 {
|
||||
left.WriteString(esc.String())
|
||||
esc.Reset()
|
||||
}
|
||||
left.WriteString(glyph)
|
||||
wrote = true
|
||||
} else {
|
||||
right.WriteRune(r)
|
||||
}
|
||||
@@ -330,6 +355,13 @@ func overlayDisplayCol(line string, x int, glyph string) string {
|
||||
if inEsc {
|
||||
left.WriteString(esc.String())
|
||||
}
|
||||
if !wrote {
|
||||
// the line ended before column x: pad to the column, then the
|
||||
// glyph (drawPane pads the tail — the glyph lands at x exactly)
|
||||
pad := max(x-col, 0)
|
||||
left.WriteString(strings.Repeat(" ", pad))
|
||||
left.WriteString(glyph)
|
||||
}
|
||||
return left.String() + right.String()
|
||||
}
|
||||
|
||||
@@ -483,6 +515,11 @@ func stripANSI(s string) string {
|
||||
func taskContent(s *semantic.State, w int) []string {
|
||||
inner := max(w-2, 0)
|
||||
lines := []string{clip(fmt.Sprintf("TASKS %s", mark(s.Focus == semantic.FocusTasks)), inner)}
|
||||
if len(s.Tasks) == 0 {
|
||||
// the D51 blank-unless-goals ruling, D52 law (#2566): an empty
|
||||
// substrate renders the honest idle state — never lab rows.
|
||||
lines = append(lines, clip("no outstanding work", inner))
|
||||
}
|
||||
for i, t := range s.Tasks {
|
||||
sel := " "
|
||||
if s.Focus == semantic.FocusTasks && i == s.Sel {
|
||||
@@ -572,6 +609,10 @@ var (
|
||||
// ghostStyle is the D43.14 ghosted placeholder tone (dim on the
|
||||
// base, ≥4.5:1 — the same dim family as the transcript stamps).
|
||||
ghostStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#78788A"))
|
||||
// composerHintStyle is the D53 ghosted-pink key-hint tone: the
|
||||
// RoleHintPink token (one hex per role, themes/roles.go — softer
|
||||
// than the speaker pink, a role of its own).
|
||||
composerHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(themes.RoleHex(themes.RoleHintPink)))
|
||||
// cursorStyle is the D43.14 blinking block cursor's tone: the lit
|
||||
// pink, marking chat-here.
|
||||
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6"))
|
||||
@@ -590,11 +631,23 @@ func (l *Lanes) activityStrip(w int) []string {
|
||||
return nil
|
||||
}
|
||||
row := l.activity.Render(w, true)
|
||||
return []string{padRow(row, w)}
|
||||
rows := []string{padRow(row, w)}
|
||||
// THE SUB-AGENTS SECTION (D53 #2568 item 7): rendered ONLY while
|
||||
// agents are in flight — the flight observation the shell's
|
||||
// syncWaves installs. Clickable: its region is
|
||||
// mouse.activity.agents (the section row), opening the centered
|
||||
// agents window (keyboard parity: the shell's 'w' key).
|
||||
if n := l.WaveAgentsCount(); n > 0 {
|
||||
text := fmt.Sprintf("%s: %d", agentsSection, n)
|
||||
line := activitySectionStyle.Render(text)
|
||||
rows = append(rows, padRow(line, w))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// activityPlusWidth is the + affordance's click-region columns.
|
||||
func activityPlusWidth() int { return 3 }
|
||||
// activityPlusWidth is the + affordance's click-region columns — the
|
||||
// single declaration lives in activity.go (the D52 law: the region
|
||||
// and the render share one constant).
|
||||
|
||||
// clip clips s to w runes.
|
||||
func clip(s string, w int) string {
|
||||
|
||||
@@ -202,6 +202,10 @@ func (in *Input3) browseDown() {
|
||||
// exitBrowsing leaves browsing discarding the stash (any editing key).
|
||||
func (in *Input3) exitBrowsing() { in.browsing = false }
|
||||
|
||||
// Clear empties the input (the submenu-execution leg's reset — the
|
||||
// '/' road runs the highlighted command and clears the draft).
|
||||
func (in *Input3) Clear() { in.setText("") }
|
||||
|
||||
// setText replaces the whole input with s and parks the cursor at the
|
||||
// end (how a history entry or stash is shown).
|
||||
func (in *Input3) setText(s string) {
|
||||
|
||||
+56
-6
@@ -226,15 +226,35 @@ func New(w, h int) *Promote { return newPromote(semantic.ModeRich, w, h) }
|
||||
func NewLinear(w, h int) *Promote { return newPromote(semantic.ModeLinear, w, h) }
|
||||
|
||||
func newPromote(mode string, w, h int) *Promote {
|
||||
inner := rail.New(w, h)
|
||||
if mode == semantic.ModeLinear {
|
||||
return newPromoteSeed(mode, w, h, true)
|
||||
}
|
||||
|
||||
// NewEmpty returns the rich-projection promote face on the honest
|
||||
// PRODUCTION boot (D52 seed-substrate law, #2566): no seed
|
||||
// suggestions — Offer fills the list when real suggestions arrive.
|
||||
func NewEmpty(w, h int) *Promote { return newPromoteSeed(semantic.ModeRich, w, h, false) }
|
||||
|
||||
// NewLinearEmpty returns the linear-projection promote face on the
|
||||
// honest PRODUCTION boot (zero-ANSI law, D52 seed-substrate law).
|
||||
func NewLinearEmpty(w, h int) *Promote { return newPromoteSeed(semantic.ModeLinear, w, h, false) }
|
||||
|
||||
func newPromoteSeed(mode string, w, h int, seeded bool) *Promote {
|
||||
var inner *rail.Rail
|
||||
switch {
|
||||
case seeded && mode == semantic.ModeLinear:
|
||||
inner = rail.NewLinear(w, h)
|
||||
case seeded:
|
||||
inner = rail.New(w, h)
|
||||
case mode == semantic.ModeLinear:
|
||||
inner = rail.NewLinearEmpty(w, h)
|
||||
default:
|
||||
inner = rail.NewEmpty(w, h)
|
||||
}
|
||||
return &Promote{
|
||||
inner: inner,
|
||||
mode: mode,
|
||||
suggestions: Rank(SeedSuggestions),
|
||||
p := &Promote{inner: inner, mode: mode}
|
||||
if seeded {
|
||||
p.suggestions = Rank(SeedSuggestions)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Open reports whether the suggestion region is showing.
|
||||
@@ -466,6 +486,9 @@ func (p *Promote) SetComposer(lines []string, placeholder, hint string) {
|
||||
p.inner.SetComposer(lines, placeholder, hint)
|
||||
}
|
||||
|
||||
// Hint returns the installed composer hint (the D53 read-back seam).
|
||||
func (p *Promote) Hint() string { return p.inner.Hint() }
|
||||
|
||||
// SetComposerCursor sets the D43.14 blinking block cursor's phase on
|
||||
// the empty composer's ghost row (the shell's tick drives the blink).
|
||||
func (p *Promote) SetComposerCursor(on bool) {
|
||||
@@ -487,6 +510,10 @@ func (p *Promote) SetActivity(sig lanes.ActivitySignal, frame int) {
|
||||
p.inner.SetActivity(sig, frame)
|
||||
}
|
||||
|
||||
// SetActivityMotionAllowed passes the MOTION LAW gate down the face
|
||||
// chain (D54 #2571 item 1).
|
||||
func (p *Promote) SetActivityMotionAllowed(on bool) { p.inner.SetActivityMotionAllowed(on) }
|
||||
|
||||
// SetActivityTurn installs the current turn's processing record (the +
|
||||
// window's content source).
|
||||
func (p *Promote) SetActivityTurn(t lanes.ActivityTurn) { p.inner.SetActivityTurn(t) }
|
||||
@@ -511,6 +538,13 @@ func (p *Promote) AmendChatThinking(content string) bool {
|
||||
return p.inner.AmendChatThinking(content)
|
||||
}
|
||||
|
||||
// StreamChat passes the progressive-render bubble through (Gitea
|
||||
// #2556 — the streamed turn renders as its bytes arrive).
|
||||
func (p *Promote) StreamChat(content string) bool { return p.inner.StreamChat(content) }
|
||||
|
||||
// DropStreamChat retires the streaming bubble (a failed dispatch).
|
||||
func (p *Promote) DropStreamChat() bool { return p.inner.DropStreamChat() }
|
||||
|
||||
// DropChatThinking removes the trailing pending thinking bubble.
|
||||
func (p *Promote) DropChatThinking() bool { return p.inner.DropChatThinking() }
|
||||
|
||||
@@ -518,6 +552,10 @@ func (p *Promote) DropChatThinking() bool { return p.inner.DropChatThinking() }
|
||||
// (view-only, above the composer — never transcript content).
|
||||
func (p *Promote) SetMenu(lines []string) { p.inner.SetMenu(lines) }
|
||||
|
||||
// MenuSnapshot returns the shell-injected model-picker menu lines
|
||||
// (test seam, the D52 picker-window law).
|
||||
func (p *Promote) MenuSnapshot() []string { return p.inner.MenuSnapshot() }
|
||||
|
||||
// SetApprovalCard installs the T5 approval card lines in the chat
|
||||
// lane's overlay band (view-only; empty closes it).
|
||||
func (p *Promote) SetApprovalCard(lines []string) { p.inner.SetApprovalCard(lines) }
|
||||
@@ -657,3 +695,15 @@ func (p *Promote) SetStill(still bool) { p.inner.SetStill(still) }
|
||||
|
||||
// ChatScrollOffset reports the chat pane's scroll offset.
|
||||
func (p *Promote) ChatScrollOffset() int { return p.inner.ChatScrollOffset() }
|
||||
|
||||
// the D53 sub-agents seam (Gitea #2568 item 7): display-state
|
||||
// passthroughs to the lanes' agents section + centered window.
|
||||
func (p *Promote) SetWaveAgents(count int, rows []lanes.AgentView) {
|
||||
p.inner.SetWaveAgents(count, rows)
|
||||
}
|
||||
func (p *Promote) WaveAgentsCount() int { return p.inner.WaveAgentsCount() }
|
||||
func (p *Promote) ToggleAgentsWindow() bool { return p.inner.ToggleAgentsWindow() }
|
||||
func (p *Promote) CloseAgentsWindow() { p.inner.CloseAgentsWindow() }
|
||||
func (p *Promote) AgentsWindowOpen() bool { return p.inner.AgentsWindowOpen() }
|
||||
func (p *Promote) AgentsSelMove(d int) { p.inner.AgentsSelMove(d) }
|
||||
func (p *Promote) AgentsWindowLines(w int) []string { return p.inner.AgentsWindowLines(w) }
|
||||
@@ -101,6 +101,18 @@ func newRail(mode string, w, h int) *Rail {
|
||||
return &Rail{lanes: lanes.New(w, h), mode: mode}
|
||||
}
|
||||
|
||||
// NewEmpty returns the rail over the honest PRODUCTION boot state
|
||||
// (D52 seed-substrate law, #2566): no lab fixtures.
|
||||
func NewEmpty(w, h int) *Rail { return newRailEmpty(semantic.ModeRich, w, h) }
|
||||
|
||||
// NewLinearEmpty returns the linear-projection rail over the honest
|
||||
// PRODUCTION boot state (zero-ANSI law, D52 seed-substrate law).
|
||||
func NewLinearEmpty(w, h int) *Rail { return newRailEmpty(semantic.ModeLinear, w, h) }
|
||||
|
||||
func newRailEmpty(mode string, w, h int) *Rail {
|
||||
return &Rail{lanes: lanes.NewEmpty(w, h), mode: mode}
|
||||
}
|
||||
|
||||
// Open reports whether the takeover box is showing.
|
||||
func (r *Rail) Open() bool { return r.open }
|
||||
|
||||
@@ -223,6 +235,9 @@ func (r *Rail) SetComposer(lines []string, placeholder, hint string) {
|
||||
r.lanes.SetComposer(lines, placeholder, hint)
|
||||
}
|
||||
|
||||
// Hint returns the installed composer hint (the D53 read-back seam).
|
||||
func (r *Rail) Hint() string { return r.lanes.Hint() }
|
||||
|
||||
// SetComposerCursor sets the D43.14 blinking block cursor's phase on
|
||||
// the empty composer's ghost row (the shell's tick drives the blink).
|
||||
func (r *Rail) SetComposerCursor(on bool) {
|
||||
@@ -251,6 +266,10 @@ func (r *Rail) SetActivity(sig lanes.ActivitySignal, frame int) {
|
||||
r.lanes.SetActivity(sig, frame)
|
||||
}
|
||||
|
||||
// SetActivityMotionAllowed passes the MOTION LAW gate down to the
|
||||
// activity pane (D54 #2571 item 1).
|
||||
func (r *Rail) SetActivityMotionAllowed(on bool) { r.lanes.SetActivityMotionAllowed(on) }
|
||||
|
||||
// SetActivityTurn installs the current turn's processing record (the +
|
||||
// window's content source).
|
||||
func (r *Rail) SetActivityTurn(t lanes.ActivityTurn) { r.lanes.SetActivityTurn(t) }
|
||||
@@ -278,6 +297,13 @@ func (r *Rail) AmendChatThinking(content string) bool {
|
||||
return r.lanes.AmendChatThinking(content)
|
||||
}
|
||||
|
||||
// StreamChat passes the progressive-render bubble through (Gitea
|
||||
// #2556 — the streamed turn renders as its bytes arrive).
|
||||
func (r *Rail) StreamChat(content string) bool { return r.lanes.StreamChat(content) }
|
||||
|
||||
// DropStreamChat retires the streaming bubble (a failed dispatch).
|
||||
func (r *Rail) DropStreamChat() bool { return r.lanes.DropStreamChat() }
|
||||
|
||||
// DropChatThinking removes the trailing pending thinking bubble.
|
||||
func (r *Rail) DropChatThinking() bool { return r.lanes.DropChatThinking() }
|
||||
|
||||
@@ -285,6 +311,10 @@ func (r *Rail) DropChatThinking() bool { return r.lanes.DropChatThinking() }
|
||||
// (view-only, above the composer).
|
||||
func (r *Rail) SetMenu(lines []string) { r.lanes.SetMenu(lines) }
|
||||
|
||||
// MenuSnapshot returns the shell-injected model-picker menu lines
|
||||
// (test seam, the D52 picker-window law).
|
||||
func (r *Rail) MenuSnapshot() []string { return r.lanes.MenuSnapshot() }
|
||||
|
||||
// SetApprovalCard installs the T5 approval card lines in the chat
|
||||
// lane's overlay band (view-only; empty closes it).
|
||||
func (r *Rail) SetApprovalCard(lines []string) { r.lanes.SetApprovalCard(lines) }
|
||||
@@ -398,3 +428,13 @@ func (r *Rail) SetChatStyle(s chatpane.Style) { r.lanes.SetChatStyle(s) }
|
||||
|
||||
// ChatScrollOffset reports the chat pane's scroll offset.
|
||||
func (r *Rail) ChatScrollOffset() int { return r.lanes.ChatScrollOffset() }
|
||||
|
||||
// the D53 sub-agents seam (Gitea #2568 item 7): display-state
|
||||
// passthroughs to the lanes' agents section + centered window.
|
||||
func (r *Rail) SetWaveAgents(count int, rows []lanes.AgentView) { r.lanes.SetWaveAgents(count, rows) }
|
||||
func (r *Rail) WaveAgentsCount() int { return r.lanes.WaveAgentsCount() }
|
||||
func (r *Rail) ToggleAgentsWindow() bool { return r.lanes.ToggleAgentsWindow() }
|
||||
func (r *Rail) CloseAgentsWindow() { r.lanes.CloseAgentsWindow() }
|
||||
func (r *Rail) AgentsWindowOpen() bool { return r.lanes.AgentsWindowOpen() }
|
||||
func (r *Rail) AgentsSelMove(d int) { r.lanes.AgentsSelMove(d) }
|
||||
func (r *Rail) AgentsWindowLines(w int) []string { return r.lanes.AgentsWindowLines(w) }
|
||||
@@ -11,6 +11,7 @@ package lib
|
||||
// core's envelope law accepts rune metadata beside the OpenAI fields).
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -144,6 +146,72 @@ func (r *Repo) Complete(ctx context.Context, req Completion) (CompletionResult,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompleteStream dispatches one model call with stream=true and
|
||||
// surfaces the answer's text AS IT ARRIVES through onDelta (Gitea
|
||||
// #2556 — the turn never sits inside one blocking call on the TUI
|
||||
// leg). The returned CompletionResult carries the assembled content
|
||||
// plus the terminal chunk's rune block (nothing the core knows is
|
||||
// dropped). onDelta may be nil (the assembled-only caller).
|
||||
func (r *Repo) CompleteStream(ctx context.Context, req Completion, onDelta func(string)) (CompletionResult, error) {
|
||||
body, err := r.post(ctx, "/v1/chat/completions", completionWire{
|
||||
Model: req.Model,
|
||||
Channel: req.Channel,
|
||||
Messages: toWireMessages(req.Messages),
|
||||
Stream: true,
|
||||
})
|
||||
if err != nil {
|
||||
return CompletionResult{}, err
|
||||
}
|
||||
defer body.Close()
|
||||
var result CompletionResult
|
||||
var content strings.Builder
|
||||
sc := bufio.NewScanner(body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var chunk struct {
|
||||
ID string `json:"id"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Rune map[string]any `json:"rune"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
return CompletionResult{}, err
|
||||
}
|
||||
if chunk.ID != "" {
|
||||
result.ID = chunk.ID
|
||||
}
|
||||
if len(chunk.Choices) > 0 {
|
||||
ch := chunk.Choices[0]
|
||||
if ch.Delta.Content != "" {
|
||||
content.WriteString(ch.Delta.Content)
|
||||
if onDelta != nil {
|
||||
onDelta(ch.Delta.Content)
|
||||
}
|
||||
}
|
||||
if ch.FinishReason != nil {
|
||||
result.Finish = *ch.FinishReason
|
||||
}
|
||||
}
|
||||
if chunk.Rune != nil {
|
||||
result.Rune = chunk.Rune
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return CompletionResult{}, err
|
||||
}
|
||||
result.Role = "assistant"
|
||||
result.Content = content.String()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toWireMessages(ms []CompletionMessage) []completionMessageWire {
|
||||
out := make([]completionMessageWire, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package lib
|
||||
|
||||
// complete_stream_test.go — the TUI leg's streaming proofs (Gitea
|
||||
// #2556, red-first, hermetic): CompleteStream parses the core's NDJSON
|
||||
// chunk framing incrementally, surfaces every text delta AS IT
|
||||
// ARRIVES, and returns the assembled content + the terminal rune
|
||||
// block; the completion client carries NO whole-call timeout (the
|
||||
// core's idle watch governs the streamed turn).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCompleteStreamDeltasAndResult(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
chunks := []string{
|
||||
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant","content":"hel"}}]}`,
|
||||
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"lo"}}]}`,
|
||||
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}],"rune":{"success":true,"model":"m"}}`,
|
||||
}
|
||||
for i, line := range chunks {
|
||||
_, _ = w.Write([]byte(line + "\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
if i < len(chunks)-1 {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
r := &Repo{entryBase: srv.URL, compHC: &http.Client{}}
|
||||
var got strings.Builder
|
||||
res, err := r.CompleteStream(context.Background(), Completion{Messages: []CompletionMessage{{Role: "user", Content: "hi"}}}, func(d string) {
|
||||
got.WriteString(d)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.String() != "hello" || res.Content != "hello" {
|
||||
t.Fatalf("deltas=%q content=%q", got.String(), res.Content)
|
||||
}
|
||||
if res.Finish != "stop" {
|
||||
t.Fatalf("finish = %q", res.Finish)
|
||||
}
|
||||
if res.Rune == nil || res.Rune["success"] != true {
|
||||
t.Fatalf("rune block = %v", res.Rune)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionClientIsUngated(t *testing.T) {
|
||||
r := &Repo{entryBase: "http://127.0.0.1:8377", compHC: &http.Client{}}
|
||||
if r.compHC.Timeout != 0 {
|
||||
t.Fatalf("completion client timeout = %v, want 0 (the streamed turn is governed by the core's idle watch)", r.compHC.Timeout)
|
||||
}
|
||||
}
|
||||
@@ -152,12 +152,13 @@ type Repo struct {
|
||||
run runner
|
||||
entryBase string
|
||||
hc *http.Client
|
||||
// compHC is the DEDICATED completion client (chat-live P1 F3): the
|
||||
// 10s blanket on hc fits reads, not completions — a dispatched
|
||||
// completion runs the core's WHOLE tool loop (D47 defect 3: up to
|
||||
// ToolLoopIterations model calls), so the client's timeout carries
|
||||
// the local whole-turn ceiling; the per-class deadline rides the
|
||||
// dispatch ctx the callers bind (TurnBudgetFor).
|
||||
// compHC is the DEDICATED completion client (chat-live P1 F3).
|
||||
// After the streaming root-cause wave (Gitea #2556) it carries NO
|
||||
// whole-call Timeout: the dispatch rides stream=true and the CORE
|
||||
// governs its deadlines (the inter-chunk idle timeout + the
|
||||
// 30-minute backstop) — a flat client cap here would patch the
|
||||
// symptom again. Non-streaming fallback callers keep their ctx
|
||||
// class budget (TurnBudgetFor).
|
||||
compHC *http.Client
|
||||
serveCmd *exec.Cmd
|
||||
closed bool
|
||||
@@ -197,7 +198,7 @@ func Open(root string) (*Repo, error) {
|
||||
root: root,
|
||||
run: binRunner(binDir),
|
||||
hc: &http.Client{Timeout: 10 * time.Second},
|
||||
compHC: &http.Client{Timeout: LocalTurnBudget},
|
||||
compHC: &http.Client{},
|
||||
}
|
||||
if err := r.startServe(binDir); err != nil {
|
||||
os.RemoveAll(binDir)
|
||||
|
||||
@@ -89,33 +89,39 @@ func configs() []faceConfig {
|
||||
boxes: []string{"sug-1", "sug-2", "sug-3"},
|
||||
},
|
||||
{
|
||||
name: "tasks/closed",
|
||||
face: func() semantic.Face { return tasks.New(80, 24) },
|
||||
name: "tasks/closed",
|
||||
// D52 supersession (#2566): the box inventory drives the
|
||||
// canonical seed substrate — the lab seed is explicit.
|
||||
face: func() semantic.Face { return tasks.NewSeeded(80, 24) },
|
||||
boxes: []string{"chat", "task-1", "task-2", "task-3"},
|
||||
pending: []string{"chat"},
|
||||
},
|
||||
{
|
||||
name: "tasks/tree",
|
||||
face: func() semantic.Face {
|
||||
f := tasks.New(80, 24)
|
||||
f := tasks.NewSeeded(80, 24)
|
||||
f.SetTreeOpen(true) // D36 owner fix: t is a letter now
|
||||
return f
|
||||
},
|
||||
boxes: []string{"task-1", "task-1a", "task-1b", "task-2", "task-3"},
|
||||
// D50 residual 3: no selectable rows anywhere — the tree
|
||||
// region is a view and declares no boxes.
|
||||
boxes: nil,
|
||||
},
|
||||
{
|
||||
name: "tasks/linear-tree",
|
||||
face: func() semantic.Face {
|
||||
f := tasks.NewLinear(80, 24)
|
||||
f.SetLiveTree(tasks.SeedTree(), "lab")
|
||||
f.SetTreeOpen(true) // D36 owner fix: t is a letter now
|
||||
return f
|
||||
},
|
||||
boxes: []string{"task-1", "task-1a", "task-1b", "task-2", "task-3"},
|
||||
// D50 residual 3: no selectable rows anywhere.
|
||||
boxes: nil,
|
||||
},
|
||||
{
|
||||
name: "tasks/promote-open-inside",
|
||||
face: func() semantic.Face {
|
||||
f := tasks.New(80, 24)
|
||||
f := tasks.NewSeeded(80, 24)
|
||||
f.SetPromotionOpen(true) // D36 owner fix: p is a letter now
|
||||
return f
|
||||
},
|
||||
@@ -124,7 +130,7 @@ func configs() []faceConfig {
|
||||
{
|
||||
name: "tasks/rail-takeover-inside",
|
||||
face: func() semantic.Face {
|
||||
f := tasks.New(80, 24)
|
||||
f := tasks.NewSeeded(80, 24)
|
||||
f.SetTakeoverOpen(true)
|
||||
return f
|
||||
},
|
||||
@@ -273,7 +279,9 @@ func (f *shiftTabBlindFace) SelectedBox() string {
|
||||
// chain (the face cmd/rune-tui actually boots) with shift+tab dropped
|
||||
// anywhere in it must go RED, not pass silently.
|
||||
func TestMutantShiftTabBlindTripsRed(t *testing.T) {
|
||||
rep := semantic.CheckSelection(&shiftTabBlindFace{tasks.New(80, 24)})
|
||||
// D52 supersession (#2566): the mutant proof drives the seed
|
||||
// substrate — the lab seed is explicit.
|
||||
rep := semantic.CheckSelection(&shiftTabBlindFace{tasks.NewSeeded(80, 24)})
|
||||
if rep.OK {
|
||||
t.Fatal("shift+tab-blind mutant of the shell face chain PASSED the conformance gate — the gate has no teeth")
|
||||
}
|
||||
|
||||
@@ -68,6 +68,13 @@ func Seed(w, h int) State {
|
||||
}
|
||||
}
|
||||
|
||||
// Empty returns the honest PRODUCTION boot state (D52 seed-substrate
|
||||
// law, #2566): no lab tasks, no seeded chat line — the face boots
|
||||
// blank and the live data fills it. The lab harness keeps Seed.
|
||||
func Empty(w, h int) State {
|
||||
return State{W: w, H: h, Status: "RUNNING", Focus: FocusChat, Seq: -1}
|
||||
}
|
||||
|
||||
// Clone deep-copies the slice fields so two faces never share memory.
|
||||
func (s State) Clone() State {
|
||||
n := s
|
||||
|
||||
@@ -32,6 +32,11 @@ const (
|
||||
KeyThemesTheme = "themes.theme"
|
||||
KeyThemesAnimation = "themes.animation"
|
||||
KeyThemesReducedMotion = "themes.reduced_motion"
|
||||
// KeyThemesSensory is the D54 sensory presentation setting
|
||||
// (#2571 item 5, the NAS Vivid/Calm precedent): "vivid" (the
|
||||
// DEFAULT — the owner's ruling) or "calm" (muted chroma + no
|
||||
// wave motion).
|
||||
KeyThemesSensory = "themes.sensory"
|
||||
)
|
||||
|
||||
// ChannelKey is the setting key of one notification channel's toggle.
|
||||
@@ -51,6 +56,7 @@ func KnownSettings() []string {
|
||||
KeyThemesTheme,
|
||||
KeyThemesAnimation,
|
||||
KeyThemesReducedMotion,
|
||||
KeyThemesSensory,
|
||||
}
|
||||
for _, c := range ChannelOrder {
|
||||
out = append(out, ChannelKey(c))
|
||||
@@ -96,6 +102,8 @@ type ThemesConfig struct {
|
||||
Theme string
|
||||
Animation string
|
||||
ReducedMotion bool
|
||||
// Sensory is the presentation mode: "vivid" (default) or "calm".
|
||||
Sensory string
|
||||
}
|
||||
|
||||
// Config is the whole live config, as the owner holds it.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D54 item 5 (Gitea #2571): the sensory setting is part of the closed
|
||||
// address space, rendered in the themes section, and toggleable from
|
||||
// the surface (NAS Vivid/Calm precedent; vivid is the default).
|
||||
|
||||
type stubOwner struct {
|
||||
cfg Config
|
||||
refus error
|
||||
}
|
||||
|
||||
func (o *stubOwner) Get() (Config, error) { return o.cfg, nil }
|
||||
func (o *stubOwner) Apply(cs []Change) error {
|
||||
for _, c := range cs {
|
||||
if c.Setting == KeyThemesSensory {
|
||||
v, ok := c.Value.(string)
|
||||
if !ok || (v != "vivid" && v != "calm") {
|
||||
return errStub("sensory must be vivid or calm")
|
||||
}
|
||||
o.cfg.Themes.Sensory = v
|
||||
}
|
||||
}
|
||||
return o.refus
|
||||
}
|
||||
|
||||
type errStub string
|
||||
|
||||
func (e errStub) Error() string { return string(e) }
|
||||
|
||||
func TestSensoryKeyIsKnown(t *testing.T) {
|
||||
found := false
|
||||
for _, k := range KnownSettings() {
|
||||
if k == KeyThemesSensory {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("themes.sensory must be inside the closed address space")
|
||||
}
|
||||
if err := (Open(&stubOwner{})).Edit(KeyThemesSensory, "calm"); err != nil {
|
||||
t.Fatalf("staging sensory=calm must be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensoryToggleStagesAndFlips(t *testing.T) {
|
||||
o := &stubOwner{cfg: Config{Themes: ThemesConfig{Sensory: "vivid"}}}
|
||||
s := Open(o)
|
||||
if err := s.ToggleSensory(); err != nil {
|
||||
t.Fatalf("toggle from vivid: %v", err)
|
||||
}
|
||||
p := s.Pending()
|
||||
if len(p) != 1 || p[0].Value != "calm" {
|
||||
t.Fatalf("toggle must stage calm, got %+v", p)
|
||||
}
|
||||
if err := s.Submit(); err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
if o.cfg.Themes.Sensory != "calm" {
|
||||
t.Fatalf("owner must hold calm after submit, got %q", o.cfg.Themes.Sensory)
|
||||
}
|
||||
// and back
|
||||
if err := s.ToggleSensory(); err != nil {
|
||||
t.Fatalf("toggle back: %v", err)
|
||||
}
|
||||
if p := s.Pending(); len(p) != 1 || p[0].Value != "vivid" {
|
||||
t.Fatalf("toggle must flip back to vivid, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensoryRendersInThemesSection(t *testing.T) {
|
||||
o := &stubOwner{cfg: Config{Themes: ThemesConfig{Sensory: "vivid"}}}
|
||||
out := strings.Join(Open(o).Render(), "\n")
|
||||
if !strings.Contains(out, "sensory") || !strings.Contains(out, "vivid") {
|
||||
t.Fatalf("the themes section must render the sensory setting:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ func sectionLines(cfg Config) []string {
|
||||
row("theme", cfg.Themes.Theme),
|
||||
row("animation", cfg.Themes.Animation),
|
||||
row("reduced motion", onOff(cfg.Themes.ReducedMotion)),
|
||||
row("sensory", orNone(cfg.Themes.Sensory)),
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -134,6 +134,41 @@ func (s *Surface) ToggleChannel(channel string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToggleSensory flips the D54 sensory presentation setting
|
||||
// (#2571 item 5): vivid <-> calm, staged as a draft like any other
|
||||
// edit (the owner validates at Submit; vivid is the default).
|
||||
func (s *Surface) ToggleSensory() error {
|
||||
cfg, err := s.owner.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("settings: cannot toggle sensory — owner unavailable: %s", err)
|
||||
}
|
||||
next := "calm"
|
||||
if cfg.Themes.Sensory != "vivid" {
|
||||
// live value anything but vivid (incl. empty/unset): the
|
||||
// toggle flips TO vivid — vivid is the default.
|
||||
next = "vivid"
|
||||
}
|
||||
current := cfg.Themes.Sensory
|
||||
for _, d := range s.drafts {
|
||||
if d.Setting == KeyThemesSensory {
|
||||
if v, ok := d.Value.(string); ok {
|
||||
current = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if current == "vivid" {
|
||||
next = "calm"
|
||||
} else if current == "calm" {
|
||||
next = "vivid"
|
||||
}
|
||||
if next == cfg.Themes.Sensory {
|
||||
s.unstage(KeyThemesSensory)
|
||||
return nil
|
||||
}
|
||||
s.stage(Change{Setting: KeyThemesSensory, Value: next})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit hands every pending edit to the owner's Apply. On refusal the
|
||||
// error is kept and rendered visibly, the drafts stay staged, and
|
||||
// nothing changes. On success drafts clear and the next render shows
|
||||
|
||||
@@ -287,6 +287,7 @@ func TestRoundTripFixtures(t *testing.T) {
|
||||
" theme slate",
|
||||
" animation full",
|
||||
" reduced motion off",
|
||||
" sensory (none)",
|
||||
}
|
||||
if got := s.Render(); !reflect.DeepEqual(got, wantBefore) {
|
||||
t.Fatalf("default render drift:\n got: %q\nwant: %q", got, wantBefore)
|
||||
@@ -337,6 +338,7 @@ func TestRoundTripFixtures(t *testing.T) {
|
||||
" theme dusk",
|
||||
" animation off",
|
||||
" reduced motion on",
|
||||
" sensory (none)",
|
||||
}
|
||||
if got := s.Render(); !reflect.DeepEqual(got, wantAfter) {
|
||||
t.Fatalf("post-submit render drift:\n got: %q\nwant: %q", got, wantAfter)
|
||||
|
||||
+1
-1
@@ -56,4 +56,4 @@ j/k select - y consent/approve - n decline - e expand - esc back
|
||||
│[m [38;5;240m│
|
||||
│[m [38;5;240m│
|
||||
│[m [38;5;240m│
|
||||
│[m [38;5;240m│
|
||||
─────────────────┘[m [38;5;240m└──────────────────────────────────────────────────────
|
||||
@@ -39,7 +39,7 @@ func TestAppendAnswerCodeRoutesToWorkStream(t *testing.T) {
|
||||
if n != 1 {
|
||||
t.Fatalf("first routed block appends, got %d", n)
|
||||
}
|
||||
rows, _ := f.treeRows(78, 40)
|
||||
rows, _, _ := f.treeRows(78, 40)
|
||||
joined := strings.Join(rows, "\n")
|
||||
if !strings.Contains(joined, "code · go (2 lines)") {
|
||||
t.Fatalf("the collapsed summary must name the language and size, got:\n%s", joined)
|
||||
@@ -51,7 +51,7 @@ func TestAppendAnswerCodeRoutesToWorkStream(t *testing.T) {
|
||||
if !f.ToggleWorkExpand() {
|
||||
t.Fatal("expand must flip the code artifact")
|
||||
}
|
||||
rows, _ = f.treeRows(78, 40)
|
||||
rows, _, _ = f.treeRows(78, 40)
|
||||
joined = strings.Join(rows, "\n")
|
||||
if !strings.Contains(joined, "fmt.Println(\"hi\")") {
|
||||
t.Fatalf("expanded must show the code body, got:\n%s", joined)
|
||||
|
||||
@@ -12,8 +12,6 @@ package tasks
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
// Command verbs (mirroring the command surface's vocabulary).
|
||||
@@ -89,25 +87,6 @@ func (f *Tasks) PendingCommand() string {
|
||||
return f.pending.Verb + " " + f.pending.TaskID
|
||||
}
|
||||
|
||||
// queueCommand enqueues one command for the selected task.
|
||||
func (f *Tasks) queueCommand(verb string, delta int, assignee string) semantic.ActionID {
|
||||
id := f.SelectedID()
|
||||
if id == "" {
|
||||
return actionNoop("tasks.command." + verb)
|
||||
}
|
||||
if f.pending != nil {
|
||||
f.notice = "command in flight (" + f.pending.Verb + " " + f.pending.TaskID + ") — wait for its receipt"
|
||||
return actionNoop("tasks.command." + verb + "." + id)
|
||||
}
|
||||
if isDerivedRow(id) {
|
||||
f.notice = "not a task record — derived rows (questions, history lines, journal leaves) are never commanded"
|
||||
return actionNoop("tasks.command." + verb + "." + id)
|
||||
}
|
||||
f.pending = &TaskCommand{Verb: verb, TaskID: id, Delta: delta, Assignee: assignee}
|
||||
f.notice = verb + " " + id + " — sending…"
|
||||
return semantic.ActionID("tasks.command." + verb + "." + id)
|
||||
}
|
||||
|
||||
// ApplyReceipt feeds one runtime receipt back: an applied command
|
||||
// moves the view-model through the same pure ops (the receipt is the
|
||||
// authority); a refusal mutates nothing and surfaces its typed reason.
|
||||
@@ -149,7 +128,6 @@ func (f *Tasks) applyConfirmed(r TaskReceipt) {
|
||||
// propagation line comes from the RECEIPT (the authority).
|
||||
if nt, _, err := f.tree.Reorder(id, sentDelta(sent), OriginOwner); err == nil {
|
||||
f.tree = nt
|
||||
f.sel = indexOf(nt.Order(), id)
|
||||
}
|
||||
p := Propagation{Moved: id, Above: r.Above, Running: r.Running, Effective: r.Effective, NewOrder: r.Order}
|
||||
f.lastProp = &p
|
||||
|
||||
+17
-181
@@ -1,193 +1,29 @@
|
||||
// commands_test.go — the K20 command-path pins that SURVIVE the D50
|
||||
// residual 3 removal (the row-selection walker is gone; the row-verb
|
||||
// command suites went with it — recorded in the wave commit).
|
||||
package tasks
|
||||
|
||||
// commands_test.go — the COMMAND protocol battery (wave K20): the
|
||||
// controls enqueue real commands only on the live command path, the
|
||||
// receipt applies through the pure ops, refusals mutate nothing, and
|
||||
// the honest notices carry the refusal's own words.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
// liveFace builds a tree-open live face over the frozen three-task
|
||||
// fixture: task_run running, task_dep + task_audit created.
|
||||
func liveFace(t *testing.T) *Tasks {
|
||||
t.Helper()
|
||||
f := New(80, 24)
|
||||
f.SetLiveTree(liveCommandTree(), "lib")
|
||||
f.SetCommandPath(true)
|
||||
f.SetTreeOpen(true)
|
||||
return f
|
||||
}
|
||||
|
||||
func liveCommandTree() *Tree {
|
||||
in := LiveInput{
|
||||
Tasks: []LiveTask{
|
||||
{ID: "task_run", Title: "wire stream", Status: "running"},
|
||||
{ID: "task_dep", Title: "approve migration", Status: "created"},
|
||||
{ID: "task_audit", Title: "resize audit", Status: "created"},
|
||||
},
|
||||
}
|
||||
return DeriveLive(in)
|
||||
}
|
||||
|
||||
func (f *Tasks) selectRow(id string) {
|
||||
for i, row := range f.tree.Order() {
|
||||
if row == id {
|
||||
f.sel = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPathOffKeepsRenderOnly(t *testing.T) {
|
||||
f := New(80, 24)
|
||||
f.SetLiveTree(liveCommandTree(), "lib")
|
||||
f.SetTreeOpen(true)
|
||||
f.selectRow("task_dep")
|
||||
if f.CommandPath() {
|
||||
t.Fatal("no seam wired: the path must stay off")
|
||||
}
|
||||
a, _ := f.Apply(semantic.Named("K"))
|
||||
if a != semantic.ActionID("tasks.reorder.up.task_dep") {
|
||||
t.Fatalf("offline K reorders locally, got %v", a)
|
||||
}
|
||||
if _, sent := f.TakeCommand(); sent {
|
||||
t.Fatal("no command may be sent without the path")
|
||||
}
|
||||
if !strings.Contains(f.footerLine(), "render-only") {
|
||||
t.Fatalf("the footer must say render-only without the path, got %q", f.footerLine())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderMidFlightSendsAndReceipts(t *testing.T) {
|
||||
f := liveFace(t)
|
||||
f.selectRow("task_audit")
|
||||
a, _ := f.Apply(semantic.Named("K"))
|
||||
if a != semantic.ActionID("tasks.command.reorder.task_audit") {
|
||||
t.Fatalf("live K must send the command, got %v", a)
|
||||
}
|
||||
cmd, ok := f.TakeCommand()
|
||||
if !ok || cmd.Verb != CmdVerbReorder || cmd.TaskID != "task_audit" || cmd.Delta != -1 {
|
||||
t.Fatalf("queued command = %+v", cmd)
|
||||
}
|
||||
f.ApplyReceipt(TaskReceipt{
|
||||
CommandID: "cmd_1", Verb: CmdVerbReorder, TaskID: "task_audit",
|
||||
Applied: true, Effective: "at-running-boundary", Running: "task_run",
|
||||
Above: "task_dep", Order: []string{"task_run", "task_audit", "task_dep"},
|
||||
Detail: "owner moved task_audit above task_dep; takes effect at running boundary (task_run running)",
|
||||
})
|
||||
if f.tree.Order()[1] != "task_audit" || f.tree.Order()[2] != "task_dep" {
|
||||
t.Fatalf("receipted reorder must move the view-model, got %v", f.tree.Order())
|
||||
}
|
||||
if f.lastProp == nil || f.lastProp.Effective != "at-running-boundary" || f.lastProp.Running != "task_run" {
|
||||
t.Fatalf("the receipt's propagation is the authority, got %+v", f.lastProp)
|
||||
}
|
||||
if !strings.Contains(f.notice, "reorder receipted") || !strings.Contains(f.notice, "cmd_1") {
|
||||
t.Fatalf("the applied receipt must carry its receipt id in the notice, got %q", f.notice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignToAgentReceiptApplies(t *testing.T) {
|
||||
f := liveFace(t)
|
||||
f.selectRow("task_dep")
|
||||
a, _ := f.Apply(semantic.Named("a"))
|
||||
if a != semantic.ActionID("tasks.command.assign.task_dep") {
|
||||
t.Fatalf("live a must send assign, got %v", a)
|
||||
}
|
||||
cmd, ok := f.TakeCommand()
|
||||
if !ok || cmd.Verb != CmdVerbAssign || cmd.Assignee != AgentAssignee(SeedAgent) {
|
||||
t.Fatalf("queued assign = %+v", cmd)
|
||||
}
|
||||
f.ApplyReceipt(TaskReceipt{CommandID: "cmd_2", Verb: CmdVerbAssign, TaskID: "task_dep",
|
||||
Applied: true, Detail: "task_dep assigned to agent:alpha"})
|
||||
if n := f.tree.Node("task_dep"); n.Assignee != AgentAssignee(SeedAgent) {
|
||||
t.Fatalf("receipted assign must set the assignee, got %q", n.Assignee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedStartRefusalMutatesNothing(t *testing.T) {
|
||||
f := liveFace(t)
|
||||
f.selectRow("task_dep")
|
||||
if _, ok := f.Apply(semantic.Named("s")); ok {
|
||||
t.Fatal("quit must never fire on s")
|
||||
}
|
||||
cmd, ok := f.TakeCommand()
|
||||
if !ok || cmd.Verb != CmdVerbStart || cmd.TaskID != "task_dep" {
|
||||
t.Fatalf("queued start = %+v", cmd)
|
||||
}
|
||||
before := f.tree.Clone()
|
||||
f.ApplyReceipt(TaskReceipt{CommandID: "cmd_3", Verb: CmdVerbStart, TaskID: "task_dep",
|
||||
Refused: true, Code: "blocked",
|
||||
Message: "task_dep is blocked by unfinished dependencies",
|
||||
Blockers: []string{"task_audit"}})
|
||||
if n := f.tree.Node("task_dep"); n.Status != before.Node("task_dep").Status {
|
||||
t.Fatalf("a refusal must not mutate the tree, got %q", n.Status)
|
||||
}
|
||||
if !strings.Contains(f.notice, "refused (blocked)") || !strings.Contains(f.notice, "task_audit") {
|
||||
t.Fatalf("blocked refusal must name the blocker, got %q", f.notice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleIDRefusalEchoesAndResyncs(t *testing.T) {
|
||||
f := liveFace(t)
|
||||
f.selectRow("task_dep")
|
||||
if _, ok := f.Apply(semantic.Named("d")); ok {
|
||||
t.Fatal("quit must never fire on d")
|
||||
}
|
||||
if _, ok := f.TakeCommand(); !ok {
|
||||
t.Fatal("d must queue complete")
|
||||
}
|
||||
f.ApplyReceipt(TaskReceipt{CommandID: "cmd_4", Verb: CmdVerbComplete, TaskID: "task_dep",
|
||||
Refused: true, Code: "stale_task_id",
|
||||
Message: "unknown task_id \"task_dep\" — the state moved on; current state echoed",
|
||||
Current: []LiveTask{{ID: "task_run", Title: "wire stream", Status: "running"}}})
|
||||
if !strings.Contains(f.notice, "refused (stale id)") || !strings.Contains(f.notice, "current state echoed") {
|
||||
t.Fatalf("stale refusal must surface the echo, got %q", f.notice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneCommandInFlightLaw(t *testing.T) {
|
||||
f := liveFace(t)
|
||||
f.selectRow("task_dep")
|
||||
f.Apply(semantic.Named("s"))
|
||||
a, _ := f.Apply(semantic.Named("a"))
|
||||
if !strings.HasSuffix(string(a), ".noop") {
|
||||
t.Fatalf("a second control while a command is in flight must be a noop, got %v", a)
|
||||
}
|
||||
if !strings.Contains(f.notice, "command in flight") {
|
||||
t.Fatalf("the in-flight law must be surfaced, got %q", f.notice)
|
||||
}
|
||||
cmd, ok := f.TakeCommand()
|
||||
if !ok || cmd.Verb != CmdVerbStart {
|
||||
t.Fatalf("the first queued command must reach the shell, got %+v ok=%v", cmd, ok)
|
||||
}
|
||||
if _, ok := f.TakeCommand(); ok {
|
||||
t.Fatal("the rejected second control must not have queued anything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedRowsAreNeverCommanded(t *testing.T) {
|
||||
f := liveFace(t)
|
||||
f.selectRow("ev:evt_1") // a journal leaf
|
||||
f.tree.Nodes["ev:evt_1"] = &Node{ID: "ev:evt_1", evLeaf: true}
|
||||
f.tree.Roots = append(f.tree.Roots, "ev:evt_1")
|
||||
f.selectRow("ev:evt_1")
|
||||
f.Apply(semantic.Named("s"))
|
||||
if !strings.Contains(f.notice, "never commanded") {
|
||||
t.Fatalf("derived rows must refuse commands, got %q", f.notice)
|
||||
}
|
||||
if _, ok := f.TakeCommand(); ok {
|
||||
t.Fatal("no command may target a derived row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterNamesLiveCommands(t *testing.T) {
|
||||
f := liveFace(t)
|
||||
if !strings.Contains(f.footerLine(), "live commands") || !strings.Contains(f.footerLine(), "s start") {
|
||||
if !strings.Contains(f.footerLine(), "live commands") || !strings.Contains(f.footerLine(), "ctrl+q steer") {
|
||||
t.Fatalf("the command-path footer must name the live controls, got %q", f.footerLine())
|
||||
}
|
||||
}
|
||||
|
||||
func liveFace(t *testing.T) *Tasks {
|
||||
t.Helper()
|
||||
in := LiveInput{Tasks: []LiveTask{
|
||||
{ID: "task_stream", Title: "wire stream", Status: "running"},
|
||||
{ID: "task_dep", Title: "approve migration", Status: "created"},
|
||||
{ID: "task_audit", Title: "resize audit", Status: "created"},
|
||||
}}
|
||||
f := New(80, 24)
|
||||
f.SetLiveTree(DeriveLive(in), "lib")
|
||||
f.cmdOn = true
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package tasks
|
||||
|
||||
// d52_seedleak_test.go — the D52 seed-substrate law (Gitea #2566 item
|
||||
// 1): PRODUCTION faces boot EMPTY. The owner's recurring "fix the
|
||||
// login timeout" phantom is the lab substrate leaking into the
|
||||
// production face: the semantic seed's three lab tasks rendered as
|
||||
// boot task rows, the promote seed's suggestion fixture ("fix the
|
||||
// login timeout now - it blocks deploys") held behind the promotion
|
||||
// region, and SeedTree's lab tree behind the tree region. The law:
|
||||
//
|
||||
// - tasks.New / NewLinear boot with NO task rows, NO suggestions,
|
||||
// NO lab tree — an honest idle render ("no outstanding work").
|
||||
// - SetLiveTree / Offer fill the faces when REAL data arrives.
|
||||
// - the seeds stay confined to the lab: semantic.Seed, SeedTree,
|
||||
// SeedSuggestions keep their canonical shapes (the frozen lab
|
||||
// fixtures drive them explicitly).
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rune.dev/rune/tui/promote"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
// TestProductionFaceBootsEmpty — the red-first pin: a FRESH production
|
||||
// face renders no lab task rows, no lab suggestion text, and names the
|
||||
// honest idle state.
|
||||
func TestProductionFaceBootsEmpty(t *testing.T) {
|
||||
for _, f := range []*Tasks{New(80, 24), NewLinear(80, 24)} {
|
||||
plain := semantic.StripANSI(f.Render().Content)
|
||||
if strings.Contains(plain, "login timeout") {
|
||||
t.Fatalf("production face must NOT boot the lab suggestion fixture:\n%s", plain)
|
||||
}
|
||||
if strings.Contains(plain, "wire stream") || strings.Contains(plain, "resize audit") ||
|
||||
strings.Contains(plain, "approve migration") {
|
||||
t.Fatalf("production face must NOT boot the lab task rows:\n%s", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "no outstanding work") {
|
||||
t.Fatalf("the empty face must render the honest idle state:\n%s", plain)
|
||||
}
|
||||
if f.Live() {
|
||||
t.Fatal("the boot substrate is not live data")
|
||||
}
|
||||
if len(f.TreeSnapshot().Roots) != 0 {
|
||||
t.Fatalf("the boot tree must be empty, roots = %v", f.TreeSnapshot().Roots)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedsStayLabConfined — the seeds keep their canonical shapes and
|
||||
// the production face accepts them only EXPLICITLY.
|
||||
func TestSeedsStayLabConfined(t *testing.T) {
|
||||
tr := SeedTree()
|
||||
if len(tr.Roots) != 3 {
|
||||
t.Fatalf("SeedTree stays the canonical lab seed, roots = %v", tr.Roots)
|
||||
}
|
||||
if len(promote.SeedSuggestions) != 3 {
|
||||
t.Fatal("SeedSuggestions stays the canonical lab seed")
|
||||
}
|
||||
if len(semantic.Seed(80, 24).Tasks) != 3 {
|
||||
t.Fatal("semantic.Seed stays the canonical lab seed")
|
||||
}
|
||||
// an explicit live install carries real data into the face
|
||||
f := New(80, 24)
|
||||
f.SetLiveTree(SeedTree(), "lab")
|
||||
if !f.Live() || len(f.TreeSnapshot().Roots) != 3 {
|
||||
t.Fatal("SetLiveTree must install the tree when the caller asks")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteBootsEmptyOffersFill — the promote face's production
|
||||
// boot is suggestion-free; Offer fills it when real suggestions land.
|
||||
func TestPromoteBootsEmptyOffersFill(t *testing.T) {
|
||||
p := promote.NewEmpty(80, 24)
|
||||
if got := p.Suggestions(); len(got) != 0 {
|
||||
t.Fatalf("the production promote face must boot with no suggestions, got %d", len(got))
|
||||
}
|
||||
p.Offer(promote.SeedSuggestions)
|
||||
if got := p.Suggestions(); len(got) != 3 {
|
||||
t.Fatalf("Offer must fill the suggestion list, got %d", len(got))
|
||||
}
|
||||
}
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user