Compare commits
334
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
372e94b49a | ||
|
|
d0d116be2e | ||
|
|
d43cc2ca80 | ||
|
|
a9c868225e | ||
|
|
69365109b3 | ||
|
|
28f73d32e9 | ||
|
|
44c67fca91 | ||
|
|
6816f2f02c | ||
|
|
5faef80a43 | ||
|
|
9b868f6f67 | ||
|
|
df051c17cc | ||
|
|
3ea35d6711 | ||
|
|
a7d78ad685 | ||
|
|
d4c6ae7b11 | ||
|
|
85b2d52b71 | ||
|
|
3638abfbf9 | ||
|
|
c1f5f0f911 | ||
|
|
d661886c90 | ||
|
|
8d72845399 | ||
|
|
b7a05b6b6f | ||
|
|
80ece3867b | ||
|
|
fa4800414c | ||
|
|
cb481e2f2b | ||
|
|
66fdcfa3bd | ||
|
|
17a81ac89e | ||
|
|
6a8d31856f | ||
|
|
34678d2f2e | ||
|
|
eebc2286fc | ||
|
|
056a40aa4d | ||
|
|
fdefb2d38c | ||
|
|
6ab8428b88 | ||
|
|
8cd49c496f | ||
|
|
69339aab91 | ||
|
|
bc7212cf93 | ||
|
|
ead9d7b256 | ||
|
|
4ee74fa5df | ||
|
|
3dce1b967f | ||
|
|
289fad1868 | ||
|
|
3616ce006a | ||
|
|
32a4faa2d5 | ||
|
|
cc1725cbe5 | ||
|
|
5be99b6fce | ||
|
|
4c9628eab5 | ||
|
|
053f162f94 | ||
|
|
53bdcacf17 | ||
|
|
75afaf46da | ||
|
|
c4f5a45d5d | ||
|
|
66513768c1 | ||
|
|
d9fe008db8 | ||
|
|
8685fea0ce | ||
|
|
da131aef3a | ||
|
|
0a5d8a16fc | ||
|
|
805c22c836 | ||
|
|
2946805299 | ||
|
|
c5b62fdbae | ||
|
|
241bc112e8 | ||
|
|
ee62aab1a7 | ||
|
|
f8f5ce7da5 | ||
|
|
543941f709 | ||
|
|
5ee1c426de | ||
|
|
9439f11717 | ||
|
|
ca8aee87e8 | ||
|
|
c1c991ae9e | ||
|
|
06be0e69b6 | ||
|
|
ed67f9aacc | ||
|
|
a60b00e12d | ||
|
|
f50c3d904c | ||
|
|
0796a981d7 | ||
|
|
2cc0ff44b6 | ||
|
|
246eacea7b | ||
|
|
185b08a2eb | ||
|
|
cd6fb2b167 | ||
|
|
08abc5eba8 | ||
|
|
25b3cd2ced | ||
|
|
32f7c5afaf | ||
|
|
6ced760a3d | ||
|
|
53e6435908 | ||
|
|
089f09fa5f | ||
|
|
9e4b89857a | ||
|
|
1f45504609 | ||
|
|
80dcff8458 | ||
|
|
eaa35ae68f | ||
|
|
c934c533db | ||
|
|
a7738796e1 | ||
|
|
72f714ca36 | ||
|
|
b86ca7cae5 | ||
|
|
b932997f44 | ||
|
|
3e2fe3fcab | ||
|
|
9ed5aef1e6 | ||
|
|
5e44413b7c | ||
|
|
132e0005c8 | ||
|
|
cc64f0289a | ||
|
|
b810711e4e | ||
|
|
8e6d1a9a53 | ||
|
|
50a6dc7efc | ||
|
|
1f25791b1f | ||
|
|
558dab0e3e | ||
|
|
9c9b057b73 | ||
|
|
9ddcb58a21 | ||
|
|
91799405aa | ||
|
|
61ea271900 | ||
|
|
8d1b1372e9 | ||
|
|
6bb0eac398 | ||
|
|
a4bc1ca502 | ||
|
|
beffbab3d7 | ||
|
|
4dccfcd9b7 | ||
|
|
12096b1e3d | ||
|
|
8e4b5d8774 | ||
|
|
fccc222bd7 | ||
|
|
1d7db1ba17 | ||
|
|
2e08b778ab | ||
|
|
0cd4afeafd | ||
|
|
42626da1ce | ||
|
|
ca988df8d0 | ||
|
|
21ac2b50dc | ||
|
|
5c89cc4359 | ||
|
|
d35b003f02 | ||
|
|
e40a38aa29 | ||
|
|
62747aa584 | ||
|
|
fa8a3e328e | ||
|
|
f716b876a8 | ||
|
|
93a47dd466 | ||
|
|
c132783ea0 | ||
|
|
2d7353cd3b | ||
|
|
26a2fda8d7 | ||
|
|
3ec5a06f4f | ||
|
|
bc56002124 | ||
|
|
44f6f8435b | ||
|
|
565ea19a49 | ||
|
|
390ddfd028 | ||
|
|
ece3dd1a4f | ||
|
|
93102e91cf | ||
|
|
c1529e58da | ||
|
|
0eb5cb5e07 | ||
|
|
42534605b7 | ||
|
|
e027f43f70 | ||
|
|
96f21e8a54 | ||
|
|
24d7333eb1 | ||
|
|
74db4bfe68 | ||
|
|
6b1b2e6f0e | ||
|
|
6d4f7f4049 | ||
|
|
2b226be8e3 | ||
|
|
4f7109551a | ||
|
|
ada06ee7b0 | ||
|
|
3638136e7e | ||
|
|
c7fd3eb377 | ||
|
|
78312c192d | ||
|
|
4ab3bf66f1 | ||
|
|
1d603fe822 | ||
|
|
2962ba2b7b | ||
|
|
0dbf639bc8 | ||
|
|
d9165d7a67 | ||
|
|
769381fb3e | ||
|
|
5b794c984e | ||
|
|
65d42e35d4 | ||
|
|
c2c2449d05 | ||
|
|
547bf1ee9e | ||
|
|
3d67f00fe1 | ||
|
|
fdd3943cb1 | ||
|
|
76d4b65d59 | ||
|
|
1f07fae6df | ||
|
|
0e15805e25 | ||
|
|
01b0451909 | ||
|
|
d5c03fb369 | ||
|
|
1d1b670cb5 | ||
|
|
5c8d098eb3 | ||
|
|
b0a88899bf | ||
|
|
781968be5e | ||
|
|
b9b5481d62 | ||
|
|
6833eabb53 | ||
|
|
4c88e2163b | ||
|
|
dc3e4e8428 | ||
|
|
6bd02ae1a6 | ||
|
|
51083d2edc | ||
|
|
02f1dd0857 | ||
|
|
4cb85fb7fc | ||
|
|
eb7be2edde | ||
|
|
e8000b42e7 | ||
|
|
07cb4a697e | ||
|
|
eed6bb14bc | ||
|
|
65145d9c5a | ||
|
|
b86367e496 | ||
|
|
08298dabbd | ||
|
|
ec5835ab8b | ||
|
|
849c17752d | ||
|
|
e321b83392 | ||
|
|
683059feb5 | ||
|
|
2755bf558e | ||
|
|
cdfe562342 | ||
|
|
792ede0a36 | ||
|
|
b327eaa3a6 | ||
|
|
a7dcf9787b | ||
|
|
47bbc12e18 | ||
|
|
a553bc74e4 | ||
|
|
714d7cc1a4 | ||
|
|
1c58fe4362 | ||
|
|
129b9f9d33 | ||
|
|
a9613d2e57 | ||
|
|
795bf4a9e6 | ||
|
|
509960e827 | ||
|
|
40fd2b8c08 | ||
|
|
8aa2a8bbcf | ||
|
|
a9a8ba2acf | ||
|
|
de602b7298 | ||
|
|
acbc3abe8b | ||
|
|
2e9765b34e | ||
|
|
ca9c30c7f0 | ||
|
|
49a8c61cac | ||
|
|
d81a3dbfbb | ||
|
|
28dced2440 | ||
|
|
4035d70bbe | ||
|
|
2ca38e5df4 | ||
|
|
06a2d77372 | ||
|
|
33fd705421 | ||
|
|
d2bb6cc251 | ||
|
|
18d83b4da9 | ||
|
|
d12ea20009 | ||
|
|
fe2ae40983 | ||
|
|
4fbfb26704 | ||
|
|
79a83830ba | ||
|
|
03c96b7ab5 | ||
|
|
17b3a4bd41 | ||
|
|
929c952596 | ||
|
|
d1c0c33a8b | ||
|
|
d462116226 | ||
|
|
0bc7fb2b3b | ||
|
|
2cabeeabca | ||
|
|
9284a3402f | ||
|
|
9d4cc12605 | ||
|
|
91546b8337 | ||
|
|
97d51ca20d | ||
|
|
c82c5786f4 | ||
|
|
4c99a44e6d | ||
|
|
2841a9cbca | ||
|
|
9220c0c0bb | ||
|
|
8058e01834 | ||
|
|
7419de6ac6 | ||
|
|
a94f8e69e1 | ||
|
|
953cbc0300 | ||
|
|
8fc278207b | ||
|
|
44b171fe3d | ||
|
|
ecc6aec4bf | ||
|
|
cbea2acbb8 | ||
|
|
1943c1b750 | ||
|
|
813ddb4555 | ||
|
|
7c9d05267c | ||
|
|
3e163d29bd | ||
|
|
9859e1f7df | ||
|
|
7168845dc3 | ||
|
|
cc1765dce2 | ||
|
|
93f8da55cb | ||
|
|
d3d989c4c2 | ||
|
|
8cfc9e4e27 | ||
|
|
664b131c30 | ||
|
|
d010220588 | ||
|
|
59a735b8f3 | ||
|
|
69b97a97f7 | ||
|
|
b8a2b9b93e | ||
|
|
58e3d41582 | ||
|
|
a2172547a8 | ||
|
|
cdc123ec2f | ||
|
|
8b6d34ad85 | ||
|
|
12ad13ddca | ||
|
|
f957fe3760 | ||
|
|
de5ece9944 | ||
|
|
3d84689714 | ||
|
|
f7a6c7a6e5 | ||
|
|
8d4f7a0002 | ||
|
|
f43ff5b4bb | ||
|
|
8ceada6e30 | ||
|
|
be96f22026 | ||
|
|
2f9d88caee | ||
|
|
030822b68d | ||
|
|
a8e6c0f853 | ||
|
|
0721d2ea80 | ||
|
|
61e7db2820 | ||
|
|
26f1f6a76b | ||
|
|
c1b0f6f3c1 | ||
|
|
c3602f7f05 | ||
|
|
58ffc10c17 | ||
|
|
a01499136d | ||
|
|
7cbdcf1ba6 | ||
|
|
ca16a8e07d | ||
|
|
e59dcf46f1 | ||
|
|
e1367b44bc | ||
|
|
8e01309917 | ||
|
|
b0358cf3c8 | ||
|
|
527c68baa4 | ||
|
|
994a405f2d | ||
|
|
4ab4894f44 | ||
|
|
c83a196402 | ||
|
|
5bb933eedf | ||
|
|
f54e8706f7 | ||
|
|
40351d0923 | ||
|
|
57f8ba3b19 | ||
|
|
8df8f86784 | ||
|
|
5f4c952ab0 | ||
|
|
62bfba521d | ||
|
|
143942d497 | ||
|
|
6096f73ce8 | ||
|
|
5fccc9aae9 | ||
|
|
328e4f5a1e | ||
|
|
dc861964fc | ||
|
|
46fb10203b | ||
|
|
d21165c2f0 | ||
|
|
7d28e84e72 | ||
|
|
507d479c8c | ||
|
|
3dd9d5e692 | ||
|
|
936b407a72 | ||
|
|
390b03c455 | ||
|
|
b3ff5fc5b1 | ||
|
|
125fac7943 | ||
|
|
c428e725aa | ||
|
|
b0b7f15598 | ||
|
|
9881328651 | ||
|
|
a57bab1c84 | ||
|
|
f320f3e5d7 | ||
|
|
ff6d86ed0f | ||
|
|
f31c10a0da | ||
|
|
2c159f00e4 | ||
|
|
0bbf679e4c | ||
|
|
b416907538 | ||
|
|
38863322a4 | ||
|
|
094c883bc0 | ||
|
|
f50e1e9c51 | ||
|
|
286ec6afb3 | ||
|
|
7f9cab15d8 | ||
|
|
8ed766025b | ||
|
|
49497bcddb | ||
|
|
69e15d630e | ||
|
|
2168df37b8 | ||
|
|
3f08201bac | ||
|
|
406d7a67f0 | ||
|
|
2ebeede006 |
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
packages: ${{ steps.set-matrix.outputs.packages }}
|
||||
checks: ${{ steps.set-matrix.outputs.checks }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
@@ -22,21 +22,40 @@ jobs:
|
||||
command: npm ci --ignore-scripts
|
||||
- id: set-matrix
|
||||
run: |
|
||||
PACKAGES=$(npm query .workspace | jq -c '[.[].location]')
|
||||
if [ "$PACKAGES" = "[]" ] || [ -z "$PACKAGES" ]; then
|
||||
echo "::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently)."
|
||||
exit 1
|
||||
fi
|
||||
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
|
||||
node -e '
|
||||
const { execSync } = require("child_process");
|
||||
const pkgs = JSON.parse(execSync("npm query .workspace", { encoding: "utf-8" }));
|
||||
if (pkgs.length === 0) {
|
||||
console.error("::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently).");
|
||||
process.exit(1);
|
||||
}
|
||||
const checks = [];
|
||||
for (const pkg of pkgs) {
|
||||
const scripts = pkg.scripts || {};
|
||||
const subs = Object.keys(scripts).filter(s => /^check:.+$/.test(s));
|
||||
if (subs.length > 0) {
|
||||
for (const script of subs) {
|
||||
checks.push({ package: pkg.location, script });
|
||||
}
|
||||
} else if (scripts.check) {
|
||||
checks.push({ package: pkg.location, script: "check" });
|
||||
}
|
||||
}
|
||||
if (checks.length === 0) {
|
||||
console.error("::error::No check scripts found in any workspace package.");
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("checks=" + JSON.stringify(checks) + "\n");
|
||||
' >> "$GITHUB_OUTPUT"
|
||||
|
||||
check:
|
||||
name: Typecheck & Test
|
||||
name: ${{ matrix.package }} / ${{ matrix.script }}
|
||||
needs: workspaces
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
|
||||
include: ${{ fromJson(needs.workspaces.outputs.checks) }}
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -47,5 +66,4 @@ jobs:
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
- run: npm run --prefix ${{ matrix.package }} check
|
||||
- run: npm run --prefix ${{ matrix.package }} fix
|
||||
- run: npm run --prefix ${{ matrix.package }} ${{ matrix.script }}
|
||||
|
||||
@@ -1936,8 +1936,8 @@ class HermesACPAgent(acp.Agent):
|
||||
return "No tools available."
|
||||
lines = [f"Available tools ({len(tools)}):"]
|
||||
for t in tools:
|
||||
name = t.get("function", {}).get("name", "?")
|
||||
desc = t.get("function", {}).get("description", "")
|
||||
name = (t.get("function") or {}).get("name", "?")
|
||||
desc = (t.get("function") or {}).get("description", "")
|
||||
# Truncate long descriptions
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
|
||||
@@ -480,6 +480,7 @@ def init_agent(
|
||||
checkpoint_max_total_size_mb: int = 500,
|
||||
checkpoint_max_file_size_mb: int = 10,
|
||||
pass_session_id: bool = False,
|
||||
requested_provider: str = None,
|
||||
):
|
||||
"""
|
||||
Initialize the AI Agent.
|
||||
@@ -488,6 +489,7 @@ def init_agent(
|
||||
base_url (str): Base URL for the model API (optional)
|
||||
api_key (str): API key for authentication (optional, uses env var if not provided)
|
||||
provider (str): Provider identifier (optional; used for telemetry/routing hints)
|
||||
requested_provider (str): Original provider identity before runtime canonicalization
|
||||
api_mode (str): API mode override: "chat_completions" or "codex_responses"
|
||||
model (str): Model name to use (default: "anthropic/claude-opus-4.6")
|
||||
max_iterations (int): Maximum number of tool calling iterations (default: 90)
|
||||
@@ -568,6 +570,11 @@ def init_agent(
|
||||
agent.base_url = base_url or ""
|
||||
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
|
||||
agent.provider = provider_name or ""
|
||||
agent.requested_provider = (
|
||||
requested_provider.strip().lower()
|
||||
if isinstance(requested_provider, str) and requested_provider.strip()
|
||||
else agent.provider
|
||||
)
|
||||
agent._credential_pool = credential_pool
|
||||
agent.acp_command = acp_command or command
|
||||
agent.acp_args = list(acp_args or args or [])
|
||||
@@ -1803,6 +1810,28 @@ def init_agent(
|
||||
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
|
||||
compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20))
|
||||
compression_protect_last = int(_compression_cfg.get("protect_last_n", 20))
|
||||
# Minimum REAL (actionable) user messages guaranteed to survive in the
|
||||
# uncompressed tail (compression.min_tail_user_messages). Default 1
|
||||
# preserves current behavior exactly — the existing single-user tail
|
||||
# anchor. Values > 1 extend the guarantee to the last N actionable
|
||||
# user turns. Booleans rejected (bool subclasses int), non-int-like
|
||||
# values fall back to 1, floor at 1.
|
||||
_raw_min_tail_users = _compression_cfg.get("min_tail_user_messages", 1)
|
||||
if isinstance(_raw_min_tail_users, bool):
|
||||
compression_min_tail_users = 1
|
||||
elif isinstance(_raw_min_tail_users, int):
|
||||
compression_min_tail_users = _raw_min_tail_users
|
||||
elif isinstance(_raw_min_tail_users, float):
|
||||
compression_min_tail_users = (
|
||||
int(_raw_min_tail_users) if _raw_min_tail_users.is_integer() else 1
|
||||
)
|
||||
else:
|
||||
try:
|
||||
compression_min_tail_users = int(str(_raw_min_tail_users).strip())
|
||||
except (TypeError, ValueError):
|
||||
compression_min_tail_users = 1
|
||||
if compression_min_tail_users < 1:
|
||||
compression_min_tail_users = 1
|
||||
# Cap on compression retry rounds before a turn gives up with "max
|
||||
# compression attempts reached" (compression.max_attempts). Hardcoding 3
|
||||
# strands sessions that legitimately need more rounds — e.g. a restart
|
||||
@@ -1831,6 +1860,39 @@ def init_agent(
|
||||
if compression_max_attempts < 1:
|
||||
compression_max_attempts = 3
|
||||
compression_max_attempts = min(compression_max_attempts, 10)
|
||||
|
||||
def _parse_prune_int(raw, default):
|
||||
# Same parser semantics as compression.max_attempts above: reject
|
||||
# booleans (bool subclasses int — YAML `true` would coerce to 1),
|
||||
# reject fractional floats rather than truncating them, accept
|
||||
# integral floats and numeric strings, fall back to the default on
|
||||
# anything else.
|
||||
if isinstance(raw, bool):
|
||||
return default
|
||||
if isinstance(raw, int):
|
||||
return raw
|
||||
if isinstance(raw, float):
|
||||
return int(raw) if raw.is_integer() else default
|
||||
try:
|
||||
return int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
# Opt-in proactive tool-result prune trigger (0 = disabled — the
|
||||
# default, so an unset key is behavior-neutral). Negative values are
|
||||
# treated as disabled rather than erroring.
|
||||
compression_proactive_prune_tokens = max(
|
||||
0, _parse_prune_int(_compression_cfg.get("proactive_prune_tokens", 0), 0)
|
||||
)
|
||||
compression_proactive_prune_min_chars = _parse_prune_int(
|
||||
_compression_cfg.get("proactive_prune_min_result_chars", 8000), 8000
|
||||
)
|
||||
compression_proactive_prune_min_reclaim = max(
|
||||
0,
|
||||
_parse_prune_int(
|
||||
_compression_cfg.get("proactive_prune_min_reclaim_tokens", 4096), 4096
|
||||
),
|
||||
)
|
||||
# protect_first_n is the number of non-system messages to protect at
|
||||
# the head, in addition to the system prompt (which is always
|
||||
# implicitly protected by the compressor). Floor at 0 — a value of
|
||||
@@ -2305,6 +2367,10 @@ def init_agent(
|
||||
max_tokens=agent.max_tokens,
|
||||
model_thresholds=compression_model_thresholds,
|
||||
threshold_tokens_cap=compression_threshold_tokens,
|
||||
proactive_prune_tokens=compression_proactive_prune_tokens,
|
||||
proactive_prune_min_result_chars=compression_proactive_prune_min_chars,
|
||||
proactive_prune_min_reclaim_tokens=compression_proactive_prune_min_reclaim,
|
||||
min_tail_user_messages=compression_min_tail_users,
|
||||
)
|
||||
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
|
||||
if callable(_bind_session_state):
|
||||
@@ -2569,6 +2635,7 @@ def init_agent(
|
||||
agent._primary_runtime = {
|
||||
"model": agent.model,
|
||||
"provider": agent.provider,
|
||||
"requested_provider": agent.requested_provider,
|
||||
"base_url": agent.base_url,
|
||||
"api_mode": agent.api_mode,
|
||||
"api_key": getattr(agent, "api_key", ""),
|
||||
|
||||
@@ -922,12 +922,17 @@ def recover_with_credential_pool(
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
# Capture the current API key before any rotation — needed to
|
||||
# identify which credential actually failed when
|
||||
# mark_exhausted_and_rotate is called. Without this hint the
|
||||
# pool falls back to current() or _select_unlocked(), which may
|
||||
# return the NEXT (healthy) entry after a prior rotation, marking
|
||||
# the wrong credential as exhausted (#43747).
|
||||
# Attribute the failure to the API key the agent actually dispatched the
|
||||
# request with, not to pool.current(). The current() pointer is shared,
|
||||
# mutable state — round-robin select() advances it on every call, and
|
||||
# concurrent turns or a second process (gateway/dashboard) reloading the
|
||||
# pool reset it to None — so by the time recovery runs it routinely points
|
||||
# at a DIFFERENT, healthy entry. Marking that entry exhausted copies this
|
||||
# request's error/reset time onto it and can take the whole pool offline
|
||||
# from a single rate-limited key (#43747). ``_swap_credential`` keeps
|
||||
# ``agent.api_key`` in sync with the entry in use, so it identifies the
|
||||
# failing entry exactly; fall back to current()'s key only when the agent
|
||||
# carries no key at all.
|
||||
_api_key_hint = getattr(agent, "api_key", None) or None
|
||||
if not _api_key_hint:
|
||||
_cur = pool.current()
|
||||
@@ -987,7 +992,16 @@ def recover_with_credential_pool(
|
||||
# rotate immediately. This prevents the "cancel-between-429s" trap
|
||||
# where has_retried_429 (a local var) gets reset on each new prompt,
|
||||
# causing the pool to retry the same exhausted credential forever.
|
||||
current_entry = pool.current()
|
||||
# Prefer the entry matching the failing key over the shared current()
|
||||
# pointer, for the same attribution reason as above.
|
||||
current_entry = None
|
||||
if _api_key_hint:
|
||||
current_entry = next(
|
||||
(e for e in pool.entries() if e.runtime_api_key == _api_key_hint),
|
||||
None,
|
||||
)
|
||||
if current_entry is None:
|
||||
current_entry = pool.current()
|
||||
current_last_status = getattr(current_entry, "last_status", None) if current_entry else None
|
||||
if current_last_status == STATUS_EXHAUSTED:
|
||||
_ra().logger.info(
|
||||
@@ -995,7 +1009,11 @@ def recover_with_credential_pool(
|
||||
current_last_status,
|
||||
)
|
||||
rotate_status = status_code if status_code is not None else 429
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
|
||||
next_entry = pool.mark_exhausted_and_rotate(
|
||||
status_code=rotate_status,
|
||||
error_context=error_context,
|
||||
api_key_hint=_api_key_hint,
|
||||
)
|
||||
if next_entry is not None:
|
||||
_ra().logger.info(
|
||||
"Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s",
|
||||
@@ -1019,7 +1037,11 @@ def recover_with_credential_pool(
|
||||
if not has_retried_429 and not usage_limit_reached:
|
||||
return False, True
|
||||
rotate_status = status_code if status_code is not None else 429
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
|
||||
next_entry = pool.mark_exhausted_and_rotate(
|
||||
status_code=rotate_status,
|
||||
error_context=error_context,
|
||||
api_key_hint=_api_key_hint,
|
||||
)
|
||||
if next_entry is not None:
|
||||
_ra().logger.info(
|
||||
"Credential %s (rate limit) — rotated to pool entry %s",
|
||||
@@ -1034,7 +1056,7 @@ def recover_with_credential_pool(
|
||||
# Subscription/entitlement 403s look like auth failures on the wire
|
||||
# but refresh cannot fix them — the OAuth token is already valid,
|
||||
# the account simply lacks the entitlement. Without this guard,
|
||||
# ``try_refresh_current()`` keeps minting fresh tokens against the
|
||||
# the refresh path keeps minting fresh tokens against the
|
||||
# same unsubscribed account and the main agent loop spins re-issuing
|
||||
# the same 403 until the user Ctrl+C's.
|
||||
#
|
||||
@@ -1087,9 +1109,13 @@ def recover_with_credential_pool(
|
||||
agent.provider or "provider",
|
||||
)
|
||||
return False, has_retried_429
|
||||
refreshed = pool.try_refresh_current()
|
||||
# Refresh the entry that supplied the failing key, not current():
|
||||
# the shared pointer can reference a different, healthy entry, and
|
||||
# refreshing it would consume that entry's single-use refresh token
|
||||
# (or mark it exhausted on failure) for a failure it never had.
|
||||
refreshed = pool.try_refresh_matching(api_key_hint=_api_key_hint)
|
||||
if refreshed is not None:
|
||||
# ``try_refresh_current()`` re-mints a fresh OAuth token and reports
|
||||
# ``try_refresh_matching()`` re-mints a fresh OAuth token and reports
|
||||
# success even when the upstream keeps rejecting it — a single-entry
|
||||
# pool (common for OAuth/Max subscribers) has nothing to rotate to,
|
||||
# so a bare "refreshed → retry" loop spins forever on the same dead
|
||||
@@ -1117,9 +1143,13 @@ def recover_with_credential_pool(
|
||||
agent._swap_credential(refreshed)
|
||||
return True, has_retried_429
|
||||
# Refresh failed — rotate to next credential instead of giving up.
|
||||
# The failed entry is already marked exhausted by try_refresh_current().
|
||||
# The failed entry is already marked exhausted by the refresh attempt.
|
||||
rotate_status = status_code if status_code is not None else 401
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
|
||||
next_entry = pool.mark_exhausted_and_rotate(
|
||||
status_code=rotate_status,
|
||||
error_context=error_context,
|
||||
api_key_hint=_api_key_hint,
|
||||
)
|
||||
if next_entry is not None:
|
||||
_ra().logger.info(
|
||||
"Credential %s (auth refresh failed) — rotated to pool entry %s",
|
||||
@@ -1178,6 +1208,7 @@ def try_recover_primary_transport(
|
||||
agent._client_kwargs = dict(rt["client_kwargs"])
|
||||
agent.model = rt["model"]
|
||||
agent.provider = rt["provider"]
|
||||
agent.requested_provider = rt.get("requested_provider", agent.provider)
|
||||
agent.base_url = rt["base_url"]
|
||||
agent.api_mode = rt["api_mode"]
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
@@ -1341,6 +1372,7 @@ def restore_primary_runtime(agent) -> bool:
|
||||
# ── Core runtime state ──
|
||||
agent.model = rt["model"]
|
||||
agent.provider = rt["provider"]
|
||||
agent.requested_provider = rt.get("requested_provider", agent.provider)
|
||||
agent.base_url = rt["base_url"] # setter updates _base_url_lower
|
||||
agent.api_mode = rt["api_mode"]
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
@@ -2005,6 +2037,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
for name in (
|
||||
"model",
|
||||
"provider",
|
||||
"requested_provider",
|
||||
"base_url",
|
||||
"api_mode",
|
||||
"api_key",
|
||||
@@ -2033,6 +2066,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
# ── Swap core runtime fields ──
|
||||
agent.model = new_model
|
||||
agent.provider = new_provider
|
||||
agent.requested_provider = new_provider
|
||||
# Use the new base_url when provided. When it's empty AND the
|
||||
# provider is actually changing, do NOT fall back to the current
|
||||
# (old provider's) URL — that silently pairs the new provider label
|
||||
@@ -2282,6 +2316,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
agent._primary_runtime = {
|
||||
"model": agent.model,
|
||||
"provider": agent.provider,
|
||||
"requested_provider": agent.requested_provider,
|
||||
"base_url": agent.base_url,
|
||||
"api_mode": agent.api_mode,
|
||||
"api_key": getattr(agent, "api_key", ""),
|
||||
|
||||
@@ -1881,6 +1881,28 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
_EMPTY_TEXT_PLACEHOLDER = "(empty)"
|
||||
|
||||
|
||||
def _safe_text(text: Any) -> str:
|
||||
"""Return ``text`` if it's non-whitespace, else a non-whitespace placeholder.
|
||||
|
||||
The Anthropic Messages API rejects requests where a text content block is
|
||||
empty or whitespace-only (HTTP 400 "text content blocks must contain
|
||||
non-whitespace text"). When such a block gets stored in session history —
|
||||
e.g. produced by context compression — it is replayed verbatim on every
|
||||
subsequent turn, permanently wedging the session. Coercing to a
|
||||
non-whitespace placeholder is self-healing: the next API call recovers.
|
||||
|
||||
Mirrors ``bedrock_adapter._safe_text`` (#9486); ref #69512.
|
||||
"""
|
||||
if text is None:
|
||||
return _EMPTY_TEXT_PLACEHOLDER
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER
|
||||
|
||||
|
||||
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Strip output-only fields from a stored Anthropic content block so it is
|
||||
valid as REQUEST input on replay.
|
||||
@@ -1898,7 +1920,10 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
btype = b.get("type")
|
||||
if btype == "text":
|
||||
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
|
||||
# Coerce empty/whitespace-only text to a non-whitespace placeholder;
|
||||
# the Messages input schema rejects blank text blocks (#69512), and a
|
||||
# blank block stored in history replays on every turn → permanent 400.
|
||||
out: Dict[str, Any] = {"type": "text", "text": _safe_text(b.get("text", ""))}
|
||||
# citations is input-valid ONLY when it's a non-empty list; the SDK
|
||||
# emits citations=None on responses, which the input schema rejects.
|
||||
cits = b.get("citations")
|
||||
@@ -2058,7 +2083,16 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Anthropic rejects empty assistant content
|
||||
effective = blocks or content
|
||||
if not effective or effective == "":
|
||||
effective = [{"type": "text", "text": "(empty)"}]
|
||||
effective = [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]
|
||||
elif isinstance(effective, list):
|
||||
# The all-empty guard above misses a list that still contains a
|
||||
# whitespace-only text block (e.g. from a content array of blank parts,
|
||||
# or compression). Those also trip "text content blocks must contain
|
||||
# non-whitespace text" (#69512). Coerce text blocks in place; other
|
||||
# block types (thinking/tool_use/image) are left untouched.
|
||||
for blk in effective:
|
||||
if isinstance(blk, dict) and blk.get("type") == "text":
|
||||
blk["text"] = _safe_text(blk.get("text", ""))
|
||||
return {"role": "assistant", "content": effective}
|
||||
|
||||
|
||||
|
||||
+225
-38
@@ -2303,6 +2303,62 @@ def _read_main_base_url() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_moa_aggregator(preset_name: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Resolve a MoA preset to its aggregator (provider, model) pair.
|
||||
|
||||
"moa" is a virtual provider — the acting model of a preset is its
|
||||
aggregator slot, and there is no real "moa" HTTP endpoint. Auxiliary
|
||||
tasks (title generation, compression, vision, commit messages, …) don't
|
||||
need the reference fan-out, so every aux resolution layer maps
|
||||
provider="moa"/model=<preset> to the aggregator's real provider+model
|
||||
through this single helper (shared by ``_resolve_auto``,
|
||||
``_resolve_task_provider_model``, and ``resolve_provider_client`` so the
|
||||
preset lookup and validation cannot drift between paths).
|
||||
|
||||
Args:
|
||||
preset_name: The MoA preset name (usually carried in the "model"
|
||||
field), or None/"" to resolve the user's default preset.
|
||||
|
||||
Returns:
|
||||
(aggregator_provider, aggregator_model), or (None, None) when the
|
||||
preset cannot be resolved (missing config, renamed/deleted preset,
|
||||
or a malformed aggregator slot).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.moa_config import resolve_moa_preset
|
||||
|
||||
preset = resolve_moa_preset(load_config().get("moa") or {}, preset_name or None)
|
||||
agg = preset.get("aggregator") or {}
|
||||
agg_provider = str(agg.get("provider") or "").strip()
|
||||
agg_model = str(agg.get("model") or "").strip()
|
||||
if agg_provider and agg_model and agg_provider.lower() != "moa":
|
||||
return agg_provider, agg_model
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MoA aggregator resolution failed for preset %r", preset_name, exc_info=True
|
||||
)
|
||||
return None, None
|
||||
|
||||
|
||||
def _read_main_model_for_aux() -> str:
|
||||
"""Main model with MoA presets unwrapped to the aggregator's model.
|
||||
|
||||
When the main provider is ``moa``, ``_read_main_model()`` returns a MoA
|
||||
*preset name* (e.g. "opus-gpt") — never a valid wire model id on any
|
||||
provider. Auxiliary fallback chains that pre-fill a missing model from
|
||||
the main model must use this reader instead, so unset aux models default
|
||||
to the preset's acting (aggregator) model. Returns "" when the main
|
||||
provider is moa but the preset cannot be resolved — sending nothing is
|
||||
strictly better than sending a preset name that 400s.
|
||||
"""
|
||||
model = _read_main_model()
|
||||
if (_read_main_provider() or "").strip().lower() == "moa":
|
||||
_, agg_model = _resolve_moa_aggregator(model)
|
||||
return agg_model or ""
|
||||
return model
|
||||
|
||||
|
||||
def _read_main_api_key_if_same_host(aux_base_url: str) -> str:
|
||||
"""Return the main api_key only when *aux_base_url* points at the same
|
||||
host as the main model's base_url.
|
||||
@@ -2378,6 +2434,7 @@ def set_runtime_main(
|
||||
provider: str,
|
||||
model: str,
|
||||
*,
|
||||
requested_provider: str = "",
|
||||
base_url: str = "",
|
||||
api_key: Any = "",
|
||||
api_mode: str = "",
|
||||
@@ -2393,6 +2450,7 @@ def set_runtime_main(
|
||||
global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT
|
||||
runtime = {
|
||||
"provider": (provider or "").strip().lower(),
|
||||
"requested_provider": (requested_provider or "").strip().lower(),
|
||||
"model": (model or "").strip(),
|
||||
"base_url": (base_url or "").strip(),
|
||||
"api_key": (
|
||||
@@ -2573,7 +2631,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
return None, None
|
||||
if custom_base.lower().startswith(_CODEX_AUX_BASE_URL.lower()):
|
||||
return None, None
|
||||
model = _read_main_model() or "gpt-4o-mini"
|
||||
model = _read_main_model_for_aux() or "gpt-4o-mini"
|
||||
logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions")
|
||||
_clean_base, _dq = _extract_url_query_params(custom_base)
|
||||
_extra = {"default_query": _dq} if _dq else {}
|
||||
@@ -2865,6 +2923,7 @@ _AUTO_PROVIDER_LABELS = {
|
||||
}
|
||||
|
||||
_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode")
|
||||
_MAIN_RUNTIME_CONTEXT_FIELDS = _MAIN_RUNTIME_FIELDS + ("requested_provider",)
|
||||
|
||||
|
||||
def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
@@ -2887,7 +2946,7 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,
|
||||
if not isinstance(main_runtime, dict):
|
||||
return {}
|
||||
normalized: Dict[str, Any] = {}
|
||||
for field in _MAIN_RUNTIME_FIELDS:
|
||||
for field in _MAIN_RUNTIME_CONTEXT_FIELDS:
|
||||
value = main_runtime.get(field)
|
||||
# Preserve a callable api_key (Entra ID bearer provider) unchanged.
|
||||
if field == "api_key" and callable(value) and not isinstance(value, str):
|
||||
@@ -2895,9 +2954,10 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,
|
||||
continue
|
||||
if isinstance(value, str) and value.strip():
|
||||
normalized[field] = value.strip()
|
||||
provider = normalized.get("provider")
|
||||
if isinstance(provider, str):
|
||||
normalized["provider"] = provider.lower()
|
||||
for identity_field in ("provider", "requested_provider"):
|
||||
identity = normalized.get(identity_field)
|
||||
if isinstance(identity, str):
|
||||
normalized[identity_field] = identity.lower()
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -3623,6 +3683,7 @@ def _retry_same_provider_sync(
|
||||
extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
task=task,
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
@@ -3682,6 +3743,7 @@ async def _retry_same_provider_async(
|
||||
extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=retry_base or resolved_base_url,
|
||||
task=task,
|
||||
)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
@@ -3757,6 +3819,24 @@ def _refresh_provider_credentials(provider: str) -> bool:
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
if normalized == "vertex":
|
||||
# Mirrors run_agent.py's _try_refresh_vertex_client_credentials
|
||||
# for the main conversation loop. Without this branch, an
|
||||
# auxiliary Vertex client (vision, title generation, reflection,
|
||||
# context compression, ...) that 401s on its ~1h token expiry
|
||||
# falls through to the final `return False` below: the stale
|
||||
# client is never evicted from _client_cache (whose cache key
|
||||
# ignores the rotating bearer token), so every subsequent
|
||||
# auxiliary Vertex call keeps 401ing until process restart.
|
||||
from agent.vertex_adapter import get_vertex_config
|
||||
|
||||
token, base_url = get_vertex_config()
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
return False
|
||||
if not isinstance(base_url, str) or not base_url.strip():
|
||||
return False
|
||||
_evict_cached_clients(normalized)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc)
|
||||
return False
|
||||
@@ -3870,7 +3950,7 @@ def _call_fallback_candidate_sync(
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body, reasoning_config=reasoning_config,
|
||||
base_url=fb_base)
|
||||
base_url=fb_base, task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
@@ -3887,7 +3967,7 @@ def _call_fallback_candidate_sync(
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
@@ -3936,7 +4016,7 @@ async def _call_fallback_candidate_async(
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body, reasoning_config=reasoning_config,
|
||||
base_url=fb_base)
|
||||
base_url=fb_base, task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
@@ -3954,7 +4034,7 @@ async def _call_fallback_candidate_async(
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
|
||||
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
@@ -4041,6 +4121,13 @@ def _try_main_agent_model_fallback(
|
||||
"""
|
||||
main_provider = (_read_main_provider() or "").strip()
|
||||
main_model = (_read_main_model() or "").strip()
|
||||
if main_provider.lower() == "moa":
|
||||
# MoA virtual provider: fall back to the preset's aggregator — the
|
||||
# acting model — instead of the unreachable "moa"/<preset-name> pair.
|
||||
_agg_provider, _agg_model = _resolve_moa_aggregator(main_model)
|
||||
if not _agg_provider or not _agg_model:
|
||||
return None, None, ""
|
||||
main_provider, main_model = _agg_provider, _agg_model
|
||||
if not main_provider or not main_model or main_provider.lower() in {"auto", ""}:
|
||||
return None, None, ""
|
||||
|
||||
@@ -4441,26 +4528,17 @@ def _resolve_auto(
|
||||
# model. Resolve the MoA preset to its aggregator slot and continue Step 1
|
||||
# with that real provider+model. Mirrors the MoA context-length resolution.
|
||||
if main_provider == "moa":
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.moa_config import resolve_moa_preset
|
||||
|
||||
_preset = resolve_moa_preset(load_config().get("moa") or {}, main_model)
|
||||
_agg = _preset.get("aggregator") or {}
|
||||
_agg_provider = str(_agg.get("provider") or "").strip()
|
||||
_agg_model = str(_agg.get("model") or "").strip()
|
||||
if _agg_provider and _agg_model and _agg_provider.lower() != "moa":
|
||||
main_provider = _agg_provider
|
||||
main_model = _agg_model
|
||||
# The MoA virtual runtime carries a non-HTTP base_url
|
||||
# ("moa://local") and a placeholder api_key; they belong to the
|
||||
# facade, not the aggregator's real provider. Drop them so the
|
||||
# aggregator resolves through its own provider credentials.
|
||||
runtime_base_url = ""
|
||||
runtime_api_key = ""
|
||||
runtime_api_mode = ""
|
||||
except Exception:
|
||||
logger.debug("MoA aux resolution to aggregator failed", exc_info=True)
|
||||
_agg_provider, _agg_model = _resolve_moa_aggregator(main_model)
|
||||
if _agg_provider and _agg_model:
|
||||
main_provider = _agg_provider
|
||||
main_model = _agg_model
|
||||
# The MoA virtual runtime carries a non-HTTP base_url
|
||||
# ("moa://local") and a placeholder api_key; they belong to the
|
||||
# facade, not the aggregator's real provider. Drop them so the
|
||||
# aggregator resolves through its own provider credentials.
|
||||
runtime_base_url = ""
|
||||
runtime_api_key = ""
|
||||
runtime_api_mode = ""
|
||||
|
||||
if (main_provider and main_model
|
||||
and main_provider not in {"auto", ""}):
|
||||
@@ -4715,6 +4793,27 @@ def resolve_provider_client(
|
||||
# Normalise aliases
|
||||
provider = _normalize_aux_provider(provider)
|
||||
|
||||
# MoA virtual provider chokepoint: "moa" is not a real HTTP provider —
|
||||
# its acting model is the preset's aggregator slot. The two resolver
|
||||
# layers above (_resolve_auto, _resolve_task_provider_model) already
|
||||
# unwrap their own paths, but callers that route here directly (vision
|
||||
# auto-detect, _try_main_agent_model_fallback, get_available_vision_backends,
|
||||
# plugin code) would otherwise dead-end in the unknown-provider branch.
|
||||
# ``model`` carries the preset name for moa calls; when the preset can't
|
||||
# be resolved we leave the call untouched and let the normal
|
||||
# missing-provider handling produce its diagnostic.
|
||||
if provider == "moa":
|
||||
_agg_provider, _agg_model = _resolve_moa_aggregator(model)
|
||||
if _agg_provider and _agg_model:
|
||||
original_provider = _agg_provider.strip().lower()
|
||||
provider = _normalize_aux_provider(_agg_provider)
|
||||
model = _agg_model
|
||||
# The moa:// facade endpoint and placeholder key belong to the
|
||||
# virtual runtime, not the aggregator's real provider.
|
||||
if explicit_base_url and str(explicit_base_url).lower().startswith("moa://"):
|
||||
explicit_base_url = None
|
||||
explicit_api_key = None
|
||||
|
||||
# Universal model-resolution fallback for concrete providers. ``auto`` is
|
||||
# intentionally excluded: `_resolve_auto(main_runtime=...)` returns the
|
||||
# model paired with the provider it actually selected. Pre-filling an auto
|
||||
@@ -4735,6 +4834,10 @@ def resolve_provider_client(
|
||||
# the load-bearing step for OAuth providers: an xai-oauth user
|
||||
# with grok-4.3 configured gets grok-4.3 for title generation
|
||||
# instead of silently dropping to whatever Step-2 fallback (#31845).
|
||||
# When the main provider is MoA, ``_read_main_model_for_aux()``
|
||||
# substitutes the preset's aggregator model — the preset NAME is
|
||||
# never a valid wire model id, so unset aux models default to the
|
||||
# preset's acting model instead.
|
||||
#
|
||||
# Each provider branch below sees a non-empty ``model`` whenever the
|
||||
# user has *anything* configured — no provider-specific empty-model
|
||||
@@ -4751,7 +4854,7 @@ def resolve_provider_client(
|
||||
# return the actual current runtime model when the caller did not explicitly
|
||||
# request one. (# compression-current-model)
|
||||
if not model and provider != "auto":
|
||||
model = _get_aux_model_for_provider(provider) or _read_main_model() or model
|
||||
model = _get_aux_model_for_provider(provider) or _read_main_model_for_aux() or model
|
||||
|
||||
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
|
||||
"""Decide if a plain OpenAI client should be wrapped for Responses API.
|
||||
@@ -5025,7 +5128,7 @@ def resolve_provider_client(
|
||||
model
|
||||
or custom_entry.get("model")
|
||||
or (main_runtime.get("model") if main_runtime else None)
|
||||
or _read_main_model()
|
||||
or _read_main_model_for_aux()
|
||||
or "gpt-4o-mini",
|
||||
provider,
|
||||
)
|
||||
@@ -5260,7 +5363,7 @@ def resolve_provider_client(
|
||||
final_model = _normalize_resolved_model(
|
||||
model
|
||||
or (main_runtime.get("model") if main_runtime else None)
|
||||
or _read_main_model(),
|
||||
or _read_main_model_for_aux(),
|
||||
provider,
|
||||
)
|
||||
if provider == "copilot-acp":
|
||||
@@ -5628,7 +5731,24 @@ def resolve_vision_provider_client(
|
||||
# 5. Stop
|
||||
main_provider = str(runtime.get("provider") or _read_main_provider())
|
||||
main_model = str(runtime.get("model") or _read_main_model())
|
||||
if main_provider and main_provider not in {"auto", ""}:
|
||||
if main_provider.strip().lower() == "moa":
|
||||
# MoA virtual provider: main_model is a preset NAME, and every
|
||||
# capability probe below (_PROVIDERS_WITHOUT_VISION,
|
||||
# _main_model_supports_vision, _resolve_provider_vision_default)
|
||||
# would run against a provider/model pair that doesn't exist on
|
||||
# any wire. Unwrap to the preset's aggregator slot first so the
|
||||
# checks and the eventual client target the real acting model.
|
||||
_agg_provider, _agg_model = _resolve_moa_aggregator(main_model)
|
||||
if _agg_provider and _agg_model:
|
||||
main_provider, main_model = _agg_provider, _agg_model
|
||||
# Drop the moa:// facade endpoint from the runtime view used
|
||||
# below — it belongs to the virtual provider, not the
|
||||
# aggregator's real provider.
|
||||
runtime = dict(runtime)
|
||||
runtime["base_url"] = ""
|
||||
runtime["api_key"] = ""
|
||||
runtime["api_mode"] = ""
|
||||
if main_provider and main_provider not in {"auto", "", "moa"}:
|
||||
# A provider-specific vision default wins over the user's chat model:
|
||||
# static overrides (xiaomi/zai) and catalog-backed discovery (the
|
||||
# DeepInfra profile hook) both yield a *known* vision-capable model,
|
||||
@@ -6225,8 +6345,8 @@ def _resolve_task_provider_model(
|
||||
task: str = None,
|
||||
provider: str = None,
|
||||
model: str = None,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||
"""Determine provider + model for a call.
|
||||
|
||||
@@ -6269,12 +6389,57 @@ def _resolve_task_provider_model(
|
||||
# which downstream consumers like ContextCompressor accept as the task output.
|
||||
# The provider-side 'auto' is handled in _resolve_auto() via main_runtime
|
||||
# fallback, so dropping cfg_model to None here lets that path do its job.
|
||||
#
|
||||
# The explicit `model` kwarg needs the identical normalization: MoA slots
|
||||
# (agent/moa_loop.py's _slot_runtime) forward a preset's `model:` field as
|
||||
# this explicit argument rather than through auxiliary.<task> config, so a
|
||||
# user-configured `model: auto` on a MoA reference/aggregator slot reaches
|
||||
# this function here, not as cfg_model. Only normalizing cfg_model let that
|
||||
# literal "auto" slip through via `model or cfg_model` below.
|
||||
if model and model.lower() == "auto":
|
||||
model = None
|
||||
if cfg_model and cfg_model.lower() == "auto":
|
||||
cfg_model = None
|
||||
|
||||
resolved_model = model or cfg_model
|
||||
resolved_api_mode = cfg_api_mode
|
||||
|
||||
# MoA virtual provider: an *explicit* `provider: moa` override (either the
|
||||
# caller-passed `provider` arg or `auxiliary.<task>.provider` in
|
||||
# config.yaml) reaches this function directly — it never goes through
|
||||
# _resolve_auto(), which only unwraps the *implicit* "main provider is
|
||||
# moa" case (#53827). Left as-is, "moa" is returned verbatim and
|
||||
# resolve_provider_client() looks it up in PROVIDER_REGISTRY (which has
|
||||
# no "moa" entry — it's not a real HTTP provider), falls to the
|
||||
# unknown-provider dead end, and call_llm surfaces a nonsensical
|
||||
# "MOA_API_KEY environment variable" error for a provider that was never
|
||||
# meant to be reached over the wire. Auxiliary tasks don't need the
|
||||
# reference fan-out — resolve to the preset's aggregator slot instead,
|
||||
# exactly like the implicit path does (shared helper: _resolve_moa_aggregator).
|
||||
def _unwrap_moa_provider(prov: str, mdl: Optional[str]) -> Tuple[str, Optional[str]]:
|
||||
if prov.strip().lower() != "moa":
|
||||
return prov, mdl
|
||||
agg_provider, agg_model = _resolve_moa_aggregator(mdl)
|
||||
if agg_provider and agg_model:
|
||||
return agg_provider, agg_model
|
||||
return prov, mdl
|
||||
|
||||
if provider and str(provider).strip().lower() == "moa":
|
||||
provider, resolved_model = _unwrap_moa_provider(provider, resolved_model)
|
||||
# The moa:// virtual endpoint (if any explicit base_url/api_key was
|
||||
# passed alongside provider="moa") belongs to the facade, not the
|
||||
# aggregator's real provider — drop it so the aggregator resolves
|
||||
# through its own provider credentials, mirroring _resolve_auto().
|
||||
if provider and provider.lower() != "moa":
|
||||
base_url = None
|
||||
api_key = None
|
||||
elif cfg_provider and str(cfg_provider).strip().lower() == "moa":
|
||||
cfg_provider, cfg_model = _unwrap_moa_provider(cfg_provider, resolved_model)
|
||||
if cfg_provider and cfg_provider.lower() != "moa":
|
||||
resolved_model = cfg_model
|
||||
cfg_base_url = None
|
||||
cfg_api_key = None
|
||||
|
||||
# Convenience aliases for direct API-key endpoints that aren't first-class
|
||||
# providers (e.g. ``provider: openai`` → custom + api.openai.com/v1).
|
||||
# Applied to both explicit args and config-derived values. When the user
|
||||
@@ -6631,6 +6796,7 @@ def _build_call_kwargs(
|
||||
extra_body: Optional[dict] = None,
|
||||
reasoning_config: Optional[dict] = None,
|
||||
base_url: Optional[str] = None,
|
||||
task: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Build kwargs for .chat.completions.create() with model/provider adjustments."""
|
||||
kwargs: Dict[str, Any] = {
|
||||
@@ -6685,11 +6851,32 @@ def _build_call_kwargs(
|
||||
_provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"}
|
||||
or base_url_host_matches(_effective_base, "integrate.api.nvidia.com")
|
||||
)
|
||||
_is_moa = bool(task) and str(task) == "moa_reference"
|
||||
# Gemini's native generateContent maps max_tokens → maxOutputTokens and,
|
||||
# when it is omitted, applies a fixed 65,535-token ceiling rather than
|
||||
# "the model's full budget" (see gemini_native_adapter.build_gemini_request).
|
||||
# So an explicit cap is both safe and the ONLY way to honor it here —
|
||||
# dropping max_tokens silently makes MoA's reference_max_tokens a no-op
|
||||
# for gemini advisors (they run effectively uncapped).
|
||||
_is_gemini_native = _provider_norm in {
|
||||
"gemini", "google", "google-gemini", "google-ai-studio",
|
||||
}
|
||||
if not _is_gemini_native and _effective_base:
|
||||
try:
|
||||
from agent.gemini_native_adapter import is_native_gemini_base_url
|
||||
_is_gemini_native = is_native_gemini_base_url(_effective_base)
|
||||
except Exception:
|
||||
pass
|
||||
if (
|
||||
_is_anthropic_compat_endpoint(provider, _effective_base)
|
||||
or _is_nvidia_nim
|
||||
or _is_moa
|
||||
or _is_gemini_native
|
||||
):
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
# Use auxiliary_max_tokens_param() so models that require
|
||||
# max_completion_tokens (GPT-5 family, Copilot) get the right
|
||||
# parameter name instead of a hardcoded max_tokens that 400s.
|
||||
kwargs.update(auxiliary_max_tokens_param(max_tokens, model=model))
|
||||
|
||||
if tools:
|
||||
# Defensive dedup: providers like Google Vertex, Azure, and Bedrock
|
||||
@@ -7058,7 +7245,7 @@ def call_llm(
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=_base_info or resolved_base_url)
|
||||
base_url=_base_info or resolved_base_url, task=task)
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
_client_base = str(getattr(client, "base_url", "") or "")
|
||||
@@ -7674,7 +7861,7 @@ async def async_call_llm(
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
|
||||
reasoning_config=reasoning_config,
|
||||
base_url=_client_base or resolved_base_url)
|
||||
base_url=_client_base or resolved_base_url, task=task)
|
||||
|
||||
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
|
||||
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
|
||||
|
||||
+61
-11
@@ -433,6 +433,29 @@ def _model_supports_tool_use(model_id: str) -> bool:
|
||||
return not any(pattern in model_lower for pattern in _NON_TOOL_CALLING_PATTERNS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt-cache capability detection (Converse API cachePoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claude on Bedrock already gets prompt caching through the AnthropicBedrock
|
||||
# SDK path (see is_anthropic_bedrock_model / runtime_provider.py's dual-path
|
||||
# routing) — it never reaches build_converse_kwargs unless bearer-token auth
|
||||
# forces the Converse path (#28156). This allowlist covers the Converse API
|
||||
# itself: sending an unsupported model a cachePoint block raises a
|
||||
# ValidationException, so — like _model_supports_tool_use but inverted —
|
||||
# unknown models default to NOT receiving cache markers until confirmed.
|
||||
# Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
|
||||
_CACHE_POINT_PATTERNS = [
|
||||
"anthropic.claude", # bearer-token fallback path
|
||||
"amazon.nova",
|
||||
]
|
||||
|
||||
|
||||
def _model_supports_prompt_cache(model_id: str) -> bool:
|
||||
"""Return True if the model accepts a Converse API cachePoint block."""
|
||||
model_lower = model_id.lower()
|
||||
return any(pattern in model_lower for pattern in _CACHE_POINT_PATTERNS)
|
||||
|
||||
|
||||
def is_anthropic_bedrock_model(model_id: str) -> bool:
|
||||
"""Return True if the model is an Anthropic Claude model on Bedrock.
|
||||
|
||||
@@ -764,14 +787,22 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace:
|
||||
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
|
||||
)
|
||||
|
||||
# Build usage stats
|
||||
# Build usage stats. Converse's inputTokens excludes cache read/write
|
||||
# tokens (unlike OpenAI's prompt_tokens, which includes them) — restore
|
||||
# the OpenAI-style "total includes cache" convention here so downstream
|
||||
# normalize_usage() can subtract them back out consistently, and surface
|
||||
# the Anthropic-named fields it already falls back to for cache reads.
|
||||
usage_data = response.get("usage", {})
|
||||
input_tokens = usage_data.get("inputTokens", 0)
|
||||
cache_read_tokens = usage_data.get("cacheReadInputTokens", 0)
|
||||
cache_write_tokens = usage_data.get("cacheWriteInputTokens", 0)
|
||||
output_tokens = usage_data.get("outputTokens", 0)
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=usage_data.get("inputTokens", 0),
|
||||
completion_tokens=usage_data.get("outputTokens", 0),
|
||||
total_tokens=(
|
||||
usage_data.get("inputTokens", 0) + usage_data.get("outputTokens", 0)
|
||||
),
|
||||
prompt_tokens=input_tokens + cache_read_tokens + cache_write_tokens,
|
||||
completion_tokens=output_tokens,
|
||||
total_tokens=input_tokens + cache_read_tokens + cache_write_tokens + output_tokens,
|
||||
cache_read_input_tokens=cache_read_tokens,
|
||||
cache_creation_input_tokens=cache_write_tokens,
|
||||
)
|
||||
|
||||
finish_reason = _converse_stop_reason_to_openai(stop_reason)
|
||||
@@ -936,6 +967,8 @@ def stream_converse_with_callbacks(
|
||||
usage_data = {
|
||||
"inputTokens": meta_usage.get("inputTokens", 0),
|
||||
"outputTokens": meta_usage.get("outputTokens", 0),
|
||||
"cacheReadInputTokens": meta_usage.get("cacheReadInputTokens", 0),
|
||||
"cacheWriteInputTokens": meta_usage.get("cacheWriteInputTokens", 0),
|
||||
}
|
||||
|
||||
# Flush remaining text
|
||||
@@ -949,12 +982,16 @@ def stream_converse_with_callbacks(
|
||||
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
|
||||
)
|
||||
|
||||
input_tokens = usage_data.get("inputTokens", 0)
|
||||
cache_read_tokens = usage_data.get("cacheReadInputTokens", 0)
|
||||
cache_write_tokens = usage_data.get("cacheWriteInputTokens", 0)
|
||||
output_tokens = usage_data.get("outputTokens", 0)
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=usage_data.get("inputTokens", 0),
|
||||
completion_tokens=usage_data.get("outputTokens", 0),
|
||||
total_tokens=(
|
||||
usage_data.get("inputTokens", 0) + usage_data.get("outputTokens", 0)
|
||||
),
|
||||
prompt_tokens=input_tokens + cache_read_tokens + cache_write_tokens,
|
||||
completion_tokens=output_tokens,
|
||||
total_tokens=input_tokens + cache_read_tokens + cache_write_tokens + output_tokens,
|
||||
cache_read_input_tokens=cache_read_tokens,
|
||||
cache_creation_input_tokens=cache_write_tokens,
|
||||
)
|
||||
|
||||
finish_reason = _converse_stop_reason_to_openai(stop_reason)
|
||||
@@ -993,6 +1030,7 @@ def build_converse_kwargs(
|
||||
Converts OpenAI-format inputs to Converse API parameters.
|
||||
"""
|
||||
system_prompt, converse_messages = convert_messages_to_converse(messages)
|
||||
cache_enabled = _model_supports_prompt_cache(model)
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"modelId": model,
|
||||
@@ -1003,6 +1041,8 @@ def build_converse_kwargs(
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
if cache_enabled:
|
||||
system_prompt = system_prompt + [{"cachePoint": {"type": "default"}}]
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
@@ -1026,6 +1066,8 @@ def build_converse_kwargs(
|
||||
# Strip tools for known non-tool-calling models and warn the user.
|
||||
# Ref: PR #7920 feedback from @ptlally, pattern from PR #4346.
|
||||
if _model_supports_tool_use(model):
|
||||
if cache_enabled:
|
||||
converse_tools = converse_tools + [{"cachePoint": {"type": "default"}}]
|
||||
kwargs["toolConfig"] = {"tools": converse_tools}
|
||||
else:
|
||||
logger.warning(
|
||||
@@ -1033,6 +1075,14 @@ def build_converse_kwargs(
|
||||
"The agent will operate in text-only mode.", model
|
||||
)
|
||||
|
||||
if cache_enabled and len(converse_messages) >= 2:
|
||||
# Checkpoint everything up to (not including) the newest turn, so the
|
||||
# marker survives unchanged across requests as only the tail grows —
|
||||
# mirroring the Anthropic system_and_3 strategy in prompt_caching.py.
|
||||
content = converse_messages[-2].get("content")
|
||||
if isinstance(content, list) and content:
|
||||
content.append({"cachePoint": {"type": "default"}})
|
||||
|
||||
if guardrail_config:
|
||||
kwargs["guardrailConfig"] = guardrail_config
|
||||
|
||||
|
||||
@@ -1712,6 +1712,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
agent._config_context_length = None
|
||||
agent.model = fb_model
|
||||
agent.provider = fb_provider
|
||||
agent.requested_provider = fb_provider
|
||||
agent.base_url = fb_base_url
|
||||
agent.api_mode = fb_api_mode
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
|
||||
+890
-67
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,39 @@ def sanitize_memory_context(memory_context: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def automatic_compaction_status_message(
|
||||
engine: Any,
|
||||
*,
|
||||
phase: str,
|
||||
default_message: str,
|
||||
**context: Any,
|
||||
) -> str | None:
|
||||
"""Resolve host-visible status for an automatic compaction event.
|
||||
|
||||
Engines can suppress routine automatic status with
|
||||
``emit_automatic_compaction_status = False`` or customize it by defining
|
||||
``get_automatic_compaction_status_message(...)``. Empty strings and
|
||||
``None`` mean "do not emit a lifecycle status".
|
||||
"""
|
||||
if not getattr(engine, "emit_automatic_compaction_status", True):
|
||||
return None
|
||||
|
||||
formatter = getattr(engine, "get_automatic_compaction_status_message", None)
|
||||
if callable(formatter):
|
||||
message = formatter(
|
||||
phase=phase,
|
||||
default_message=default_message,
|
||||
**context,
|
||||
)
|
||||
else:
|
||||
message = default_message
|
||||
|
||||
if message is None:
|
||||
return None
|
||||
message = str(message).strip()
|
||||
return message or None
|
||||
|
||||
|
||||
class ContextEngine(ABC):
|
||||
"""Base class all context engines must implement."""
|
||||
|
||||
@@ -89,6 +122,12 @@ class ContextEngine(ABC):
|
||||
protect_first_n: int = 3
|
||||
protect_last_n: int = 6
|
||||
|
||||
# User-visible lifecycle status for automatic host-triggered compaction.
|
||||
# Alternative engines that treat compaction as routine background
|
||||
# maintenance can set this false to keep successful automatic passes silent;
|
||||
# warnings, errors, and explicit manual commands should still surface.
|
||||
emit_automatic_compaction_status: bool = True
|
||||
|
||||
# -- Core interface ----------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
@@ -107,6 +146,19 @@ class ContextEngine(ABC):
|
||||
def should_compress(self, prompt_tokens: int = None) -> bool:
|
||||
"""Return True if compaction should fire this turn."""
|
||||
|
||||
def should_compress_info(self, prompt_tokens: int = None) -> "tuple[bool, str | None]":
|
||||
"""Return ``(should_compress, reason)``.
|
||||
|
||||
The base implementation is backward-compatible: engines that only
|
||||
implement ``should_compress`` get ``(should_compress(prompt_tokens),
|
||||
None)``. Concrete engines with richer block reasons (e.g. a
|
||||
summary-LLM cooldown or an anti-thrashing guard) override this to
|
||||
surface a human-readable reason so callers can warn the user instead
|
||||
of silently skipping compression. Added for the silent-overflow
|
||||
warning fix (#62625) so plugin engines don't raise AttributeError.
|
||||
"""
|
||||
return self.should_compress(prompt_tokens), None
|
||||
|
||||
@abstractmethod
|
||||
def compress(
|
||||
self,
|
||||
@@ -137,6 +189,27 @@ class ContextEngine(ABC):
|
||||
host filters unsupported optional arguments by signature.
|
||||
"""
|
||||
|
||||
# -- Optional: proactive tool-result prune -----------------------------
|
||||
|
||||
def prune_tool_results_only(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
current_tokens: int | None = None,
|
||||
) -> tuple[List[Dict[str, Any]], int]:
|
||||
"""Deterministically trim old tool-result payloads without an LLM call.
|
||||
|
||||
Runs on a low, cost-oriented trigger independent of ``should_compress``
|
||||
so large-window engines can reclaim re-sent tool output long before full
|
||||
compaction would fire. Returns ``(messages, n_pruned)``.
|
||||
|
||||
Default is a safe no-op: the list is returned unchanged with ``0``
|
||||
pruned. Engines that don't implement a cheap prune — and any engine that
|
||||
predates this hook — inherit this default, so the agent loop's
|
||||
post-tool-call prune path never raises ``AttributeError`` on them. The
|
||||
built-in ContextCompressor overrides this with the real implementation.
|
||||
"""
|
||||
return messages, 0
|
||||
|
||||
# -- Optional: pre-flight check ----------------------------------------
|
||||
|
||||
def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
@@ -156,6 +229,27 @@ class ContextEngine(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_automatic_compaction_status_message(
|
||||
self,
|
||||
*,
|
||||
phase: str,
|
||||
default_message: str,
|
||||
**context: Any,
|
||||
) -> str | None:
|
||||
"""Return user-visible status for automatic host-triggered compaction.
|
||||
|
||||
Return ``None`` to suppress successful automatic lifecycle status for
|
||||
this compaction event. ``phase`` identifies the host call site (for
|
||||
example ``"preflight"`` or ``"compress"``). ``context`` contains
|
||||
best-effort fields such as ``approx_tokens`` and ``threshold_tokens``.
|
||||
|
||||
This hook does not control warning/error messages or explicit manual
|
||||
commands such as ``/compress``.
|
||||
"""
|
||||
if not self.emit_automatic_compaction_status:
|
||||
return None
|
||||
return default_message
|
||||
|
||||
# -- Optional: manual /compress preflight ------------------------------
|
||||
|
||||
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
|
||||
@@ -42,7 +42,10 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from agent.context_engine import sanitize_memory_context
|
||||
from agent.context_engine import (
|
||||
automatic_compaction_status_message,
|
||||
sanitize_memory_context,
|
||||
)
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -106,6 +109,22 @@ COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE = (
|
||||
"🗜️ Context reduced to {new_ctx:,} tokens (was {old_ctx:,}), retrying..."
|
||||
)
|
||||
|
||||
# FAILURE-CLASS notice — a deliberate carve-out from routine-compression
|
||||
# silence (#16775 class): the context is over the compression threshold but
|
||||
# compression is blocked (summary-LLM cooldown / anti-thrash breaker), so the
|
||||
# session will keep growing until the hard provider token limit kills it.
|
||||
# This MUST stay visible on chat gateways. Do NOT add it to
|
||||
# ROUTINE_COMPRESSION_STATUS_SAMPLES or the gateway noise regex
|
||||
# (_TELEGRAM_NOISY_STATUS_RE); it is pinned un-swallowed in
|
||||
# tests/gateway/test_telegram_noise_filter.py::VISIBLE_COMPRESSION_MESSAGES.
|
||||
CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE = (
|
||||
"⚠ Context is over the compression threshold "
|
||||
"(~{tokens:,} tokens >= {threshold:,}) "
|
||||
"but compression is currently blocked ({reason}). "
|
||||
"The model may stop responding. Run /new to start a fresh "
|
||||
"session or /compress to retry immediately."
|
||||
)
|
||||
|
||||
# Sample-formatted instances of every routine compression status line, for
|
||||
# behavioral tests that iterate the ACTUAL emitted wording (formatted from the
|
||||
# same constants the emission sites use) through the gateway noise filter.
|
||||
@@ -188,6 +207,64 @@ def _cached_prompt_reflects_builtin_memory(agent: Any, cached_prompt: str) -> bo
|
||||
return True
|
||||
|
||||
|
||||
class CompressionCommitFence:
|
||||
"""Fence timeout cancellation against post-summary session mutation.
|
||||
|
||||
Compression itself is synchronous and may be running in an executor thread.
|
||||
A caller can stop waiting for the summary, but it cannot kill that thread.
|
||||
This fence makes the commit boundary deterministic: cancellation either wins
|
||||
before session mutation starts, or waits until an already-started commit is
|
||||
fully complete before the caller proceeds.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._cancelled = False
|
||||
self._commit_started = False
|
||||
|
||||
def cancel_before_commit(self) -> bool:
|
||||
"""Cancel a pending commit, or wait for an active commit to finish.
|
||||
|
||||
Returns ``True`` when cancellation won before the commit boundary.
|
||||
Returns ``False`` when the worker had already entered the boundary; in
|
||||
that case acquiring this lock waits until all session mutation finishes.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._commit_started:
|
||||
return False
|
||||
self._cancelled = True
|
||||
return True
|
||||
|
||||
def try_cancel_before_commit(self) -> Optional[bool]:
|
||||
"""Non-blocking form of :meth:`cancel_before_commit`.
|
||||
|
||||
Returns ``None`` while an active commit owns the fence, allowing an
|
||||
async caller to yield instead of blocking its event loop.
|
||||
"""
|
||||
if not self._lock.acquire(blocking=False):
|
||||
return None
|
||||
try:
|
||||
if self._commit_started:
|
||||
return False
|
||||
self._cancelled = True
|
||||
return True
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def begin_commit(self) -> bool:
|
||||
"""Enter the commit boundary unless cancellation already won."""
|
||||
self._lock.acquire()
|
||||
if self._cancelled:
|
||||
self._lock.release()
|
||||
return False
|
||||
self._commit_started = True
|
||||
return True
|
||||
|
||||
def finish_commit(self) -> None:
|
||||
"""Leave a commit boundary entered by :meth:`begin_commit`."""
|
||||
self._lock.release()
|
||||
|
||||
|
||||
def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool:
|
||||
"""Whether the live in-memory SessionDB class structurally predates locks.
|
||||
|
||||
@@ -216,6 +293,7 @@ def _refresh_persisted_compression_guards(compressor: Any) -> None:
|
||||
method_calls = (
|
||||
("get_active_compression_failure_cooldown", {"refresh": True}),
|
||||
("_load_fallback_compression_streak", {}),
|
||||
("_load_ineffective_compression_count", {}),
|
||||
)
|
||||
for method_name, kwargs in method_calls:
|
||||
method = getattr(type(compressor), method_name, None)
|
||||
@@ -277,6 +355,24 @@ def _emit_compression_attempt_telemetry(
|
||||
logger.debug("failed to emit compression attempt telemetry: %s", exc)
|
||||
|
||||
|
||||
def compression_skipped_due_to_lock(agent: Any) -> bool:
|
||||
"""Type-pinned read of the #69870 lock-skip signal.
|
||||
|
||||
``agent._compression_skipped_due_to_lock`` is set by ``compress_context``
|
||||
when a compression pass no-ops because another path holds the per-session
|
||||
compression lock (holder string when the holder was confirmed, ``True``
|
||||
otherwise) and cleared to ``None`` at the entry of every call.
|
||||
|
||||
The read MUST be type-pinned (``is True or isinstance(x, str)``), never
|
||||
bare truthiness: MagicMock test-double agents auto-create truthy
|
||||
attributes, and a bare ``if getattr(agent, ...)`` would hijack every
|
||||
mocked agent in sibling suites into the lock-skip branch (the
|
||||
#69870 × #69840 type-ahead incident).
|
||||
"""
|
||||
_sig = getattr(agent, "_compression_skipped_due_to_lock", None)
|
||||
return _sig is True or isinstance(_sig, str)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
|
||||
@@ -730,7 +826,11 @@ def replay_compression_warning(agent: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def conversation_history_after_compression(agent: Any, messages: list) -> Optional[list]:
|
||||
def conversation_history_after_compression(
|
||||
agent: Any,
|
||||
messages: list,
|
||||
previous_history: Optional[list] = None,
|
||||
) -> Optional[list]:
|
||||
"""Return the correct flush baseline after a compression boundary.
|
||||
|
||||
Legacy compression rotates to a fresh child session. That child has not
|
||||
@@ -747,7 +847,19 @@ def conversation_history_after_compression(agent: Any, messages: list) -> Option
|
||||
|
||||
A shallow copy is intentional: it captures the current compacted dict
|
||||
identities as history while allowing later same-turn appends to remain new.
|
||||
|
||||
An aborted or no-op attempt after an earlier in-place compaction must retain
|
||||
the pre-attempt baseline. Treating all current messages as persisted would
|
||||
drop any later, unflushed turns on restart; clearing the baseline would
|
||||
append the already-persisted compacted rows a second time.
|
||||
"""
|
||||
if bool(getattr(agent, "_last_compression_attempt_recorded", False)):
|
||||
attempt_in_place = getattr(agent, "_last_compression_attempt_in_place", None)
|
||||
if attempt_in_place is True:
|
||||
return list(messages)
|
||||
if attempt_in_place is False:
|
||||
return None
|
||||
return previous_history
|
||||
if bool(getattr(agent, "_last_compaction_in_place", False)):
|
||||
return list(messages)
|
||||
return None
|
||||
@@ -806,6 +918,37 @@ def _is_real_user_message(message: Any) -> bool:
|
||||
return not ContextCompressor._is_synthetic_compression_user_turn(message)
|
||||
|
||||
|
||||
def _strip_stale_todo_snapshot(content: Any) -> Any:
|
||||
"""Remove a previously merged todo-snapshot block from message content.
|
||||
|
||||
Snapshot merges (see the injection site in ``compress_context``) always
|
||||
append the block at the end of the trailing user turn, so a surviving
|
||||
header marks stale todo state from an earlier compaction boundary.
|
||||
Stripping before re-injection keeps repeated boundaries from
|
||||
accumulating outdated snapshots (#26981).
|
||||
"""
|
||||
from tools.todo_tool import TODO_INJECTION_HEADER
|
||||
|
||||
if isinstance(content, str):
|
||||
idx = content.find(TODO_INJECTION_HEADER)
|
||||
if idx == -1:
|
||||
return content
|
||||
return content[:idx].rstrip()
|
||||
if isinstance(content, list):
|
||||
return [
|
||||
part
|
||||
for part in content
|
||||
if not (
|
||||
isinstance(part, dict)
|
||||
and part.get("type") == "text"
|
||||
and str(part.get("text") or "")
|
||||
.lstrip()
|
||||
.startswith(TODO_INJECTION_HEADER)
|
||||
)
|
||||
]
|
||||
return content
|
||||
|
||||
|
||||
def _merge_anchor_into_user_message(target: dict, anchor: dict) -> None:
|
||||
"""Fold the human anchor into an existing user-role scaffolding turn.
|
||||
|
||||
@@ -975,6 +1118,7 @@ def compress_context(
|
||||
focus_topic: Optional[str] = None,
|
||||
force: bool = False,
|
||||
defer_context_engine_notification: bool = False,
|
||||
commit_fence: Optional[CompressionCommitFence] = None,
|
||||
) -> Tuple[list, str]:
|
||||
"""Compress conversation context and split the session in SQLite.
|
||||
|
||||
@@ -994,6 +1138,9 @@ def compress_context(
|
||||
callers use the default ``False``.
|
||||
defer_context_engine_notification: Delay the existing context-engine
|
||||
hook until a manual host commits its outer history transaction.
|
||||
commit_fence: Optional cooperative fence for executor callers that
|
||||
may time out. It prevents a late worker from mutating session state
|
||||
after its caller has moved on.
|
||||
|
||||
Returns:
|
||||
``(compressed_messages, new_system_prompt)`` tuple. When
|
||||
@@ -1008,6 +1155,21 @@ def compress_context(
|
||||
):
|
||||
raise RuntimeError("a compression notification is already pending")
|
||||
|
||||
# ``conversation_history_after_compression()`` needs the latest attempt's
|
||||
# outcome, while ``_last_compaction_in_place`` remains the run-level signal
|
||||
# read by gateway callers. ``None`` means this attempt aborted or made no
|
||||
# boundary, so the previous flush baseline remains authoritative.
|
||||
agent._last_compression_attempt_recorded = True
|
||||
agent._last_compression_attempt_in_place = None
|
||||
# Clear the lock-skip signal at the VERY TOP, before the codex route and
|
||||
# the breaker gates below can early-return (per-attempt state rule,
|
||||
# #58630/#69853). A stale ``True``/holder value from a prior lock-skip
|
||||
# must never make a later breaker/codex no-op look like lock contention
|
||||
# to the automatic-path consumers (compression_deferred, #49874) — the
|
||||
# second clear before lock acquisition below stays for the same reason
|
||||
# it was added in #69870 and is simply idempotent now.
|
||||
agent._compression_skipped_due_to_lock = None
|
||||
|
||||
_attempt_started_at = time.monotonic()
|
||||
_attempt_id = uuid.uuid4().hex
|
||||
_trigger_source = "manual" if force else "auto"
|
||||
@@ -1030,14 +1192,26 @@ def compress_context(
|
||||
# the app server does not expose its native summary prompt, so there is no
|
||||
# truthful injection point for ``on_pre_compress()`` return text here.
|
||||
if getattr(agent, "api_mode", None) == "codex_app_server":
|
||||
return _compress_context_via_codex_app_server(
|
||||
agent,
|
||||
messages,
|
||||
system_message,
|
||||
approx_tokens=approx_tokens,
|
||||
task_id=task_id,
|
||||
force=force,
|
||||
)
|
||||
_codex_fence_entered = False
|
||||
if commit_fence is not None:
|
||||
_codex_fence_entered = commit_fence.begin_commit()
|
||||
if not _codex_fence_entered:
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
return messages, existing_prompt
|
||||
try:
|
||||
return _compress_context_via_codex_app_server(
|
||||
agent,
|
||||
messages,
|
||||
system_message,
|
||||
approx_tokens=approx_tokens,
|
||||
task_id=task_id,
|
||||
force=force,
|
||||
)
|
||||
finally:
|
||||
if _codex_fence_entered:
|
||||
commit_fence.finish_commit()
|
||||
|
||||
# Every automatic entrypoint must honor compressor-owned cooldown and
|
||||
# breaker state. Gateway hygiene constructs a fresh AIAgent, so the
|
||||
@@ -1089,7 +1263,20 @@ def compress_context(
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
|
||||
focus_topic,
|
||||
)
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
_compaction_status = COMPACTION_STATUS
|
||||
if not force:
|
||||
_compaction_status = automatic_compaction_status_message(
|
||||
agent.context_compressor,
|
||||
phase="compress",
|
||||
default_message=_compaction_status,
|
||||
approx_tokens=approx_tokens,
|
||||
message_count=_pre_msg_count,
|
||||
model=agent.model,
|
||||
focus_topic=focus_topic,
|
||||
)
|
||||
_compaction_status_emitted = bool(_compaction_status)
|
||||
if _compaction_status:
|
||||
agent._emit_status(_compaction_status)
|
||||
_compaction_done_emitted = False
|
||||
|
||||
def _complete_compaction_lifecycle() -> None:
|
||||
@@ -1097,7 +1284,11 @@ def compress_context(
|
||||
if _compaction_done_emitted:
|
||||
return
|
||||
_compaction_done_emitted = True
|
||||
_emit_compaction_done(agent)
|
||||
# A suppressed start (quiet context engine) opened no visible
|
||||
# compaction phase — emit no terminal edge either. Failure warnings
|
||||
# go through agent._emit_warning and are never suppressed here.
|
||||
if _compaction_status_emitted:
|
||||
_emit_compaction_done(agent)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
@@ -1134,6 +1325,12 @@ def compress_context(
|
||||
_try_acquire_lock = None
|
||||
_lock_lookup_error: Optional[Exception] = None
|
||||
_legacy_session_db_without_lock_api = False
|
||||
# Clear any stale lock-skip signal from a prior call so this call's
|
||||
# outcome alone determines what callers see. Without this an
|
||||
# auto-compress lock-skip followed by a successful manual /compress
|
||||
# would falsely report "Compression already in progress" and discard
|
||||
# the compression results.
|
||||
agent._compression_skipped_due_to_lock = None
|
||||
if _lock_db is not None:
|
||||
try:
|
||||
_legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db(
|
||||
@@ -1221,6 +1418,11 @@ def compress_context(
|
||||
_lock_sid, existing,
|
||||
)
|
||||
_lock_holder = None # don't release a lock we don't own
|
||||
# Signal to callers that this no-op is due to a concurrent lock,
|
||||
# not a genuine "nothing to compress" or aux-model failure.
|
||||
# Manual /compress callers can surface a clear status message
|
||||
# instead of the misleading "No changes from compression" text.
|
||||
agent._compression_skipped_due_to_lock = existing or True
|
||||
# Surface to the user once — quiet for downstream auto-compress loops
|
||||
if getattr(agent, "_last_compression_lock_warning_sid", None) != _lock_sid:
|
||||
agent._last_compression_lock_warning_sid = _lock_sid
|
||||
@@ -1394,6 +1596,7 @@ def compress_context(
|
||||
if _activity_heartbeat is not None:
|
||||
_activity_heartbeat.stop("context compression completed")
|
||||
|
||||
_commit_fence_entered = False
|
||||
try:
|
||||
# Capture boundary quality before session-rotation callbacks run. Built-in
|
||||
# and plugin lifecycle hooks may reset per-session compressor fields while
|
||||
@@ -1481,6 +1684,28 @@ def compress_context(
|
||||
_release_lock()
|
||||
return messages, _existing_sp
|
||||
|
||||
if commit_fence is not None:
|
||||
_commit_fence_entered = commit_fence.begin_commit()
|
||||
if not _commit_fence_entered:
|
||||
logger.info(
|
||||
"Compression commit cancelled before session mutation "
|
||||
"(session=%s).",
|
||||
agent.session_id or "none",
|
||||
)
|
||||
agent._last_compaction_in_place = False
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status="aborted",
|
||||
split_status="aborted",
|
||||
failure_class="commit_fence_cancelled",
|
||||
)
|
||||
_release_lock()
|
||||
return messages, _existing_sp
|
||||
|
||||
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
if summary_error:
|
||||
if getattr(agent, "_last_compression_summary_warning", None) != summary_error:
|
||||
@@ -1509,11 +1734,54 @@ def compress_context(
|
||||
|
||||
todo_snapshot = agent._todo_store.format_for_injection()
|
||||
if todo_snapshot:
|
||||
compressed.append({
|
||||
"role": "user",
|
||||
"content": todo_snapshot,
|
||||
"_todo_snapshot_synthetic": True,
|
||||
})
|
||||
# Fold the snapshot into a trailing REAL user message so
|
||||
# compression never introduces a synthetic user/user pair. Any
|
||||
# snapshot merged at an earlier boundary is stripped first so
|
||||
# repeated compactions refresh rather than accumulate todo state
|
||||
# (#26981). Scaffolding tails (continuation marker, summary
|
||||
# handoff, a bare stale snapshot row) must never absorb the
|
||||
# snapshot: merging would upgrade them to "real user" evidence
|
||||
# and break zero-user provenance (#69292), so those keep the
|
||||
# flagged standalone append and the real-user preservation pass
|
||||
# continues to see todo scaffolding, not human intent.
|
||||
from agent.context_compressor import _append_text_to_content
|
||||
|
||||
merged = False
|
||||
_tail = (
|
||||
compressed[-1]
|
||||
if compressed and isinstance(compressed[-1], dict)
|
||||
else None
|
||||
)
|
||||
if _tail is not None and _tail.get("role") == "user":
|
||||
_stripped = _strip_stale_todo_snapshot(_tail.get("content"))
|
||||
_probe = {
|
||||
key: value for key, value in _tail.items() if key != "content"
|
||||
}
|
||||
_probe["content"] = _stripped
|
||||
if _is_real_user_message(_probe):
|
||||
_snapshot_text = (
|
||||
f"\n\n{todo_snapshot}"
|
||||
if isinstance(_stripped, str) and _stripped
|
||||
else todo_snapshot
|
||||
)
|
||||
_tail["content"] = _append_text_to_content(
|
||||
_stripped, _snapshot_text
|
||||
)
|
||||
merged = True
|
||||
elif _stripped != _tail.get("content") and not _message_text(
|
||||
{"role": "user", "content": _stripped}
|
||||
).strip():
|
||||
# The tail was nothing but an earlier snapshot row —
|
||||
# refresh it in place instead of stacking a duplicate.
|
||||
_tail["content"] = todo_snapshot
|
||||
_tail["_todo_snapshot_synthetic"] = True
|
||||
merged = True
|
||||
if not merged:
|
||||
compressed.append({
|
||||
"role": "user",
|
||||
"content": todo_snapshot,
|
||||
"_todo_snapshot_synthetic": True,
|
||||
})
|
||||
_ensure_compressed_has_user_turn(messages, compressed)
|
||||
|
||||
cached_system_prompt = agent._cached_system_prompt
|
||||
@@ -1823,6 +2091,7 @@ def compress_context(
|
||||
# via a rotation-independent flag. The gateway uses this — NOT an
|
||||
# id-change diff — to re-baseline transcript handling (history_offset=0 +
|
||||
# rewrite on the same id) when compaction happened in place. See #38763.
|
||||
agent._last_compression_attempt_in_place = compacted_in_place
|
||||
agent._last_compaction_in_place = compacted_in_place
|
||||
|
||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||
@@ -1888,7 +2157,11 @@ def compress_context(
|
||||
# file dedup) ran. A concurrent path that wakes up the moment we
|
||||
# release will see the NEW session_id in state.db / SessionEntry and
|
||||
# acquire on that — no race against our just-finished work.
|
||||
_release_lock()
|
||||
try:
|
||||
_release_lock()
|
||||
finally:
|
||||
if _commit_fence_entered:
|
||||
commit_fence.finish_commit()
|
||||
|
||||
|
||||
def _compress_context_via_codex_app_server(
|
||||
|
||||
+292
-38
@@ -34,8 +34,10 @@ from agent.conversation_compression import (
|
||||
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE,
|
||||
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE,
|
||||
PRE_API_COMPRESSION_STATUS_TEMPLATE,
|
||||
compression_skipped_due_to_lock,
|
||||
conversation_history_after_compression,
|
||||
)
|
||||
from agent.context_engine import automatic_compaction_status_message
|
||||
from agent.display import KawaiiSpinner
|
||||
from agent.error_classifier import FailoverReason, classify_api_error
|
||||
from agent.iteration_budget import IterationBudget
|
||||
@@ -639,6 +641,55 @@ def _content_policy_blocked_result(
|
||||
}
|
||||
|
||||
|
||||
def _compression_deferred_result(
|
||||
agent,
|
||||
messages: List[Dict],
|
||||
api_call_count: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the soft turn result for a lock-contended compression defer.
|
||||
|
||||
Another path (a sibling turn, a background review fork, a manual
|
||||
``/compress``) holds this session's compression lock, so every
|
||||
compression pass this turn no-oped and the request still does not fit.
|
||||
This is a TEMPORARY condition — the lock winner is actively shrinking
|
||||
the same session — so the turn must end as a soft defer
|
||||
(``compression_deferred``), never as ``compression_exhausted``: the
|
||||
gateway auto-resets (wipes) the session on exhaustion (#9893/#35809),
|
||||
which would destroy a session that the concurrent compressor is about
|
||||
to make healthy again.
|
||||
|
||||
``failed`` stays False so the gateway persists the user turn (transient
|
||||
branch) and retry-next-message semantics apply.
|
||||
"""
|
||||
holder = getattr(agent, "_compression_skipped_due_to_lock", None)
|
||||
logger.info(
|
||||
"turn deferred: compression lock held by another path "
|
||||
"(session=%s holder=%s) — not counting as compression exhaustion",
|
||||
agent.session_id or "none",
|
||||
holder if isinstance(holder, str) else "unconfirmed",
|
||||
)
|
||||
try:
|
||||
agent._flush_status_buffer()
|
||||
except Exception:
|
||||
pass
|
||||
_final = (
|
||||
"Context compression is already running for this session. "
|
||||
"Please retry in a moment — your next message will be processed "
|
||||
"once the concurrent compression finishes."
|
||||
)
|
||||
return {
|
||||
"final_response": _final,
|
||||
"messages": messages,
|
||||
"completed": False,
|
||||
"api_calls": api_call_count,
|
||||
"error": _final,
|
||||
"partial": True,
|
||||
"failed": False,
|
||||
"compression_deferred": True,
|
||||
"session_id": agent.session_id,
|
||||
}
|
||||
|
||||
|
||||
def _sync_failover_system_message(agent, api_messages, active_system_prompt):
|
||||
"""Refresh the in-flight system message after a provider failover.
|
||||
|
||||
@@ -710,6 +761,13 @@ def run_conversation(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# The gateway caches agents across user turns. Compression state is
|
||||
# per-turn: carrying a prior in-place boundary forward would make a later
|
||||
# uncompressed result look like a compacted transcript to gateway writers.
|
||||
agent._last_compaction_in_place = False
|
||||
agent._last_compression_attempt_recorded = False
|
||||
agent._last_compression_attempt_in_place = None
|
||||
|
||||
# ── Per-turn setup (the prologue) ──
|
||||
# All once-per-turn setup — stdio guarding, retry-counter resets, user
|
||||
# message sanitization, todo/nudge hydration, system-prompt restore-or-
|
||||
@@ -981,6 +1039,13 @@ def run_conversation(
|
||||
# outgoing copy.
|
||||
_api_content = api_msg.pop("api_content", None)
|
||||
|
||||
# Display-only timeline metadata. Never a provider field — strip
|
||||
# from every outgoing copy so strict OpenAI-compatible backends
|
||||
# don't reject the request after a model switch or resumed typed
|
||||
# event row enters the live history.
|
||||
api_msg.pop("display_kind", None)
|
||||
api_msg.pop("display_metadata", None)
|
||||
|
||||
# Inject ephemeral context into the current turn's user message.
|
||||
# Sources: memory manager prefetch + plugin pre_llm_call hooks
|
||||
# with target="user_message" (the default). Both are
|
||||
@@ -1084,7 +1149,7 @@ def run_conversation(
|
||||
aggregator=moa_config.get("aggregator") or {},
|
||||
temperature=_preset_temperature(moa_config, "reference_temperature"),
|
||||
aggregator_temperature=_preset_temperature(moa_config, "aggregator_temperature"),
|
||||
max_tokens=moa_config.get("reference_max_tokens"),
|
||||
reference_max_tokens=moa_config.get("reference_max_tokens"),
|
||||
)
|
||||
if _moa_context:
|
||||
for _msg in reversed(api_messages):
|
||||
@@ -1300,6 +1365,15 @@ def run_conversation(
|
||||
if _moa_prepared_request is not None:
|
||||
pending_moa_prepared_request = _moa_prepared_request
|
||||
compression_attempts += 1
|
||||
# Compression is actually running (block cleared / was never
|
||||
# blocked) — reset the blocked-overflow warning dedup so a future
|
||||
# blocked-over-threshold turn can warn again. Mirrors the
|
||||
# turn-context preflight reset (silent-overflow fix #62625).
|
||||
# getattr guard: test doubles built via object.__new__ lack the
|
||||
# method (gateway test-double pitfall) — treat absence as no-op.
|
||||
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
|
||||
if callable(_clear_warn):
|
||||
_clear_warn()
|
||||
logger.info(
|
||||
"Pre-API compression: ~%s request tokens >= %s threshold "
|
||||
"(context=%s, attempt=%s/%s)",
|
||||
@@ -1310,42 +1384,98 @@ def run_conversation(
|
||||
compression_attempts,
|
||||
max_compression_attempts,
|
||||
)
|
||||
agent._emit_status(
|
||||
PRE_API_COMPRESSION_STATUS_TEMPLATE.format(
|
||||
_pre_api_status = automatic_compaction_status_message(
|
||||
_compressor,
|
||||
phase="pre_api",
|
||||
default_message=PRE_API_COMPRESSION_STATUS_TEMPLATE.format(
|
||||
tokens=request_pressure_tokens
|
||||
)
|
||||
),
|
||||
approx_tokens=request_pressure_tokens,
|
||||
threshold_tokens=int(
|
||||
getattr(_compressor, "threshold_tokens", 0) or 0
|
||||
),
|
||||
context_length=int(
|
||||
getattr(_compressor, "context_length", 0) or 0
|
||||
),
|
||||
model=agent.model,
|
||||
attempt=compression_attempts,
|
||||
max_attempts=max_compression_attempts,
|
||||
)
|
||||
if _pre_api_status:
|
||||
agent._emit_status(_pre_api_status)
|
||||
_last_preflight_pressure = request_pressure_tokens
|
||||
_pre_api_input = messages
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages,
|
||||
system_message,
|
||||
approx_tokens=request_pressure_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
# Reset retry/empty-response state so the compacted request
|
||||
# gets a fresh chance instead of inheriting stale recovery
|
||||
# counters from the pre-compaction history.
|
||||
agent._empty_content_retries = 0
|
||||
agent._thinking_prefill_retries = 0
|
||||
agent._last_content_with_tools = None
|
||||
agent._last_content_tools_all_housekeeping = False
|
||||
agent._mute_post_response = False
|
||||
# Re-baseline the flush cursor for the compaction mode that just
|
||||
# ran. Legacy session-rotation returns None (the child session has
|
||||
# not seen the compacted transcript, so the next flush writes it
|
||||
# whole); in-place compaction returns list(messages) because the
|
||||
# compacted rows are already persisted under the same session id —
|
||||
# leaving None there would re-append them, doubling the active
|
||||
# context and retriggering compression. Mirrors the post-response
|
||||
# and preflight compaction sites; see
|
||||
# conversation_history_after_compression().
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
)
|
||||
api_call_count -= 1
|
||||
agent._api_call_count = api_call_count
|
||||
agent.iteration_budget.refund()
|
||||
continue
|
||||
if messages is _pre_api_input and compression_skipped_due_to_lock(agent):
|
||||
# #69870 lock-skip: another path holds this session's
|
||||
# compression lock, so this pass no-oped. That is a temporary
|
||||
# DEFER, not evidence about compressibility — refund the
|
||||
# attempt (it must not burn the shared overflow-recovery
|
||||
# budget toward compression_exhausted → gateway auto-reset,
|
||||
# #9893/#35809) and leave the insufficient-progress blocker
|
||||
# unarmed. Proceed with the current request: if it truly does
|
||||
# not fit, the provider's 413/overflow handler returns the
|
||||
# soft compression_deferred result with that stronger signal.
|
||||
compression_attempts -= 1
|
||||
_last_preflight_pressure = None
|
||||
if pending_moa_prepared_request is _moa_prepared_request:
|
||||
pending_moa_prepared_request = None
|
||||
else:
|
||||
# Reset retry/empty-response state so the compacted request
|
||||
# gets a fresh chance instead of inheriting stale recovery
|
||||
# counters from the pre-compaction history.
|
||||
agent._empty_content_retries = 0
|
||||
agent._thinking_prefill_retries = 0
|
||||
agent._last_content_with_tools = None
|
||||
agent._last_content_tools_all_housekeeping = False
|
||||
agent._mute_post_response = False
|
||||
# Re-baseline the flush cursor for the compaction mode that just
|
||||
# ran. Legacy session-rotation returns None (the child session has
|
||||
# not seen the compacted transcript, so the next flush writes it
|
||||
# whole); in-place compaction returns list(messages) because the
|
||||
# compacted rows are already persisted under the same session id —
|
||||
# leaving None there would re-append them, doubling the active
|
||||
# context and retriggering compression. Mirrors the post-response
|
||||
# and preflight compaction sites; see
|
||||
# conversation_history_after_compression().
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages, conversation_history
|
||||
)
|
||||
api_call_count -= 1
|
||||
agent._api_call_count = api_call_count
|
||||
agent.iteration_budget.refund()
|
||||
continue
|
||||
elif (
|
||||
agent.compression_enabled
|
||||
and len(messages) > 1
|
||||
and compression_attempts < max_compression_attempts
|
||||
and not _defer_preflight(request_pressure_tokens)
|
||||
and _compression_cooldown
|
||||
):
|
||||
# Blocked by the summary-LLM cooldown. Surface a deduped warning
|
||||
# (only when actually over threshold — should_compress_info
|
||||
# returns a None reason below threshold) so the user isn't left
|
||||
# with a silently growing context. Mirrors the turn-context
|
||||
# preflight and the loop-compaction guards (silent-overflow fix
|
||||
# #62625).
|
||||
_block_reason = None
|
||||
try:
|
||||
_block_reason = _compressor.should_compress_info(
|
||||
request_pressure_tokens
|
||||
)[1]
|
||||
except Exception:
|
||||
_block_reason = None
|
||||
if _block_reason:
|
||||
agent._warn_context_overflow_blocked(
|
||||
_block_reason,
|
||||
request_pressure_tokens,
|
||||
int(getattr(_compressor, "threshold_tokens", 0) or 0),
|
||||
)
|
||||
|
||||
# Thinking spinner for quiet mode (animated during API call)
|
||||
thinking_spinner = None
|
||||
@@ -1935,6 +2065,8 @@ def run_conversation(
|
||||
)
|
||||
continue # Retry the API call
|
||||
|
||||
agent._turn_received_provider_response = True
|
||||
|
||||
# Check finish_reason before proceeding
|
||||
if agent.api_mode == "codex_responses":
|
||||
status = getattr(response, "status", None)
|
||||
@@ -2343,13 +2475,17 @@ def run_conversation(
|
||||
force=True,
|
||||
)
|
||||
agent._cleanup_task_resources(effective_task_id)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
_final_response = (
|
||||
"Stream repeatedly dropped mid tool-call (network); "
|
||||
"the tool was not executed"
|
||||
if _is_stub_stall
|
||||
else "Response truncated due to output length limit"
|
||||
)
|
||||
# Prior successful tool batches (or injected tool
|
||||
# errors) can leave a tool-result tail; this path
|
||||
# never reaches finalize_turn (#48879 class).
|
||||
close_interrupted_tool_sequence(messages, _final_response)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"final_response": _final_response,
|
||||
"messages": messages,
|
||||
@@ -3523,7 +3659,7 @@ def run_conversation(
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
agent, messages, conversation_history
|
||||
)
|
||||
if len(messages) < original_len or old_ctx > _reduced_ctx:
|
||||
agent._buffer_status(
|
||||
@@ -3773,12 +3909,25 @@ def run_conversation(
|
||||
|
||||
original_len = len(messages)
|
||||
original_tokens = estimate_messages_tokens_rough(messages)
|
||||
_overflow_input = messages
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message, approx_tokens=approx_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
|
||||
# #69870 lock-skip: the provider proved the request
|
||||
# does not fit, but this compression pass no-oped only
|
||||
# because another path holds the session's compression
|
||||
# lock. Temporary defer, not exhaustion — refund the
|
||||
# attempt and end the turn softly so the gateway does
|
||||
# NOT auto-reset the session (#9893/#35809).
|
||||
compression_attempts -= 1
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return _compression_deferred_result(
|
||||
agent, messages, api_call_count
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
agent, messages, conversation_history
|
||||
)
|
||||
|
||||
# Re-estimate tokens after compression. Same-message-count
|
||||
@@ -4014,12 +4163,25 @@ def run_conversation(
|
||||
|
||||
original_len = len(messages)
|
||||
original_tokens = estimate_messages_tokens_rough(messages)
|
||||
_overflow_input = messages
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message, approx_tokens=approx_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
if messages is _overflow_input and compression_skipped_due_to_lock(agent):
|
||||
# #69870 lock-skip: the provider proved the request
|
||||
# does not fit, but this compression pass no-oped only
|
||||
# because another path holds the session's compression
|
||||
# lock. Temporary defer, not exhaustion — refund the
|
||||
# attempt and end the turn softly so the gateway does
|
||||
# NOT auto-reset the session (#9893/#35809).
|
||||
compression_attempts -= 1
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return _compression_deferred_result(
|
||||
agent, messages, api_call_count
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
agent, messages, conversation_history
|
||||
)
|
||||
|
||||
# Re-estimate tokens after compression. Same-message-count
|
||||
@@ -5022,8 +5184,13 @@ def run_conversation(
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(f"{agent.log_prefix}❌ Max retries (3) for invalid tool calls exceeded. Stopping as partial.", force=True)
|
||||
agent._invalid_tool_retries = 0
|
||||
agent._persist_session(messages, conversation_history)
|
||||
_final_response = f"Model generated invalid tool call: {invalid_preview}"
|
||||
# Prior <3 retries (or an earlier successful tool batch)
|
||||
# leave a tool-result tail. Closing it here matches
|
||||
# interrupt aborts (#48879 / #52592) so the next user
|
||||
# turn is not tool→user for strict providers.
|
||||
close_interrupted_tool_sequence(messages, _final_response)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"final_response": _final_response,
|
||||
"messages": messages,
|
||||
@@ -5103,14 +5270,18 @@ def run_conversation(
|
||||
)
|
||||
agent._invalid_json_retries = 0
|
||||
agent._cleanup_task_resources(effective_task_id)
|
||||
_final_response = "Response truncated due to output length limit"
|
||||
# Same tool-tail close as interrupt / invalid-tool
|
||||
# exhaustion — this path never reaches finalize_turn.
|
||||
close_interrupted_tool_sequence(messages, _final_response)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"final_response": "Response truncated due to output length limit",
|
||||
"final_response": _final_response,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"partial": True,
|
||||
"error": "Response truncated due to output length limit",
|
||||
"error": _final_response,
|
||||
}
|
||||
|
||||
# Track retries for invalid JSON arguments
|
||||
@@ -5401,15 +5572,98 @@ def run_conversation(
|
||||
and _compressor.should_compress(_real_tokens)
|
||||
):
|
||||
compression_attempts += 1
|
||||
# Compression is actually running (block cleared / was
|
||||
# never blocked) — reset the blocked-overflow warning
|
||||
# dedup so a future blocked-over-threshold turn can warn
|
||||
# again (silent-overflow fix #62625).
|
||||
# getattr guard: test doubles built via object.__new__ lack the
|
||||
# method (gateway test-double pitfall) — treat absence as no-op.
|
||||
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
|
||||
if callable(_clear_warn):
|
||||
_clear_warn()
|
||||
agent._safe_print(" ⟳ compacting context…")
|
||||
_post_tool_input = messages
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message,
|
||||
approx_tokens=agent.context_compressor.last_prompt_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
)
|
||||
if (
|
||||
messages is _post_tool_input
|
||||
and compression_skipped_due_to_lock(agent)
|
||||
):
|
||||
# #69870 lock-skip: this pass no-oped because another
|
||||
# path holds the session's compression lock — a
|
||||
# temporary defer, not evidence about compressibility.
|
||||
# Refund the attempt so a lock-loser tool loop does not
|
||||
# burn the shared per-turn budget toward
|
||||
# compression_exhausted (#9893/#35809).
|
||||
compression_attempts -= 1
|
||||
else:
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages, conversation_history
|
||||
)
|
||||
elif agent.compression_enabled:
|
||||
# Over threshold but compression is blocked (summary-LLM
|
||||
# cooldown or anti-thrashing). Surface a deduped warning so
|
||||
# the user isn't left with a silently growing context that
|
||||
# eventually hits the hard provider limit. Mirrors the
|
||||
# turn-context preflight guard (silent-overflow fix #62625).
|
||||
_block_reason = None
|
||||
_info = getattr(_compressor, "should_compress_info", None)
|
||||
if _info is not None:
|
||||
try:
|
||||
_block_reason = _info(_real_tokens)[1]
|
||||
except Exception:
|
||||
_block_reason = None
|
||||
if _block_reason:
|
||||
agent._warn_context_overflow_blocked(
|
||||
_block_reason,
|
||||
_real_tokens,
|
||||
int(getattr(_compressor, "threshold_tokens", 0) or 0),
|
||||
)
|
||||
# Proactive tool-result prune: reclaim re-sent history on
|
||||
# large-window models long before should_compress() (≈50% of
|
||||
# the window) would ever fire. Deterministic, no LLM call;
|
||||
# protects the recent tail. No-op unless proactive_prune_tokens
|
||||
# is configured and _real_tokens is above it — and even then
|
||||
# the prune only commits when it reclaims at least
|
||||
# proactive_prune_min_reclaim_tokens, so prompt-cache breaks
|
||||
# stay episodic like compression's (the one sanctioned cache
|
||||
# break) instead of firing every tool iteration. See
|
||||
# ContextCompressor.prune_tool_results_only.
|
||||
# getattr guard: plugin context engines predating the hook and
|
||||
# minimal test doubles (SimpleNamespace compressors) lack the
|
||||
# method — treat absence as a no-op.
|
||||
_prune = getattr(_compressor, "prune_tool_results_only", None)
|
||||
if callable(_prune):
|
||||
try:
|
||||
_pruned_msgs, _pruned_n = _prune(
|
||||
messages, current_tokens=_real_tokens
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"proactive tool-result prune failed; skipping",
|
||||
exc_info=True,
|
||||
)
|
||||
_pruned_msgs, _pruned_n = messages, 0
|
||||
# Standard no-op caller contract: only commit when the
|
||||
# engine returned a NEW list object with a non-zero count.
|
||||
if _pruned_n and _pruned_msgs is not messages:
|
||||
# Do NOT rebuild conversation_history here. Unlike the
|
||||
# compression branch, the prune neither rotates the session
|
||||
# nor calls archive_and_compact(), so there is no new
|
||||
# persistence baseline to establish. _prune_old_tool_results
|
||||
# returns per-message copies that preserve the
|
||||
# _DB_PERSISTED_MARKER, so the marker-based flush dedup (see
|
||||
# _flush_messages_to_session_db) already prevents both
|
||||
# duplicate writes and dropped rows. Calling
|
||||
# conversation_history_after_compression (a compaction-only
|
||||
# helper keyed on the _last_compaction_in_place flag) would be
|
||||
# a no-op at best, and on a stale in-place flag could seed
|
||||
# this turn's fresh, not-yet-persisted rows into history_ids
|
||||
# and skip writing them.
|
||||
messages = _pruned_msgs
|
||||
|
||||
# Save session log incrementally (so progress is visible even if interrupted)
|
||||
agent._session_messages = messages
|
||||
|
||||
@@ -503,6 +503,10 @@ class CopilotACPClient:
|
||||
|
||||
def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
|
||||
try:
|
||||
# Hide the console the CLI child would otherwise flash on Windows
|
||||
# (#56747). Hide-only — stdio pipes stay intact for the ACP wire.
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[self._acp_command] + self._acp_args,
|
||||
stdin=subprocess.PIPE,
|
||||
@@ -512,6 +516,7 @@ class CopilotACPClient:
|
||||
bufsize=1,
|
||||
cwd=self._acp_cwd,
|
||||
env=_build_subprocess_env(),
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
|
||||
+131
-72
@@ -596,20 +596,32 @@ class CredentialPool:
|
||||
self._last_no_entries_log_at: Optional[float] = None
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
return bool(self._entries)
|
||||
with self._lock:
|
||||
return bool(self._entries)
|
||||
|
||||
def has_available(self) -> bool:
|
||||
"""True if at least one entry is not currently in exhaustion cooldown."""
|
||||
return bool(self._available_entries())
|
||||
# ``_available_entries`` is not read-only: it prunes aged-out DEAD
|
||||
# manual entries (rebinding ``self._entries``) and persists. It must
|
||||
# run under ``self._lock`` like every other caller (``select`` etc.),
|
||||
# otherwise a status probe here can race a concurrent ``select`` /
|
||||
# rotation and tear ``self._entries`` or double-write auth.json.
|
||||
with self._lock:
|
||||
return bool(self._available_entries())
|
||||
|
||||
def entries(self) -> List[PooledCredential]:
|
||||
return list(self._entries)
|
||||
with self._lock:
|
||||
return list(self._entries)
|
||||
|
||||
def current(self) -> Optional[PooledCredential]:
|
||||
def _current_unlocked(self) -> Optional[PooledCredential]:
|
||||
if not self._current_id:
|
||||
return None
|
||||
return next((entry for entry in self._entries if entry.id == self._current_id), None)
|
||||
|
||||
def current(self) -> Optional[PooledCredential]:
|
||||
with self._lock:
|
||||
return self._current_unlocked()
|
||||
|
||||
def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None:
|
||||
"""Swap an entry in-place by id, preserving sort order."""
|
||||
for idx, entry in enumerate(self._entries):
|
||||
@@ -652,6 +664,8 @@ class CredentialPool:
|
||||
entry: PooledCredential,
|
||||
status_code: Optional[int],
|
||||
error_context: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
persist: bool = True,
|
||||
) -> PooledCredential:
|
||||
normalized_error = _normalize_error_context(error_context)
|
||||
# Permanent OAuth failures (token_invalidated, token_revoked, etc.)
|
||||
@@ -675,7 +689,8 @@ class CredentialPool:
|
||||
last_error_reset_at=normalized_error.get("reset_at"),
|
||||
)
|
||||
self._replace_entry(entry, updated)
|
||||
self._persist()
|
||||
if persist:
|
||||
self._persist()
|
||||
return updated
|
||||
|
||||
def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -> PooledCredential:
|
||||
@@ -1726,18 +1741,21 @@ class CredentialPool:
|
||||
self._entries = [replace(candidate, priority=idx) for idx, candidate in enumerate(rotated)]
|
||||
self._persist()
|
||||
self._current_id = entry.id
|
||||
return self.current() or entry
|
||||
return self._current_unlocked() or entry
|
||||
|
||||
entry = available[0]
|
||||
self._current_id = entry.id
|
||||
return entry
|
||||
|
||||
def peek(self) -> Optional[PooledCredential]:
|
||||
current = self.current()
|
||||
if current is not None:
|
||||
return current
|
||||
available = self._available_entries()
|
||||
return available[0] if available else None
|
||||
# Single lock acquisition for the whole read; call the unlocked
|
||||
# helpers so we don't re-enter the non-reentrant ``self._lock``.
|
||||
with self._lock:
|
||||
current = self._current_unlocked()
|
||||
if current is not None:
|
||||
return current
|
||||
available = self._available_entries()
|
||||
return available[0] if available else None
|
||||
|
||||
def mark_exhausted_and_rotate(
|
||||
self,
|
||||
@@ -1757,12 +1775,49 @@ class CredentialPool:
|
||||
(e for e in self._entries if e.runtime_api_key == api_key_hint),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
# The failed key is identifiable but matches no entry
|
||||
# (rotated away, or a wrapper whose runtime key differs).
|
||||
# Falling through to current()/_select_unlocked() would
|
||||
# mark an INNOCENT healthy key exhausted for the full
|
||||
# cooldown TTL. Don't guess — just hand back a fresh
|
||||
# selection so the caller can retry.
|
||||
logger.info(
|
||||
"credential pool: failed key hint matched no %s entry; "
|
||||
"rotating without marking any credential exhausted",
|
||||
self.provider,
|
||||
)
|
||||
self._current_id = None
|
||||
return self._select_unlocked()
|
||||
if entry is None:
|
||||
entry = self.current() or self._select_unlocked()
|
||||
entry = self._current_unlocked() or self._select_unlocked()
|
||||
if entry is None:
|
||||
return None
|
||||
_label = entry.label or entry.id[:8]
|
||||
self._mark_exhausted(entry, status_code, error_context)
|
||||
# A 402/429/401 is an API-key–level failure: the account is out of
|
||||
# balance, rate-limited, or its key is rejected. The same key can
|
||||
# back more than one pool entry (e.g. an explicit pool entry plus a
|
||||
# ``model_config`` entry auto-seeded from ``model.api_key`` — both
|
||||
# carry the identical ``runtime_api_key``). Marking only the first
|
||||
# match leaves the sibling entries OK, so ``_select_unlocked()``
|
||||
# keeps handing back the same depleted key and rotation never
|
||||
# converges — the caller ``continue``s forever until the client
|
||||
# disconnects (a ~2.5min hang with no error surfaced to the user).
|
||||
# Mark every entry sharing the failed key so the pool can reach the
|
||||
# "no available entries" state and let the error propagate.
|
||||
if api_key_hint:
|
||||
siblings_marked = False
|
||||
for sibling in self._entries:
|
||||
if sibling.id == entry.id:
|
||||
continue
|
||||
if sibling.runtime_api_key == api_key_hint:
|
||||
self._mark_exhausted(
|
||||
sibling, status_code, error_context, persist=False
|
||||
)
|
||||
siblings_marked = True
|
||||
if siblings_marked:
|
||||
self._persist()
|
||||
# Re-read the updated entry to log the correct terminal state.
|
||||
updated_entry = next(
|
||||
(e for e in self._entries if e.id == entry.id), entry,
|
||||
@@ -1852,14 +1907,14 @@ class CredentialPool:
|
||||
None,
|
||||
)
|
||||
else:
|
||||
entry = self.current() or self._select_unlocked(refresh=False)
|
||||
entry = self._current_unlocked() or self._select_unlocked(refresh=False)
|
||||
if entry is None:
|
||||
return None
|
||||
self._current_id = entry.id
|
||||
return self._try_refresh_current_unlocked()
|
||||
|
||||
def _try_refresh_current_unlocked(self) -> Optional[PooledCredential]:
|
||||
entry = self.current()
|
||||
entry = self._current_unlocked()
|
||||
if entry is None:
|
||||
return None
|
||||
refreshed = self._refresh_entry(entry, force=True)
|
||||
@@ -1868,76 +1923,80 @@ class CredentialPool:
|
||||
return refreshed
|
||||
|
||||
def reset_statuses(self) -> int:
|
||||
count = 0
|
||||
new_entries = []
|
||||
for entry in self._entries:
|
||||
if entry.last_status or entry.last_status_at or entry.last_error_code:
|
||||
new_entries.append(
|
||||
replace(
|
||||
entry,
|
||||
last_status=None,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
last_error_reason=None,
|
||||
last_error_message=None,
|
||||
last_error_reset_at=None,
|
||||
with self._lock:
|
||||
count = 0
|
||||
new_entries = []
|
||||
for entry in self._entries:
|
||||
if entry.last_status or entry.last_status_at or entry.last_error_code:
|
||||
new_entries.append(
|
||||
replace(
|
||||
entry,
|
||||
last_status=None,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
last_error_reason=None,
|
||||
last_error_message=None,
|
||||
last_error_reset_at=None,
|
||||
)
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
else:
|
||||
new_entries.append(entry)
|
||||
if count:
|
||||
self._entries = new_entries
|
||||
self._persist()
|
||||
return count
|
||||
count += 1
|
||||
else:
|
||||
new_entries.append(entry)
|
||||
if count:
|
||||
self._entries = new_entries
|
||||
self._persist()
|
||||
return count
|
||||
|
||||
def remove_index(self, index: int) -> Optional[PooledCredential]:
|
||||
if index < 1 or index > len(self._entries):
|
||||
return None
|
||||
removed = self._entries.pop(index - 1)
|
||||
self._entries = [
|
||||
replace(entry, priority=new_priority)
|
||||
for new_priority, entry in enumerate(self._entries)
|
||||
]
|
||||
write_credential_pool(
|
||||
self.provider,
|
||||
[entry.to_dict() for entry in self._entries],
|
||||
removed_ids=[removed.id],
|
||||
)
|
||||
if self._current_id == removed.id:
|
||||
self._current_id = None
|
||||
return removed
|
||||
with self._lock:
|
||||
if index < 1 or index > len(self._entries):
|
||||
return None
|
||||
removed = self._entries.pop(index - 1)
|
||||
self._entries = [
|
||||
replace(entry, priority=new_priority)
|
||||
for new_priority, entry in enumerate(self._entries)
|
||||
]
|
||||
write_credential_pool(
|
||||
self.provider,
|
||||
[entry.to_dict() for entry in self._entries],
|
||||
removed_ids=[removed.id],
|
||||
)
|
||||
if self._current_id == removed.id:
|
||||
self._current_id = None
|
||||
return removed
|
||||
|
||||
def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCredential], Optional[str]]:
|
||||
raw = str(target or "").strip()
|
||||
if not raw:
|
||||
return None, None, "No credential target provided."
|
||||
|
||||
for idx, entry in enumerate(self._entries, start=1):
|
||||
if entry.id == raw:
|
||||
return idx, entry, None
|
||||
with self._lock:
|
||||
for idx, entry in enumerate(self._entries, start=1):
|
||||
if entry.id == raw:
|
||||
return idx, entry, None
|
||||
|
||||
label_matches = [
|
||||
(idx, entry)
|
||||
for idx, entry in enumerate(self._entries, start=1)
|
||||
if entry.label.strip().lower() == raw.lower()
|
||||
]
|
||||
if len(label_matches) == 1:
|
||||
return label_matches[0][0], label_matches[0][1], None
|
||||
if len(label_matches) > 1:
|
||||
return None, None, f'Ambiguous credential label "{raw}". Use the numeric index or entry id instead.'
|
||||
if raw.isdigit():
|
||||
index = int(raw)
|
||||
if 1 <= index <= len(self._entries):
|
||||
return index, self._entries[index - 1], None
|
||||
return None, None, f"No credential #{index}."
|
||||
return None, None, f'No credential matching "{raw}".'
|
||||
label_matches = [
|
||||
(idx, entry)
|
||||
for idx, entry in enumerate(self._entries, start=1)
|
||||
if entry.label.strip().lower() == raw.lower()
|
||||
]
|
||||
if len(label_matches) == 1:
|
||||
return label_matches[0][0], label_matches[0][1], None
|
||||
if len(label_matches) > 1:
|
||||
return None, None, f'Ambiguous credential label "{raw}". Use the numeric index or entry id instead.'
|
||||
if raw.isdigit():
|
||||
index = int(raw)
|
||||
if 1 <= index <= len(self._entries):
|
||||
return index, self._entries[index - 1], None
|
||||
return None, None, f"No credential #{index}."
|
||||
return None, None, f'No credential matching "{raw}".'
|
||||
|
||||
def add_entry(self, entry: PooledCredential) -> PooledCredential:
|
||||
entry = replace(entry, priority=_next_priority(self._entries))
|
||||
self._entries.append(entry)
|
||||
self._persist()
|
||||
return entry
|
||||
with self._lock:
|
||||
entry = replace(entry, priority=_next_priority(self._entries))
|
||||
self._entries.append(entry)
|
||||
self._persist()
|
||||
return entry
|
||||
|
||||
|
||||
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
|
||||
|
||||
@@ -316,12 +316,21 @@ def evaluate_credits_notices(
|
||||
active.discard(CREDITS_USAGE_KEY)
|
||||
if target_band is not None:
|
||||
# Belt-and-suspenders: a producer could set subscription_limit_micros
|
||||
# without subscription_limit_usd. Render "$? cap" rather than "$None cap".
|
||||
# without subscription_limit_usd. Render "$?" rather than "$None".
|
||||
_cap_usd = state.subscription_limit_usd or "?"
|
||||
_level = current_band[1] # type: ignore[index] (current_band set when target_band set)
|
||||
# Report absolute dollars used, not a bare "N% used": the percentage is
|
||||
# only meaningful against a Nous subscription cap (no cap → never fires),
|
||||
# so dollars are clearer and don't imply a universal %. Used = cap −
|
||||
# remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band
|
||||
# change (50 → 75 → 90), not every turn — a snapshot, not a live ticker.
|
||||
_lim = state.subscription_limit_micros or 0
|
||||
_used_micros = max(0, min(_lim, _lim - state.subscription_micros))
|
||||
_used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?"
|
||||
_glyph = "⚠" if _level == "warn" else "•"
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap",
|
||||
text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap",
|
||||
level=_level,
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key=CREDITS_USAGE_KEY,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Context-local state for delegate_task child execution.
|
||||
|
||||
The parent Hermes process may itself be a Kanban dispatcher worker with
|
||||
HERMES_KANBAN_* variables in process env. delegate_task children run inside the
|
||||
same Python process, but they are not dispatcher-owned Kanban workers. This
|
||||
module lets code paths that resolve tool schemas or spawn subprocesses fail
|
||||
closed for delegated children without mutating global os.environ for the parent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Iterator, Mapping, MutableMapping
|
||||
|
||||
_DELEGATED_CHILD_CONTEXT: ContextVar[bool] = ContextVar(
|
||||
"hermes_delegated_child_context",
|
||||
default=False,
|
||||
)
|
||||
|
||||
DELEGATED_CHILD_ENV_MARKER = "HERMES_DELEGATED_CHILD_CONTEXT"
|
||||
|
||||
KANBAN_ENV_KEYS: tuple[str, ...] = (
|
||||
"HERMES_KANBAN_TASK",
|
||||
"HERMES_KANBAN_RUN_ID",
|
||||
"HERMES_KANBAN_WORKSPACE",
|
||||
"HERMES_KANBAN_WORKSPACES_ROOT",
|
||||
"HERMES_KANBAN_CLAIM_LOCK",
|
||||
"HERMES_KANBAN_BOARD",
|
||||
"HERMES_KANBAN_DB",
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def delegated_child_context() -> Iterator[None]:
|
||||
"""Mark the current execution context as a delegate_task child."""
|
||||
token = _DELEGATED_CHILD_CONTEXT.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_DELEGATED_CHILD_CONTEXT.reset(token)
|
||||
|
||||
|
||||
def is_delegated_child_context() -> bool:
|
||||
"""Return True while code is running for a delegate_task child."""
|
||||
return bool(_DELEGATED_CHILD_CONTEXT.get())
|
||||
|
||||
|
||||
def is_delegated_child_process_context() -> bool:
|
||||
"""Return True in this process or a subprocess spawned by a child."""
|
||||
import os
|
||||
|
||||
return bool(_DELEGATED_CHILD_CONTEXT.get()) or bool(
|
||||
os.environ.get(DELEGATED_CHILD_ENV_MARKER)
|
||||
)
|
||||
|
||||
|
||||
def scrub_kanban_env(env: Mapping[str, str] | MutableMapping[str, str]) -> dict[str, str]:
|
||||
"""Return *env* with dispatcher-only Kanban variables removed."""
|
||||
cleaned = dict(env)
|
||||
for key in KANBAN_ENV_KEYS:
|
||||
cleaned.pop(key, None)
|
||||
cleaned[DELEGATED_CHILD_ENV_MARKER] = "1"
|
||||
return cleaned
|
||||
|
||||
|
||||
def delegated_child_subprocess_env(
|
||||
env: Mapping[str, str] | MutableMapping[str, str] | None = None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Return an env override only when delegated-child lineage must cross fork.
|
||||
|
||||
Most subprocess call sites historically used ``env=None`` to inherit the
|
||||
process environment. In a ``delegate_task`` child, inheriting as-is leaks
|
||||
parent dispatcher ``HERMES_KANBAN_*`` vars while losing the ContextVar in
|
||||
the new process. This helper preserves normal ``env=None`` semantics for
|
||||
non-delegated calls, and only materializes a scrubbed env when the lineage
|
||||
marker must be propagated across a child-process boundary.
|
||||
"""
|
||||
if not is_delegated_child_process_context():
|
||||
return None if env is None else dict(env)
|
||||
|
||||
if env is None:
|
||||
import os
|
||||
|
||||
env = os.environ
|
||||
return scrub_kanban_env(env)
|
||||
@@ -159,6 +159,14 @@ _RATE_LIMIT_PATTERNS = [
|
||||
"throttlingexception",
|
||||
"too many concurrent requests",
|
||||
"servicequotaexceededexception",
|
||||
# Generic throttle prefix — Bedrock (and some proxies) surface throttling
|
||||
# as "Throttling error: Too many tokens, please wait before trying
|
||||
# again." Without this entry the message falls through to the
|
||||
# context-overflow list (which contains "too many tokens") and the retry
|
||||
# loop compresses a healthy session instead of backing off. Matched
|
||||
# BEFORE _CONTEXT_OVERFLOW_PATTERNS in the message-only path, so the
|
||||
# throttle wins. (port of anomalyco/opencode#37848's exclusion guard)
|
||||
"throttling",
|
||||
]
|
||||
|
||||
# Patterns that indicate provider-side overload, NOT a per-credential rate
|
||||
@@ -212,6 +220,12 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [
|
||||
"request entity too large",
|
||||
"payload too large",
|
||||
"error code: 413",
|
||||
# Anthropic's structured 413 error type. Normally arrives with an HTTP
|
||||
# 413 status (handled by the status path), but aggregators/proxies can
|
||||
# re-wrap it into a plain message with no status attribute — route it to
|
||||
# the same compression recovery. (port of anomalyco/opencode#37848)
|
||||
"request_too_large",
|
||||
"request exceeds the maximum size",
|
||||
]
|
||||
|
||||
# Image-size patterns. Matched against 400 bodies (not 413) because most
|
||||
@@ -298,6 +312,10 @@ _CONTEXT_OVERFLOW_PATTERNS = [
|
||||
"max input token",
|
||||
"input token",
|
||||
"exceeds the maximum number of input tokens",
|
||||
# Together/Fireworks-style: "Input length 131393 exceeds the maximum
|
||||
# allowed input length of 131040 tokens." No other pattern in this list
|
||||
# matches that wording. (port of anomalyco/opencode#37848)
|
||||
"maximum allowed input length",
|
||||
]
|
||||
|
||||
# Model not found patterns
|
||||
@@ -413,6 +431,7 @@ _CONTENT_POLICY_BLOCKED_PATTERNS = [
|
||||
_AUTH_PATTERNS = [
|
||||
"invalid api key",
|
||||
"invalid_api_key",
|
||||
"gateway_auth_failed",
|
||||
"authentication",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
|
||||
+89
-35
@@ -181,6 +181,8 @@ def _supports_vision_override(
|
||||
cfg: Optional[Dict[str, Any]],
|
||||
provider: str,
|
||||
model: str,
|
||||
*,
|
||||
requested_provider: str = "",
|
||||
) -> Optional[bool]:
|
||||
"""Resolve user-declared vision capability from config.yaml.
|
||||
|
||||
@@ -188,9 +190,14 @@ def _supports_vision_override(
|
||||
1. ``model.supports_vision`` (top-level shortcut for the active model)
|
||||
2. ``providers.<provider>.models.<model>.supports_vision``
|
||||
(named custom providers — ``provider`` may be the runtime-resolved
|
||||
value ``"custom"`` and/or the user-declared name under
|
||||
``model.provider``; both are tried. For ``custom:<name>`` syntax,
|
||||
the stripped ``<name>`` is also tried as a provider key.)
|
||||
value ``"custom"``, the runtime's originally requested provider,
|
||||
and/or the user-declared name under ``model.provider``; all are
|
||||
tried. For ``custom:<name>`` syntax, the stripped ``<name>`` is also
|
||||
tried as a provider key.)
|
||||
2b. ``custom_providers`` (legacy list form) ``.models.<model>``
|
||||
|
||||
Under (2) and (2b), the per-model capability key may be written as
|
||||
either ``supports_vision`` or the shorter ``vision`` alias; both work.
|
||||
|
||||
Returns None when no override is set, so the caller falls through to
|
||||
models.dev. Returns False explicitly only when the user wrote a
|
||||
@@ -210,23 +217,30 @@ def _supports_vision_override(
|
||||
# get rewritten to provider="custom" at runtime
|
||||
# (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the
|
||||
# config still holds the user-declared name under model.provider. Try
|
||||
# both as candidate provider keys, plus the stripped suffix from
|
||||
# "custom:<name>" (where <name> is the key under providers:).
|
||||
# both as candidate provider keys. Either identity may use the
|
||||
# "custom:<name>" form while providers: is keyed by bare <name>.
|
||||
config_provider = str(model_cfg.get("provider") or "").strip()
|
||||
# Extract the stripped name from "custom:<name>" if present
|
||||
stripped_suffix = ""
|
||||
if config_provider.startswith("custom:"):
|
||||
stripped_suffix = config_provider[len("custom:"):]
|
||||
provider_candidates: List[str] = []
|
||||
for candidate in (requested_provider, provider, config_provider):
|
||||
if not candidate:
|
||||
continue
|
||||
provider_candidates.append(candidate)
|
||||
if candidate.startswith("custom:"):
|
||||
stripped_candidate = candidate[len("custom:"):]
|
||||
if stripped_candidate:
|
||||
provider_candidates.append(stripped_candidate)
|
||||
providers_raw = cfg.get("providers")
|
||||
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
|
||||
for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):
|
||||
for p in dict.fromkeys(provider_candidates):
|
||||
entry_raw = providers_cfg.get(p)
|
||||
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
|
||||
models_raw = entry.get("models")
|
||||
models_cfg: Dict[str, Any] = models_raw if isinstance(models_raw, dict) else {}
|
||||
per_model_raw = models_cfg.get(model)
|
||||
per_model: Dict[str, Any] = per_model_raw if isinstance(per_model_raw, dict) else {}
|
||||
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
|
||||
coerced = _coerce_capability_bool(
|
||||
per_model.get("supports_vision", per_model.get("vision"))
|
||||
)
|
||||
if coerced is not None:
|
||||
return coerced
|
||||
|
||||
@@ -235,28 +249,26 @@ def _supports_vision_override(
|
||||
# may appear as the raw name or "custom:<name>" at runtime).
|
||||
custom_providers = cfg.get("custom_providers")
|
||||
if isinstance(custom_providers, list):
|
||||
# Build candidate names: the provider value and the config provider
|
||||
# value, both raw and with "custom:" prefix stripped/added.
|
||||
candidate_names: set = set()
|
||||
for p in filter(None, (provider, config_provider)):
|
||||
candidate_names.add(p)
|
||||
if p.startswith("custom:"):
|
||||
candidate_names.add(p[len("custom:"):])
|
||||
else:
|
||||
candidate_names.add(f"custom:{p}")
|
||||
for entry_raw in custom_providers:
|
||||
if not isinstance(entry_raw, dict):
|
||||
continue
|
||||
entry_name = str(entry_raw.get("name") or "").strip()
|
||||
if entry_name not in candidate_names:
|
||||
continue
|
||||
models_raw = entry_raw.get("models")
|
||||
models_cfg = models_raw if isinstance(models_raw, dict) else {}
|
||||
per_model_raw = models_cfg.get(model)
|
||||
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
|
||||
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
|
||||
if coerced is not None:
|
||||
return coerced
|
||||
# Candidate priority matters when the CLI-selected provider differs
|
||||
# from model.provider. Walk identities first, then config entries, so
|
||||
# list order cannot let the persisted default shadow the live route.
|
||||
for candidate in dict.fromkeys(provider_candidates):
|
||||
candidate_name = candidate.strip().lower()
|
||||
for entry_raw in custom_providers:
|
||||
if not isinstance(entry_raw, dict):
|
||||
continue
|
||||
entry_name = str(entry_raw.get("name") or "").strip().lower()
|
||||
if entry_name != candidate_name:
|
||||
continue
|
||||
models_raw = entry_raw.get("models")
|
||||
models_cfg = models_raw if isinstance(models_raw, dict) else {}
|
||||
per_model_raw = models_cfg.get(model)
|
||||
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
|
||||
coerced = _coerce_capability_bool(
|
||||
per_model.get("supports_vision", per_model.get("vision"))
|
||||
)
|
||||
if coerced is not None:
|
||||
return coerced
|
||||
|
||||
return None
|
||||
|
||||
@@ -376,6 +388,8 @@ def _lookup_supports_vision(
|
||||
provider: str,
|
||||
model: str,
|
||||
cfg: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
requested_provider: str = "",
|
||||
) -> Optional[bool]:
|
||||
"""Return True/False if we can resolve caps, None if unknown.
|
||||
|
||||
@@ -383,7 +397,34 @@ def _lookup_supports_vision(
|
||||
(so custom/local models declared as vision-capable don't fall through to
|
||||
text routing in ``auto`` mode), then falls back to models.dev.
|
||||
"""
|
||||
override = _supports_vision_override(cfg, provider, model)
|
||||
# Named custom providers are canonicalized to ``provider="custom"`` by
|
||||
# runtime resolution. The original CLI/config name is carried in the
|
||||
# context-local main runtime so capability lookup can still select the
|
||||
# exact custom_providers entry. Require an exact provider+model match:
|
||||
# background/auxiliary lookups must never borrow another turn's identity.
|
||||
if not requested_provider:
|
||||
try:
|
||||
from agent.auxiliary_client import _runtime_main_value
|
||||
|
||||
runtime_provider = str(
|
||||
_runtime_main_value("provider") or ""
|
||||
).strip().lower()
|
||||
runtime_model = str(_runtime_main_value("model") or "").strip()
|
||||
lookup_provider = str(provider or "").strip().lower()
|
||||
lookup_model = str(model or "").strip()
|
||||
if runtime_provider == lookup_provider and runtime_model == lookup_model:
|
||||
requested_provider = str(
|
||||
_runtime_main_value("requested_provider") or ""
|
||||
).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
override = _supports_vision_override(
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
requested_provider=requested_provider,
|
||||
)
|
||||
if override is not None:
|
||||
return override
|
||||
if not provider or not model:
|
||||
@@ -421,6 +462,8 @@ def decide_image_input_mode(
|
||||
provider: str,
|
||||
model: str,
|
||||
cfg: Optional[Dict[str, Any]],
|
||||
*,
|
||||
requested_provider: str = "",
|
||||
) -> str:
|
||||
"""Return ``"native"`` or ``"text"`` for the given turn.
|
||||
|
||||
@@ -428,6 +471,7 @@ def decide_image_input_mode(
|
||||
provider: active inference provider ID (e.g. ``"anthropic"``, ``"openrouter"``).
|
||||
model: active model slug as it would be sent to the provider.
|
||||
cfg: loaded config.yaml dict, or None. When None, behaves as auto.
|
||||
requested_provider: provider identity before runtime canonicalization.
|
||||
"""
|
||||
mode_cfg = "auto"
|
||||
if isinstance(cfg, dict):
|
||||
@@ -444,7 +488,17 @@ def decide_image_input_mode(
|
||||
# explicit auxiliary.vision config acts as a *fallback* for text-only
|
||||
# main models — it should not preempt native vision on a model that
|
||||
# can natively inspect the pixels (issue #29135).
|
||||
supports = _lookup_supports_vision(provider, model, cfg)
|
||||
if requested_provider:
|
||||
supports = _lookup_supports_vision(
|
||||
provider,
|
||||
model,
|
||||
cfg,
|
||||
requested_provider=requested_provider,
|
||||
)
|
||||
else:
|
||||
# Keep the long-standing three-argument call contract for callers and
|
||||
# tests that replace the capability lookup hook.
|
||||
supports = _lookup_supports_vision(provider, model, cfg)
|
||||
if supports is True:
|
||||
return "native"
|
||||
if _explicit_aux_vision_override(cfg):
|
||||
|
||||
@@ -7,6 +7,36 @@ from typing import Any, Sequence
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
|
||||
def describe_compression_lock_skip(lock_signal: Any) -> str:
|
||||
"""User-facing text for a manual /compress skipped by the compression lock.
|
||||
|
||||
``lock_signal`` is ``agent._compression_skipped_due_to_lock`` (or the
|
||||
``holder`` carried by the TUI's ``CompressionLockHeld``): a descriptive
|
||||
holder string when another compressor CONFIRMED holds the lock, or
|
||||
``True``/``None`` when acquisition failed without a confirmed holder
|
||||
(``hermes_state.try_acquire_compression_lock`` catches ``sqlite3.Error``
|
||||
internally and returns ``False``, so a failed acquire is NOT proof that
|
||||
another compression is running). The two cases must be worded
|
||||
differently: claiming "already in progress" on an unconfirmed failure
|
||||
misdirects the user when the real problem is a broken lock subsystem.
|
||||
"""
|
||||
holder = (
|
||||
lock_signal
|
||||
if isinstance(lock_signal, str) and lock_signal.strip()
|
||||
else None
|
||||
)
|
||||
if holder:
|
||||
return (
|
||||
f"⏳ Compression already in progress for this session "
|
||||
f"(holder: {holder}). Please wait for it to finish."
|
||||
)
|
||||
return (
|
||||
"⏳ Compression skipped: could not acquire this session's "
|
||||
"compression lock. Another compression may still be running, or "
|
||||
"the lock check failed — try again shortly."
|
||||
)
|
||||
|
||||
|
||||
def summarize_manual_compression(
|
||||
before_messages: Sequence[dict[str, Any]],
|
||||
after_messages: Sequence[dict[str, Any]],
|
||||
|
||||
+52
-18
@@ -212,11 +212,30 @@ def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]:
|
||||
out["api_key"] = rt["api_key"]
|
||||
if rt.get("api_mode"):
|
||||
out["api_mode"] = rt["api_mode"]
|
||||
request_overrides = rt.get("request_overrides")
|
||||
if isinstance(request_overrides, dict):
|
||||
extra_body = request_overrides.get("extra_body")
|
||||
if isinstance(extra_body, dict) and extra_body:
|
||||
out["extra_body"] = dict(extra_body)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("MoA slot runtime resolution failed for %s: %s", _slot_label(slot), exc)
|
||||
return out
|
||||
|
||||
|
||||
def _merge_slot_extra_body(
|
||||
slot_extra_body: Any,
|
||||
caller_extra_body: Any,
|
||||
) -> Any:
|
||||
"""Merge slot defaults with a caller override for ``call_llm``."""
|
||||
if isinstance(slot_extra_body, dict) and slot_extra_body:
|
||||
if isinstance(caller_extra_body, dict):
|
||||
return {**slot_extra_body, **caller_extra_body}
|
||||
if caller_extra_body:
|
||||
return caller_extra_body
|
||||
return dict(slot_extra_body)
|
||||
return caller_extra_body
|
||||
|
||||
|
||||
def _maybe_apply_moa_cache_control(
|
||||
messages: list[dict[str, Any]],
|
||||
runtime: dict[str, Any],
|
||||
@@ -262,7 +281,7 @@ def _maybe_apply_moa_cache_control(
|
||||
|
||||
|
||||
def _run_reference(
|
||||
slot: dict[str, str],
|
||||
slot: dict[str, Any],
|
||||
ref_messages: list[dict[str, Any]],
|
||||
*,
|
||||
temperature: float | None = None,
|
||||
@@ -314,11 +333,16 @@ def _run_reference(
|
||||
# (their caching is automatic; markers are ignored harmlessly, but we
|
||||
# only decorate when the policy says the route honors them).
|
||||
messages = _maybe_apply_moa_cache_control(messages, runtime)
|
||||
# Per-slot max_tokens takes precedence over the preset-level
|
||||
# reference_max_tokens passed in by the caller. This lets each
|
||||
# reference model have its own output cap independently.
|
||||
_slot_max_tokens: int | None = slot.get("max_tokens")
|
||||
_effective_max_tokens = _slot_max_tokens if _slot_max_tokens is not None else max_tokens
|
||||
response = call_llm(
|
||||
task="moa_reference",
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
max_tokens=_effective_max_tokens,
|
||||
reasoning_config=_slot_reasoning_config(slot),
|
||||
**runtime,
|
||||
)
|
||||
@@ -379,7 +403,7 @@ def _run_reference(
|
||||
|
||||
|
||||
def _run_references_parallel(
|
||||
reference_models: list[dict[str, str]],
|
||||
reference_models: list[dict[str, Any]],
|
||||
ref_messages: list[dict[str, Any]],
|
||||
*,
|
||||
temperature: float | None = None,
|
||||
@@ -664,27 +688,30 @@ def aggregate_moa_context(
|
||||
*,
|
||||
user_prompt: str,
|
||||
api_messages: list[dict[str, Any]],
|
||||
reference_models: list[dict[str, str]],
|
||||
aggregator: dict[str, str],
|
||||
reference_models: list[dict[str, Any]],
|
||||
aggregator: dict[str, Any],
|
||||
temperature: float | None = None,
|
||||
aggregator_temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
reference_max_tokens: int | None = None,
|
||||
) -> str:
|
||||
"""Run configured reference models and synthesize their advice.
|
||||
|
||||
Failures are returned as model-specific notes instead of aborting the normal
|
||||
agent loop; the main model can still act with partial context.
|
||||
|
||||
``max_tokens`` is ``None`` by default: MoA does not cap reference or
|
||||
aggregator output, so each model uses its own maximum. ``call_llm`` omits
|
||||
the parameter entirely when it is ``None`` (see its docstring), which also
|
||||
sidesteps providers that reject ``max_tokens`` outright. A hardcoded cap
|
||||
here previously truncated long aggregator syntheses.
|
||||
``reference_max_tokens`` applies ONLY to the reference fan-out — the
|
||||
aggregator's own synthesis call is never capped, so it always uses its
|
||||
model's own maximum. ``call_llm`` omits the parameter entirely when it
|
||||
is ``None`` (see its docstring), which also sidesteps providers that
|
||||
reject ``max_tokens`` outright. A hardcoded cap on the aggregator call
|
||||
previously truncated long aggregator syntheses (#53580) — passing
|
||||
``reference_max_tokens`` to both calls here would silently reintroduce
|
||||
that regression.
|
||||
|
||||
``temperature`` / ``aggregator_temperature`` are ``None`` by default:
|
||||
like max_tokens, ``call_llm`` omits temperature when None so the
|
||||
provider default applies — matching single-model agent behavior. Presets
|
||||
may still pin explicit values.
|
||||
like ``reference_max_tokens``, ``call_llm`` omits temperature when None
|
||||
so the provider default applies — matching single-model agent behavior.
|
||||
Presets may still pin explicit values.
|
||||
"""
|
||||
reference_outputs: list[tuple[str, str, Any]] = []
|
||||
ref_messages = _reference_messages(api_messages)
|
||||
@@ -692,7 +719,7 @@ def aggregate_moa_context(
|
||||
reference_models,
|
||||
ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
max_tokens=reference_max_tokens,
|
||||
)
|
||||
|
||||
joined = "\n\n".join(
|
||||
@@ -729,7 +756,6 @@ def aggregate_moa_context(
|
||||
task="moa_aggregator",
|
||||
messages=agg_messages,
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=max_tokens,
|
||||
reasoning_config=_aggregator_reasoning_config(aggregator),
|
||||
**agg_runtime,
|
||||
)
|
||||
@@ -976,18 +1002,26 @@ class MoAChatCompletions:
|
||||
# actually governs the aggregator stream, not just call_llm's default.
|
||||
if api_kwargs.get("timeout") is not None:
|
||||
stream_kwargs["timeout"] = api_kwargs["timeout"]
|
||||
agg_runtime = _slot_runtime(aggregator)
|
||||
# _slot_runtime may carry the provider's request_overrides.extra_body;
|
||||
# pop it and merge with the caller's extra_body (caller wins) so the
|
||||
# explicit kwarg below never collides with **agg_runtime.
|
||||
agg_extra_body = _merge_slot_extra_body(
|
||||
agg_runtime.pop("extra_body", None),
|
||||
extra_body,
|
||||
)
|
||||
_agg_response = call_llm(
|
||||
task="moa_aggregator",
|
||||
messages=agg_messages,
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=max_tokens,
|
||||
tools=tools,
|
||||
extra_body=extra_body,
|
||||
extra_body=agg_extra_body,
|
||||
# Prepared requests must retain the acting aggregator's reasoning
|
||||
# policy exactly as the direct create() path does (#64187).
|
||||
reasoning_config=_aggregator_reasoning_config(aggregator),
|
||||
**stream_kwargs,
|
||||
**_slot_runtime(aggregator),
|
||||
**agg_runtime,
|
||||
)
|
||||
# Non-streaming path (quiet mode / eval / subagents): the aggregator
|
||||
# output is available inline, so capture it into the pending trace now.
|
||||
|
||||
+11
-3
@@ -2201,11 +2201,18 @@ def get_model_context_length(
|
||||
# acting context, so they're ignored here.
|
||||
if (provider or "").strip().lower() == "moa":
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.config import (
|
||||
get_compatible_custom_providers,
|
||||
load_config,
|
||||
)
|
||||
from hermes_cli.moa_config import resolve_moa_preset
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
preset = resolve_moa_preset(load_config().get("moa") or {}, model)
|
||||
config = load_config()
|
||||
effective_custom_providers = custom_providers
|
||||
if effective_custom_providers is None:
|
||||
effective_custom_providers = get_compatible_custom_providers(config)
|
||||
preset = resolve_moa_preset(config.get("moa") or {}, model)
|
||||
agg = preset.get("aggregator") or {}
|
||||
agg_provider = str(agg.get("provider") or "").strip()
|
||||
agg_model = str(agg.get("model") or "").strip()
|
||||
@@ -2215,7 +2222,8 @@ def get_model_context_length(
|
||||
agg_model,
|
||||
base_url=rt.get("base_url", "") or "",
|
||||
api_key=rt.get("api_key", "") or "",
|
||||
provider=agg_provider,
|
||||
provider=rt.get("provider") or agg_provider,
|
||||
custom_providers=effective_custom_providers,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("MoA aggregator context-length resolution failed", exc_info=True)
|
||||
|
||||
+15
-2
@@ -192,7 +192,13 @@ SKILLS_GUIDANCE = (
|
||||
"skill with skill_manage so you can reuse it next time.\n"
|
||||
"When using a skill and finding it outdated, incomplete, or wrong, "
|
||||
"patch it immediately with skill_manage(action='patch') — don't wait to be asked. "
|
||||
"Skills that aren't maintained become liabilities."
|
||||
"Skills that aren't maintained become liabilities.\n"
|
||||
"\n"
|
||||
"## Skill Safety Rule\n"
|
||||
"1. **UNAVAILABLE** — If a skill placeholder contains `[SKILL_PRUNED]`, the skill content was lost in compression and is inaccessible.\n"
|
||||
"2. **RELOAD** — Before performing any action that depends on a skill, re-check its content with `skill_view(name='...')` if it shows `[SKILL_PRUNED]`.\n"
|
||||
"3. **WAIT** — If a skill is loading or was just pruned, wait for the reload confirmation before proceeding.\n"
|
||||
"4. **DEDUP** — After reloading a pruned skill, **ignore any remaining `[SKILL_PRUNED]` markers for that same skill** — they are historical artifacts from previous compactions and do not need further action."
|
||||
)
|
||||
|
||||
KANBAN_GUIDANCE = (
|
||||
@@ -884,7 +890,14 @@ PLATFORM_HINTS = {
|
||||
"You're responding through an API server. The rendering layer is unknown — "
|
||||
"assume plain text. No markdown formatting (no asterisks, bullets, headers, "
|
||||
"code fences). Treat this like a conversation, not a document. Keep responses "
|
||||
"brief and natural."
|
||||
"brief and natural. "
|
||||
"File/media delivery: images referenced as MEDIA:/absolute/path tags "
|
||||
"(.png/.jpg/.jpeg/.gif/.webp/.bmp, up to 5MB) are inlined as base64 data "
|
||||
"URLs in responses on the chat, completions, and responses endpoints. "
|
||||
"Non-image files are NOT intercepted anywhere, and the runs endpoint "
|
||||
"intercepts nothing — a MEDIA: tag there renders as literal text exposing "
|
||||
"a raw host filesystem path. For those cases, state the plain file path "
|
||||
"in your response text instead of a MEDIA: tag."
|
||||
),
|
||||
"webui": (
|
||||
"You are in the Hermes WebUI, a browser-based chat interface. "
|
||||
|
||||
@@ -127,6 +127,10 @@ class CodexAppServerClient:
|
||||
# Codex emits tracing to stderr; default WARN keeps it quiet for users.
|
||||
spawn_env.setdefault("RUST_LOG", "warn")
|
||||
|
||||
# Hide the console the codex child would otherwise flash on Windows
|
||||
# (#56747). Hide-only — stdio pipes stay intact for the app-server wire.
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
@@ -134,6 +138,7 @@ class CodexAppServerClient:
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
env=spawn_env,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
self._next_id = 1
|
||||
self._pending: dict[int, _Pending] = {}
|
||||
|
||||
+187
-9
@@ -34,8 +34,10 @@ from typing import Any, Dict, List, Mapping, Optional
|
||||
from agent.conversation_compression import (
|
||||
IDLE_COMPACTION_STATUS_TEMPLATE,
|
||||
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
|
||||
compression_skipped_due_to_lock,
|
||||
conversation_history_after_compression,
|
||||
)
|
||||
from agent.context_engine import automatic_compaction_status_message
|
||||
from agent.iteration_budget import IterationBudget
|
||||
from agent.memory_manager import build_memory_context_block
|
||||
from agent.model_metadata import (
|
||||
@@ -375,6 +377,7 @@ def build_turn_context(
|
||||
set_runtime_main(
|
||||
getattr(agent, "provider", "") or "",
|
||||
getattr(agent, "model", "") or "",
|
||||
requested_provider=getattr(agent, "requested_provider", "") or "",
|
||||
base_url=getattr(agent, "base_url", "") or "",
|
||||
api_key=getattr(agent, "api_key", "") or "",
|
||||
api_mode=getattr(agent, "api_mode", "") or "",
|
||||
@@ -662,11 +665,18 @@ def build_turn_context(
|
||||
f"{_idle_floor:,}",
|
||||
agent.session_id or "none",
|
||||
)
|
||||
agent._emit_status(
|
||||
IDLE_COMPACTION_STATUS_TEMPLATE.format(
|
||||
_idle_status = automatic_compaction_status_message(
|
||||
_compressor,
|
||||
phase="idle",
|
||||
default_message=IDLE_COMPACTION_STATUS_TEMPLATE.format(
|
||||
idle_seconds=int(_idle_gap), tokens=_idle_tokens
|
||||
)
|
||||
),
|
||||
approx_tokens=_idle_tokens,
|
||||
idle_seconds=int(_idle_gap),
|
||||
model=agent.model,
|
||||
)
|
||||
if _idle_status:
|
||||
agent._emit_status(_idle_status)
|
||||
_idle_input = messages
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message, approx_tokens=_idle_tokens,
|
||||
@@ -680,7 +690,7 @@ def build_turn_context(
|
||||
# untouched.
|
||||
if messages is not _idle_input:
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
agent, messages, conversation_history
|
||||
)
|
||||
# Compaction rebuilt the list, so the index of this turn's
|
||||
# just-appended user message is stale — re-anchor it the
|
||||
@@ -696,6 +706,8 @@ def build_turn_context(
|
||||
# issue #27405 (a few very large messages slipping past the count gate).
|
||||
_preflight_compressed = False
|
||||
_preflight_compression_blocked = False
|
||||
agent._turn_received_provider_response = False
|
||||
agent._turn_preflight_display_snapshot = None
|
||||
if agent.compression_enabled and _should_run_preflight_estimate(
|
||||
messages,
|
||||
agent.context_compressor.protect_first_n,
|
||||
@@ -708,6 +720,21 @@ def build_turn_context(
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
_compressor = agent.context_compressor
|
||||
# getattr guard: minimal compressor doubles (SimpleNamespace in the
|
||||
# engine-preflight tests) and plugin context engines lack this
|
||||
# ContextCompressor-only method — absence means no snapshot, and the
|
||||
# finalizer's rollback stays disarmed for the turn (display-only).
|
||||
_snapshot_fn = getattr(
|
||||
_compressor, "snapshot_preflight_display_tokens", None
|
||||
)
|
||||
if callable(_snapshot_fn):
|
||||
_snapshot_val = _snapshot_fn()
|
||||
# Type pin: MagicMock compressors return truthy Mock objects —
|
||||
# only a real int snapshot may arm the interrupted-turn rollback.
|
||||
if isinstance(_snapshot_val, int) and not isinstance(
|
||||
_snapshot_val, bool
|
||||
):
|
||||
agent._turn_preflight_display_snapshot = _snapshot_val
|
||||
_defer_preflight = getattr(
|
||||
_compressor,
|
||||
"should_defer_preflight_to_real_usage",
|
||||
@@ -741,6 +768,8 @@ def build_turn_context(
|
||||
lambda: None,
|
||||
)()
|
||||
|
||||
_should_compress_now = False
|
||||
_compress_block_reason = None
|
||||
if _preflight_deferred:
|
||||
logger.info(
|
||||
"Skipping preflight compression: rough estimate ~%s >= %s, "
|
||||
@@ -756,14 +785,42 @@ def build_turn_context(
|
||||
int(_compression_cooldown.get("remaining_seconds", 0.0)),
|
||||
agent.session_id or "none",
|
||||
)
|
||||
if _preflight_tokens >= _compressor.threshold_tokens:
|
||||
# Context is over threshold but compression is blocked by the
|
||||
# summary-LLM cooldown — surface a warning (see block below).
|
||||
_cooldown_secs = _compression_cooldown.get("remaining_seconds", 0.0)
|
||||
_compress_block_reason = f"cooldown:{_cooldown_secs:.0f}"
|
||||
elif _codex_native_auto:
|
||||
logger.info(
|
||||
"Skipping Hermes preflight compression for codex app-server "
|
||||
"(mode=%s); Hermes will not start thread compaction here.",
|
||||
getattr(agent, "codex_app_server_auto_compaction", "native"),
|
||||
)
|
||||
elif _compressor.should_compress(_preflight_tokens):
|
||||
else:
|
||||
_should_compress_now = _compressor.should_compress(_preflight_tokens)
|
||||
if not _should_compress_now:
|
||||
# Context is over threshold but compression is blocked
|
||||
# (summary-LLM cooldown or anti-thrashing). Ask should_compress_info
|
||||
# for the human-readable reason so we can surface a warning below.
|
||||
# getattr guard: minimal compressor doubles (SimpleNamespace in
|
||||
# the engine-preflight tests) and older plugin engines lack the
|
||||
# method — absence means no block reason, no warning.
|
||||
_info = getattr(_compressor, "should_compress_info", None)
|
||||
if callable(_info):
|
||||
try:
|
||||
_compress_block_reason = _info(_preflight_tokens)[1]
|
||||
except Exception:
|
||||
_compress_block_reason = None
|
||||
if _should_compress_now:
|
||||
_preflight_compressed = True
|
||||
# Compression is actually running (block cleared / was never
|
||||
# blocked) — reset the dedup so a future blocked-over-threshold
|
||||
# turn can warn again. Real session boundary.
|
||||
# getattr guard: test doubles built via object.__new__ lack the
|
||||
# method (gateway test-double pitfall) — treat absence as no-op.
|
||||
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
|
||||
if callable(_clear_warn):
|
||||
_clear_warn()
|
||||
logger.info(
|
||||
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
|
||||
f"{_preflight_tokens:,}",
|
||||
@@ -771,12 +828,20 @@ def build_turn_context(
|
||||
agent.model,
|
||||
f"{_compressor.context_length:,}",
|
||||
)
|
||||
agent._emit_status(
|
||||
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
|
||||
_preflight_status = automatic_compaction_status_message(
|
||||
_compressor,
|
||||
phase="preflight",
|
||||
default_message=PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
|
||||
tokens=_preflight_tokens,
|
||||
threshold=_compressor.threshold_tokens,
|
||||
)
|
||||
),
|
||||
approx_tokens=_preflight_tokens,
|
||||
threshold_tokens=_compressor.threshold_tokens,
|
||||
context_length=_compressor.context_length,
|
||||
model=agent.model,
|
||||
)
|
||||
if _preflight_status:
|
||||
agent._emit_status(_preflight_status)
|
||||
# Preflight passes honor the same configured per-turn cap
|
||||
# (compression.max_attempts) as the loop's compression sites;
|
||||
# default 3 preserves the prior hardcoded behavior.
|
||||
@@ -786,10 +851,29 @@ def build_turn_context(
|
||||
for _pass in range(_max_preflight_passes):
|
||||
_orig_len = len(messages)
|
||||
_orig_tokens = _preflight_tokens
|
||||
_preflight_input = messages
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message, approx_tokens=_preflight_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
if (
|
||||
messages is _preflight_input
|
||||
and compression_skipped_due_to_lock(agent)
|
||||
):
|
||||
# #69870 lock-skip: another path holds this session's
|
||||
# compression lock, so the pass no-oped. That is a
|
||||
# temporary DEFER, not proof the transcript cannot
|
||||
# compress — do NOT arm the insufficient-progress
|
||||
# blocker (the loop's error handlers must keep their
|
||||
# provider-proven retry budget) and stop preflight
|
||||
# passes for this turn; the lock winner is shrinking
|
||||
# the same session concurrently.
|
||||
logger.info(
|
||||
"Preflight compression deferred: compression lock "
|
||||
"held by another path (session %s)",
|
||||
agent.session_id or "none",
|
||||
)
|
||||
break
|
||||
# Re-estimate now so size-only compression (same row count,
|
||||
# lower token count — e.g. summarising tool outputs) is
|
||||
# recognised as progress instead of being misread as
|
||||
@@ -805,7 +889,7 @@ def build_turn_context(
|
||||
_preflight_compression_blocked = True
|
||||
break # Cannot compress further: neither rows nor tokens moved
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
agent, messages, conversation_history
|
||||
)
|
||||
agent._empty_content_retries = 0
|
||||
agent._thinking_prefill_retries = 0
|
||||
@@ -827,6 +911,100 @@ def build_turn_context(
|
||||
f"{_preflight_tokens:,}",
|
||||
)
|
||||
break
|
||||
elif _compress_block_reason:
|
||||
# Context is already over the compression threshold, but compression
|
||||
# is blocked (summary LLM cooldown or anti-thrashing). Without a
|
||||
# signal the session keeps growing until the model silently stops
|
||||
# answering — the conversation hits the hard provider token limit
|
||||
# with no explanation. Surface a deduped warning so the user can
|
||||
# take action (/new or /compress) instead of hitting a silent hang.
|
||||
agent._warn_context_overflow_blocked(
|
||||
_compress_block_reason,
|
||||
_preflight_tokens,
|
||||
_compressor.threshold_tokens,
|
||||
)
|
||||
else:
|
||||
# Sub-threshold and unblocked — allow the overflow warning to fire
|
||||
# again next time the context is over threshold but blocked.
|
||||
# getattr guard: test doubles built via object.__new__ lack the
|
||||
# method (gateway test-double pitfall) — treat absence as no-op.
|
||||
_clear_warn = getattr(agent, "_clear_context_overflow_warn", None)
|
||||
if callable(_clear_warn):
|
||||
_clear_warn()
|
||||
# Engine maintenance only when NO skip-branch fired: a failure
|
||||
# cooldown, deferred estimate, or codex-native route must keep
|
||||
# the engine hook un-consulted (#20316 contract — the cooldown
|
||||
# exists precisely because compression recently failed).
|
||||
if _compression_cooldown or _preflight_deferred or _codex_native_auto:
|
||||
_engine_preflight = None
|
||||
else:
|
||||
_engine_preflight = getattr(
|
||||
_compressor, "should_compress_preflight", None
|
||||
)
|
||||
# ── Engine-driven sub-threshold preflight maintenance (#20316) ──
|
||||
# None of the threshold-path branches fired (not deferred, no
|
||||
# failure cooldown, not codex-native, and should_compress() said
|
||||
# the request is under pressure). Context engines that override
|
||||
# ``should_compress_preflight()`` (e.g. LCM-style incremental
|
||||
# leaf-chunk compaction) can still request deferred maintenance
|
||||
# below the token threshold. The default
|
||||
# ``ContextEngine.should_compress_preflight()`` returns False, so
|
||||
# the built-in ``ContextCompressor`` path is byte-identical.
|
||||
#
|
||||
# Attempt-cap integration: the engine gets exactly ONE
|
||||
# ``compress()`` pass per turn. It is mutually exclusive with the
|
||||
# threshold multi-pass loop above (if/elif), so turn-start
|
||||
# preflight passes stay bounded by the resolved
|
||||
# ``compression.max_attempts`` cap (floor 1) in every case.
|
||||
#
|
||||
# No-op-blocking integration: a sub-threshold engine pass that
|
||||
# no-ops says nothing about over-threshold compressibility, so it
|
||||
# must neither set nor clear ``_preflight_compression_blocked``
|
||||
# (#64382) — and being in the ``else`` arm it can never run after
|
||||
# the threshold loop has proven a retry ineffective.
|
||||
# (resolved above, gated on no skip-branch having fired)
|
||||
_wants_engine_preflight = False
|
||||
if callable(_engine_preflight):
|
||||
try:
|
||||
_wants_engine_preflight = bool(_engine_preflight(messages))
|
||||
except Exception as _preflight_exc:
|
||||
# A buggy engine must never break an otherwise-healthy
|
||||
# turn: swallow at debug level and skip maintenance.
|
||||
logger.debug(
|
||||
"should_compress_preflight raised %s; skipping "
|
||||
"engine-driven preflight maintenance",
|
||||
_preflight_exc,
|
||||
)
|
||||
_wants_engine_preflight = False
|
||||
if _wants_engine_preflight:
|
||||
logger.info(
|
||||
"Engine-driven preflight maintenance: %s requested "
|
||||
"compress() at ~%s tokens (below %s threshold)",
|
||||
getattr(_compressor, "name", type(_compressor).__name__),
|
||||
f"{_preflight_tokens:,}",
|
||||
f"{getattr(_compressor, 'threshold_tokens', 0):,}",
|
||||
)
|
||||
_engine_input = messages
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
messages, system_message, approx_tokens=_preflight_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
# ``_compress_context`` returns the INPUT list object on every
|
||||
# skip path (per-session lock held elsewhere, cooldown,
|
||||
# anti-thrash breaker, codex-native routing) and an engine may
|
||||
# legitimately no-op. Only re-baseline the flush history and
|
||||
# re-anchor the user row after a REAL compaction — a skip must
|
||||
# leave the turn's bookkeeping untouched.
|
||||
if messages is not _engine_input:
|
||||
_preflight_compressed = True
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
)
|
||||
agent._empty_content_retries = 0
|
||||
agent._thinking_prefill_retries = 0
|
||||
agent._last_content_with_tools = None
|
||||
agent._last_content_tools_all_housekeeping = False
|
||||
agent._mute_post_response = False
|
||||
|
||||
if _preflight_compressed:
|
||||
# Compression rebuilt the list (tail messages are fresh compaction
|
||||
|
||||
@@ -201,6 +201,36 @@ def finalize_turn(
|
||||
)
|
||||
)
|
||||
|
||||
# Preflight can seed the display count before the provider receives the
|
||||
# request. Roll that estimate back only when an interrupt wins the race
|
||||
# before any successful provider response. Compaction state remains owned
|
||||
# by the real-usage/post-compaction path, including its ``-1`` sentinel.
|
||||
# Guard rules (test-double density on this path is high):
|
||||
# - snapshot is type-pinned to a real int — MagicMock agents auto-create
|
||||
# truthy Mock attributes that must never arm the rollback;
|
||||
# - the received-response flag is pinned to ``is not True`` — its real
|
||||
# domain is True/False, and only a literal True means a provider
|
||||
# response completed;
|
||||
# - the compressor method gets a getattr+callable guard — SimpleNamespace
|
||||
# compressor doubles and plugin context engines lack it.
|
||||
_preflight_snapshot = getattr(
|
||||
agent, "_turn_preflight_display_snapshot", None
|
||||
)
|
||||
if (
|
||||
interrupted is True
|
||||
and isinstance(_preflight_snapshot, int)
|
||||
and not isinstance(_preflight_snapshot, bool)
|
||||
and getattr(agent, "_turn_received_provider_response", False) is not True
|
||||
and getattr(agent, "context_compressor", None) is not None
|
||||
):
|
||||
_rollback_fn = getattr(
|
||||
agent.context_compressor,
|
||||
"rollback_interrupted_preflight_display_tokens",
|
||||
None,
|
||||
)
|
||||
if callable(_rollback_fn):
|
||||
_rollback_fn(_preflight_snapshot)
|
||||
|
||||
# Post-loop cleanup must never lose the response. Trajectory save,
|
||||
# resource teardown, and session persistence all touch fallible
|
||||
# surfaces — file I/O / JSON serialization (_save_trajectory), remote
|
||||
@@ -625,4 +655,7 @@ def finalize_turn(
|
||||
except Exception as exc:
|
||||
logger.warning("on_session_end hook failed: %s", exc)
|
||||
|
||||
agent._turn_preflight_display_snapshot = None
|
||||
agent._turn_received_provider_response = False
|
||||
|
||||
return result
|
||||
|
||||
@@ -60,10 +60,12 @@ def _db_path() -> Path:
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
|
||||
path = _db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
apply_wal_with_fallback(conn, db_label="verification_evidence.db")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.row_factory = sqlite3.Row
|
||||
_ensure_schema(conn)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"check": "npm run typecheck",
|
||||
"check": "npm run typecheck && npm run lint",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"fix": "npm run lint:fix"
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* E2E regression: desktop resume must hide agent-only transcript rows.
|
||||
*
|
||||
* Compaction handoffs are active user rows because the model needs them for
|
||||
* context continuity. They are not authored chat content, so the desktop
|
||||
* transcript must never display them after a real compressor-generated resume.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import {
|
||||
MOCK_REPLY,
|
||||
startMockServer,
|
||||
VERIFICATION_STOP_TEXT,
|
||||
VERIFICATION_STOP_TRIGGER,
|
||||
} from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
|
||||
const SESSION_TITLE = 'E2E Hidden History Messages'
|
||||
const VISIBLE_USER_TEXT = 'E2E_VISIBLE_USER_HISTORY'
|
||||
const VISIBLE_POST_COMPACTION_TEXT = 'E2E_VISIBLE_POST_COMPACTION_HISTORY'
|
||||
const COMPACTION_TRIGGER_PADDING = ' force real context compression'.repeat(600)
|
||||
|
||||
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('hidden-history')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
fs.appendFileSync(
|
||||
path.join(sandbox.hermesHome, 'config.yaml'),
|
||||
'\ncompression:\n threshold_tokens: 1\n',
|
||||
'utf8',
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
try {
|
||||
await builder.createSession({
|
||||
title: SESSION_TITLE,
|
||||
turns: [
|
||||
`${VISIBLE_USER_TEXT}${COMPACTION_TRIGGER_PADDING}`,
|
||||
VISIBLE_POST_COMPACTION_TEXT,
|
||||
],
|
||||
})
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('resume hides real context-compaction handoffs', async ({}, testInfo) => {
|
||||
const fixture = await setupSeededMockBackend()
|
||||
|
||||
try {
|
||||
const { page } = fixture
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
const sessionRow = page
|
||||
.locator('[data-slot="sidebar"] button')
|
||||
.filter({ hasText: SESSION_TITLE })
|
||||
.first()
|
||||
await sessionRow.click()
|
||||
|
||||
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
|
||||
await expect(transcript).toContainText(VISIBLE_USER_TEXT)
|
||||
await expect(transcript).toContainText(VISIBLE_POST_COMPACTION_TEXT)
|
||||
await expect(transcript).toContainText(MOCK_REPLY)
|
||||
await expect(transcript).not.toContainText('[CONTEXT COMPACTION — REFERENCE ONLY]')
|
||||
await page.screenshot({ path: testInfo.outputPath('hidden-history-resume.png') })
|
||||
} finally {
|
||||
await fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('live verify-on-stop continuations stay out of the transcript', async ({}, testInfo) => {
|
||||
const sandbox = createSandbox('live-verification-nudge')
|
||||
const projectRoot = path.join(sandbox.root, 'project')
|
||||
const changedFile = path.join(projectRoot, 'e2e-verification-target.py')
|
||||
fs.mkdirSync(projectRoot)
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'pyproject.toml'),
|
||||
'[project]\nname = "e2e-verification-project"\nversion = "0.0.0"\n',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const mock = await startMockServer({ verificationWritePath: changedFile })
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
fs.appendFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '\nagent:\n verify_on_stop: true\n', 'utf8')
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
const fixture: MockBackendFixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(VERIFICATION_STOP_TRIGGER)
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
|
||||
await expect(transcript).toContainText(VERIFICATION_STOP_TEXT, { timeout: 60_000 })
|
||||
await expect.poll(
|
||||
() => mock.receivedPrompts.some(prompt => prompt.includes('[System: You edited code in this turn')),
|
||||
{ timeout: 30_000 },
|
||||
).toBe(true)
|
||||
expect(fs.existsSync(changedFile), 'The scripted write_file call should edit only the sandbox project').toBe(true)
|
||||
await expect(transcript).not.toContainText('[System: You edited code in this turn')
|
||||
await page.screenshot({ path: testInfo.outputPath('live-verification-nudge.png') })
|
||||
} finally {
|
||||
await fixture.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { type TestInfo } from '@playwright/test'
|
||||
@@ -15,13 +14,16 @@ import {
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const SEED_SCRIPT = path.resolve(import.meta.dirname, 'scripts', 'seed_large_session.py')
|
||||
const SESSION_TITLE = 'E2E large persisted session'
|
||||
const EXPECTED_TEXT = 'E2E persisted user message 52'
|
||||
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
|
||||
const HISTORY_TURNS = Array.from(
|
||||
{ length: 27 },
|
||||
(_, index) => `E2E persisted user message ${index * 2}: audit the compatibility matrix`,
|
||||
)
|
||||
|
||||
interface SeededFixture {
|
||||
app: ElectronApplication
|
||||
@@ -43,13 +45,11 @@ async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<Seede
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const seeded = spawnSync('python3', [SEED_SCRIPT, path.join(sandbox.hermesHome, 'state.db')], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PYTHONPATH: REPO_ROOT },
|
||||
})
|
||||
if (seeded.status !== 0) {
|
||||
throw new Error(`large-session seed failed:\n${seeded.stdout}\n${seeded.stderr}`)
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
try {
|
||||
await builder.createSession({ title: SESSION_TITLE, turns: HISTORY_TURNS })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
@@ -210,6 +210,7 @@ test.describe('large session resume', () => {
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
|
||||
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
|
||||
await fixture.mock.waitForHeldStream()
|
||||
await openNewSession(fixture.page)
|
||||
@@ -229,7 +230,10 @@ test.describe('large session resume', () => {
|
||||
await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false })
|
||||
|
||||
expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1)
|
||||
expect(await textNodeOccurrences(fixture.page, MOCK_REPLY), 'the completed assistant reply should appear once').toBe(1)
|
||||
expect(
|
||||
await textNodeOccurrences(fixture.page, MOCK_REPLY),
|
||||
'the completed assistant reply should add exactly one transcript row',
|
||||
).toBe(initialMockReplyCount + 1)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,8 +23,10 @@ export const MOCK_REPLY = 'Hello from the mock inference server! The full boot c
|
||||
export interface MockServerOptions {
|
||||
/** Pause the matching stream after its first token for session-switch E2E coverage. */
|
||||
holdFirstStreamForPrompt?: string
|
||||
/** Pause the first completion whose request JSON contains this text. */
|
||||
holdFirstCompletionContaining?: string
|
||||
/** Pause the first completion whose request JSON contains this text. */
|
||||
holdFirstCompletionContaining?: string
|
||||
/** Absolute sandbox path written by the verify-on-stop scripted tool call. */
|
||||
verificationWritePath?: string
|
||||
}
|
||||
|
||||
export interface MockServer {
|
||||
@@ -104,6 +106,9 @@ let _queueStopIndex = 0
|
||||
/** Per-server counter for the correction/session-switch script. */
|
||||
let _correctionSwitchIndex = 0
|
||||
|
||||
/** Per-server counter for the verify-on-stop script. */
|
||||
let _verificationStopIndex = 0
|
||||
|
||||
/** User messages received by the mock, for E2E assertions on real submits. */
|
||||
const _receivedUserTexts: string[] = []
|
||||
|
||||
@@ -114,6 +119,7 @@ function resetScriptIndex(): void {
|
||||
_sidebarCrossIndex = 0
|
||||
_queueStopIndex = 0
|
||||
_correctionSwitchIndex = 0
|
||||
_verificationStopIndex = 0
|
||||
_receivedUserTexts.length = 0
|
||||
}
|
||||
|
||||
@@ -214,6 +220,32 @@ const CORRECTION_SWITCH_SCRIPT: ScriptedTurn[] = [
|
||||
|
||||
export const CORRECTION_SWITCH_TRIGGER = 'E2E_CORRECTION_SWITCH_TRIGGER'
|
||||
|
||||
/**
|
||||
* Drives a real code edit followed by two finish attempts. Hermes should add
|
||||
* its synthetic verify-on-stop continuation after each finish attempt until
|
||||
* the bounded verifier gives up. The mock's request capture proves the nudge
|
||||
* reached the model; desktop must never render it as chat content.
|
||||
*/
|
||||
function verificationStopScript(writePath: string): ScriptedTurn[] {
|
||||
return [
|
||||
{
|
||||
text: 'I will make the requested code change.',
|
||||
toolCalls: [{
|
||||
name: 'write_file',
|
||||
args: {
|
||||
path: writePath,
|
||||
content: 'def changed_by_e2e():\n return "changed"\n',
|
||||
},
|
||||
}],
|
||||
},
|
||||
{ text: 'The code edit is complete.' },
|
||||
{ text: 'I cannot provide fresh verification evidence for that edit.' },
|
||||
]
|
||||
}
|
||||
|
||||
export const VERIFICATION_STOP_TRIGGER = 'E2E_VERIFY_ON_STOP_TRIGGER'
|
||||
export const VERIFICATION_STOP_TEXT = 'I cannot provide fresh verification evidence for that edit.'
|
||||
|
||||
/**
|
||||
* A marker that makes the mock emit a real blocking clarify tool call. Tests
|
||||
* use it to hold a turn open while exercising busy-composer interactions.
|
||||
@@ -340,6 +372,9 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
||||
const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER')
|
||||
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
|
||||
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
|
||||
const isVerificationStopTrigger = messages.some(
|
||||
message => typeof message?.content === 'string' && message.content.includes(VERIFICATION_STOP_TRIGGER),
|
||||
)
|
||||
const isCorrectionSwitchTrigger = messages.some(
|
||||
message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER),
|
||||
)
|
||||
@@ -364,6 +399,18 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
||||
return
|
||||
}
|
||||
|
||||
if (isVerificationStopTrigger) {
|
||||
const script = verificationStopScript(options.verificationWritePath ?? 'e2e-verification-target.py')
|
||||
const turn = script[_verificationStopIndex] ?? script[script.length - 1]
|
||||
_verificationStopIndex++
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isCorrectionSwitchTrigger) {
|
||||
const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1]
|
||||
_correctionSwitchIndex++
|
||||
|
||||
@@ -26,12 +26,18 @@ async function send(page: Page, text: string): Promise<void> {
|
||||
|
||||
async function steer(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await expect(primary).toHaveAttribute('aria-label', /Steer/)
|
||||
await primary.click()
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function queue(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Control+Enter')
|
||||
}
|
||||
|
||||
async function transcriptMessageOrder(page: Page): Promise<string[]> {
|
||||
@@ -72,15 +78,10 @@ test.describe('queued prompt turn boundary', () => {
|
||||
|
||||
test('submits a queued prompt only after the active turn completes', async () => {
|
||||
const { mock, page } = fixture!
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
|
||||
|
||||
await send(page, ACTIVE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(QUEUED_PROMPT, { delay: 5 })
|
||||
await queue.click()
|
||||
await queue(page, QUEUED_PROMPT)
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
|
||||
// The mock keeps the active SSE stream open, so a queued prompt has no
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
|
||||
import * as path from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
|
||||
interface JsonRpcError {
|
||||
code?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface JsonRpcFrame {
|
||||
error?: JsonRpcError
|
||||
id?: number
|
||||
method?: string
|
||||
params?: {
|
||||
payload?: unknown
|
||||
session_id?: string
|
||||
type?: string
|
||||
}
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
interface CreatedSession {
|
||||
session_id: string
|
||||
stored_session_id: string
|
||||
}
|
||||
|
||||
export interface RealSessionSpec {
|
||||
/** Human-visible sidebar title, persisted by the first completed turn. */
|
||||
title: string
|
||||
/** Each item becomes one real user prompt followed by the mock provider's reply. */
|
||||
turns: readonly string[]
|
||||
}
|
||||
|
||||
export interface RealSession {
|
||||
/** Runtime-only TUI session id, valid only while the builder process is alive. */
|
||||
runtimeId: string
|
||||
/** Durable SessionDB id that desktop resumes after the builder exits. */
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates durable desktop session history through the real TUI gateway and
|
||||
* AIAgent loop, using the E2E mock provider configured in `hermesHome`.
|
||||
*
|
||||
* This intentionally uses the shipped stdio JSON-RPC transport instead of
|
||||
* importing SessionDB or launching Electron. The desktop's WebSocket backend
|
||||
* dispatches the same `tui_gateway.server` methods.
|
||||
*/
|
||||
export class RealSessionBuilder {
|
||||
private readonly child: ChildProcessWithoutNullStreams
|
||||
private nextRequestId = 0
|
||||
private readonly pending = new Map<number, { reject: (reason: Error) => void; resolve: (value: unknown) => void }>()
|
||||
private readonly events: JsonRpcFrame[] = []
|
||||
private readonly eventWaiters: Array<{
|
||||
predicate: (frame: JsonRpcFrame) => boolean
|
||||
reject: (reason: Error) => void
|
||||
resolve: (frame: JsonRpcFrame) => void
|
||||
}> = []
|
||||
private readonly stderr: string[] = []
|
||||
private closed = false
|
||||
|
||||
private constructor(hermesHome: string) {
|
||||
this.child = spawn('uv', ['run', '--active', '--no-sync', 'python', '-m', 'tui_gateway.entry'], {
|
||||
cwd: REPO_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: hermesHome,
|
||||
PYTHONPATH: REPO_ROOT,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
createInterface({ input: this.child.stdout }).on('line', line => this.handleLine(line))
|
||||
createInterface({ input: this.child.stderr }).on('line', line => {
|
||||
this.stderr.push(line)
|
||||
if (this.stderr.length > 80) this.stderr.shift()
|
||||
})
|
||||
this.child.once('error', error => this.failAll(new Error(`real-session gateway failed to start: ${error.message}`)))
|
||||
this.child.once('exit', (code, signal) => {
|
||||
if (!this.closed) {
|
||||
this.failAll(new Error(`real-session gateway exited unexpectedly (${signal ?? code ?? 'unknown'}):\n${this.stderr.join('\n')}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async start(hermesHome: string): Promise<RealSessionBuilder> {
|
||||
const builder = new RealSessionBuilder(hermesHome)
|
||||
await builder.waitForEvent(frame => frame.params?.type === 'gateway.ready')
|
||||
return builder
|
||||
}
|
||||
|
||||
async createSession(spec: RealSessionSpec): Promise<RealSession> {
|
||||
if (spec.turns.length === 0) {
|
||||
throw new Error('RealSessionBuilder requires at least one turn so the real agent creates a durable session row')
|
||||
}
|
||||
|
||||
const created = await this.request<CreatedSession>('session.create', {
|
||||
cols: 120,
|
||||
cwd: REPO_ROOT,
|
||||
source: 'desktop',
|
||||
title: spec.title,
|
||||
})
|
||||
const runtimeId = requireString(created, 'session_id')
|
||||
const sessionId = requireString(created, 'stored_session_id')
|
||||
|
||||
for (const text of spec.turns) {
|
||||
const completion = this.waitForEvent(
|
||||
frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId,
|
||||
)
|
||||
await this.request('prompt.submit', { session_id: runtimeId, text })
|
||||
const frame = await completion
|
||||
const status = readString(frame.params?.payload, 'status')
|
||||
if (status !== 'complete') {
|
||||
throw new Error(`real session turn failed with status ${status ?? 'unknown'}: ${JSON.stringify(frame.params?.payload)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await this.request('session.close', { session_id: runtimeId })
|
||||
return { runtimeId, sessionId }
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.child.stdin.end()
|
||||
await new Promise<void>(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.child.kill('SIGTERM')
|
||||
resolve()
|
||||
}, 5_000)
|
||||
this.child.once('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const id = ++this.nextRequestId
|
||||
return this.withTimeout(new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: value => resolve(value as T), reject })
|
||||
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, error => {
|
||||
if (error) {
|
||||
this.pending.delete(id)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}), `request ${method}`)
|
||||
}
|
||||
|
||||
private waitForEvent(predicate: (frame: JsonRpcFrame) => boolean): Promise<JsonRpcFrame> {
|
||||
const index = this.events.findIndex(predicate)
|
||||
if (index >= 0) {
|
||||
return Promise.resolve(this.events.splice(index, 1)[0])
|
||||
}
|
||||
return this.withTimeout(new Promise<JsonRpcFrame>((resolve, reject) => {
|
||||
this.eventWaiters.push({ predicate, resolve, reject })
|
||||
}), 'gateway event')
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let frame: JsonRpcFrame
|
||||
try {
|
||||
frame = JSON.parse(line) as JsonRpcFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof frame.id === 'number') {
|
||||
const pending = this.pending.get(frame.id)
|
||||
if (!pending) return
|
||||
this.pending.delete(frame.id)
|
||||
if (frame.error) {
|
||||
pending.reject(new Error(`JSON-RPC error ${frame.error.code ?? 'unknown'}: ${frame.error.message ?? 'unknown error'}`))
|
||||
} else {
|
||||
pending.resolve(frame.result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.method !== 'event') return
|
||||
const waiter = this.eventWaiters.find(candidate => candidate.predicate(frame))
|
||||
if (!waiter) {
|
||||
this.events.push(frame)
|
||||
return
|
||||
}
|
||||
this.eventWaiters.splice(this.eventWaiters.indexOf(waiter), 1)
|
||||
waiter.resolve(frame)
|
||||
}
|
||||
|
||||
private withTimeout<T>(promise: Promise<T>, operation: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timed out after ${DEFAULT_TIMEOUT_MS / 1000}s waiting for ${operation}:\n${this.stderr.join('\n')}`)), DEFAULT_TIMEOUT_MS)
|
||||
promise.then(value => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
}, error => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private failAll(error: Error): void {
|
||||
for (const pending of this.pending.values()) pending.reject(error)
|
||||
this.pending.clear()
|
||||
for (const waiter of this.eventWaiters) waiter.reject(error)
|
||||
this.eventWaiters.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
function readString(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const candidate = (value as Record<string, unknown>)[key]
|
||||
return typeof candidate === 'string' ? candidate : undefined
|
||||
}
|
||||
|
||||
function requireString(value: unknown, key: string): string {
|
||||
const candidate = readString(value, key)
|
||||
if (!candidate) throw new Error(`Gateway response omitted required ${key}: ${JSON.stringify(value)}`)
|
||||
return candidate
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed a deterministic, tool-free large session into an isolated state.db."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(repo_root))
|
||||
|
||||
from hermes_state import SessionDB # noqa: E402
|
||||
|
||||
SESSION_ID = "e2e-large-session"
|
||||
SESSION_TITLE = "E2E large persisted session"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit(f"usage: {sys.argv[0]} <state.db>")
|
||||
|
||||
messages = []
|
||||
for index in range(53):
|
||||
role = "user" if index % 2 == 0 else "assistant"
|
||||
content = (
|
||||
f"E2E persisted user message {index}: audit the compatibility matrix"
|
||||
if role == "user"
|
||||
else f"E2E persisted assistant reply {index}: recorded the audit result"
|
||||
)
|
||||
messages.append({"role": role, "content": content, "timestamp": 1_700_000_000 + index})
|
||||
|
||||
database = SessionDB(db_path=Path(sys.argv[1]))
|
||||
result = database.import_sessions(
|
||||
[
|
||||
{
|
||||
"id": SESSION_ID,
|
||||
"source": "desktop",
|
||||
"model": "mock-model",
|
||||
"started_at": 1_700_000_000,
|
||||
"title": SESSION_TITLE,
|
||||
"cwd": str(repo_root),
|
||||
"system_prompt": "",
|
||||
"messages": messages,
|
||||
}
|
||||
]
|
||||
)
|
||||
database.close()
|
||||
|
||||
if not result.get("ok") or result.get("imported") != 1:
|
||||
raise SystemExit(f"failed to seed large session: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed a Hermes state.db with a session exported from a real conversation.
|
||||
|
||||
Usage: seed_session_db.py <state_db_path> <fixture_json_path>
|
||||
|
||||
Creates the database with the full SessionDB schema (if it doesn't exist)
|
||||
and imports the session from the JSON fixture. Uses the real
|
||||
SessionDB.import_sessions() so the data shape matches what the desktop
|
||||
backend expects.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the repo root to sys.path so we can import hermes_state.
|
||||
# The script is invoked from apps/desktop/e2e/ — repo root is ../../..
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(repo_root))
|
||||
|
||||
from hermes_state import SessionDB # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <state_db_path> <fixture_json_path>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
db_path = Path(sys.argv[1])
|
||||
fixture_path = Path(sys.argv[2])
|
||||
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(fixture_path, "r", encoding="utf-8") as f:
|
||||
session_data = json.load(f)
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
result = db.import_sessions([session_data])
|
||||
|
||||
if not result.get("ok"):
|
||||
print(f"Import failed: {result}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
imported = result.get("imported", 0)
|
||||
skipped = result.get("skipped", 0)
|
||||
errors = result.get("errors", [])
|
||||
|
||||
if errors:
|
||||
print(f"Import had errors: {errors}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Seeded {imported} session(s), skipped {skipped} → {db_path}")
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -28,11 +28,6 @@
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
@@ -45,12 +40,9 @@ import {
|
||||
launchDesktop,
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const SEED_SCRIPT = path.join(DESKTOP_ROOT, 'e2e', 'scripts', 'seed_session_db.py')
|
||||
const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
|
||||
const SESSION_ID = 'e2e-warm-resume-session'
|
||||
/** 32 messages (16 user/assistant pairs) — enough DOM churn for detection. */
|
||||
const MESSAGE_COUNT = 32
|
||||
/** Seeded PRNG so the generated content is deterministic across runs. */
|
||||
@@ -82,54 +74,27 @@ function gibberish(rng: () => number): string {
|
||||
const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED))
|
||||
|
||||
/**
|
||||
* Generate a session fixture with MESSAGE_COUNT messages (user/assistant
|
||||
* pairs) of seeded gibberish — just role + content, enough for SessionDB
|
||||
* to import and the transcript to render. Written to a temp file for the
|
||||
* seed script.
|
||||
* Generate the user turns for a real session. The mock provider produces the
|
||||
* assistant side of each pair through the normal AIAgent persistence path.
|
||||
*/
|
||||
function generateSessionFixture(fixturePath: string): void {
|
||||
function generateSessionTurns(): string[] {
|
||||
const rng = mulberry32(RNG_SEED)
|
||||
const messages: Array<{ role: string; content: string }> = []
|
||||
const turns: string[] = []
|
||||
|
||||
for (let i = 0; i < MESSAGE_COUNT / 2; i++) {
|
||||
messages.push({ role: 'user', content: gibberish(rng) })
|
||||
messages.push({ role: 'assistant', content: gibberish(rng) })
|
||||
turns.push(gibberish(rng))
|
||||
gibberish(rng)
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: SESSION_ID,
|
||||
source: 'cli',
|
||||
model: 'mock-model',
|
||||
system_prompt: '',
|
||||
started_at: 1721692800.0,
|
||||
message_count: MESSAGE_COUNT,
|
||||
title: SESSION_TITLE,
|
||||
cwd: '/tmp',
|
||||
archived: 0,
|
||||
rewind_count: 0,
|
||||
compression_fallback_streak: 0,
|
||||
messages,
|
||||
}
|
||||
|
||||
fs.writeFileSync(fixturePath, JSON.stringify(session), 'utf8')
|
||||
}
|
||||
|
||||
/** Resolve the python binary from the nix devshell (falls back to python3). */
|
||||
function findPython(): string {
|
||||
const result = spawnSync('which', ['python'], { encoding: 'utf8' })
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim()
|
||||
}
|
||||
return 'python3'
|
||||
return turns
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock-backend sandbox with a pre-seeded session in state.db.
|
||||
* Set up a mock-backend sandbox with a real persisted session in state.db.
|
||||
*
|
||||
* Unlike the shared `setupMockBackend()`, this variant seeds the DB
|
||||
* BEFORE launching the app so the session appears in the sidebar on first
|
||||
* load — exercising the real `resumeSession()` cold path without needing
|
||||
* to send a message first.
|
||||
* Unlike the shared `setupMockBackend()`, this variant creates the session
|
||||
* through the real stdio gateway before launching desktop so the session is
|
||||
* visible in the sidebar on first load.
|
||||
*/
|
||||
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
@@ -140,28 +105,13 @@ async function setupSeededMockBackend(): Promise<MockBackendFixture> {
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Pre-seed state.db: generate a fixture JSON to a temp file, then
|
||||
// run the seed script to import it into state.db BEFORE launching.
|
||||
const stateDbPath = path.join(sandbox.hermesHome, 'state.db')
|
||||
const fixturePath = path.join(os.tmpdir(), `hermes-e2e-warm-resume-${Date.now()}.json`)
|
||||
generateSessionFixture(fixturePath)
|
||||
const python = findPython()
|
||||
const seedResult = spawnSync(
|
||||
python,
|
||||
[SEED_SCRIPT, stateDbPath, fixturePath],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, PYTHONPATH: REPO_ROOT },
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
},
|
||||
)
|
||||
fs.unlinkSync(fixturePath)
|
||||
|
||||
if (seedResult.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to seed state.db:\nstdout: ${seedResult.stdout}\nstderr: ${seedResult.stderr}`,
|
||||
)
|
||||
// 3. Produce all 16 user/assistant pairs through the real TUI gateway,
|
||||
// AIAgent, mock provider, and SessionDB persistence path before desktop starts.
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
try {
|
||||
await builder.createSession({ title: SESSION_TITLE, turns: generateSessionTurns() })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
// 4. Build env + launch
|
||||
|
||||
@@ -54,7 +54,11 @@
|
||||
"test:desktop:platforms": "vitest run --project electron",
|
||||
"test": "vitest run",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
|
||||
"check": "npm run typecheck && npm run test && npm run test:desktop:all",
|
||||
"check:test:desktop:platforms": "npm run test:desktop:platforms",
|
||||
"check:test:ui": "npm run test:ui",
|
||||
"check:test:desktop:all": "npm run test:desktop:all",
|
||||
"check:lint": "npm run typecheck && npm run lint",
|
||||
"check": "npm run check:lint && npm run check:test:ui && npm run test:desktop:platforms && npm run test:desktop:all",
|
||||
"test:e2e": "npm run build && playwright test e2e/",
|
||||
"test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
|
||||
"test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
|
||||
|
||||
@@ -65,15 +65,15 @@ describe('ComposerControls shortcut tooltips', () => {
|
||||
await expectShortcutTooltip('Send', '↵')
|
||||
})
|
||||
|
||||
it('shows Ctrl+Enter for Steer', async () => {
|
||||
it('shows Enter for Steer', async () => {
|
||||
renderControls({ busy: true, busyAction: 'steer' })
|
||||
|
||||
await expectShortcutTooltip('Steer the current run', 'Ctrl+↵')
|
||||
await expectShortcutTooltip('Steer the current run', '↵')
|
||||
})
|
||||
|
||||
it('shows Enter for Queue', async () => {
|
||||
it('shows Ctrl+Enter for Queue', async () => {
|
||||
renderControls({ busy: true, busyAction: 'queue' })
|
||||
|
||||
await expectShortcutTooltip('Queue message', '↵')
|
||||
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,7 +81,7 @@ export function ComposerControls({
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
{busyAction === 'steer' ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.send" text={c.queueMessage} />}>
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
|
||||
<Button
|
||||
aria-label={c.queueMessage}
|
||||
className={GHOST_ICON_BTN}
|
||||
@@ -116,7 +116,13 @@ export function ComposerControls({
|
||||
label={
|
||||
busy ? (
|
||||
<TipKeybindLabel
|
||||
actionId={busyAction === 'steer' ? 'composer.steer' : 'composer.send'}
|
||||
actionId={
|
||||
busyAction === 'steer'
|
||||
? 'composer.steer'
|
||||
: busyAction === 'queue'
|
||||
? 'composer.queue'
|
||||
: 'composer.send'
|
||||
}
|
||||
text={busyLabel}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
@@ -60,6 +60,7 @@ export function useComposerVoice({
|
||||
onTranscribeAudio
|
||||
})
|
||||
|
||||
/** Auto-speak selector: the latest unspoken reply only — a backlog collapses to the newest. */
|
||||
const pendingResponse = () => {
|
||||
const messages = $messages.get()
|
||||
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
|
||||
@@ -81,6 +82,13 @@ export function useComposerVoice({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice-conversation selector: every unspoken assistant bubble of the turn,
|
||||
* in order — narration interims AND the final answer, not just whichever
|
||||
* bubble happens to be last. See `collectUnspokenTurnSpeech`.
|
||||
*/
|
||||
const pendingTurnResponse = () => collectUnspokenTurnSpeech($messages.get(), lastSpokenIdRef.current)
|
||||
|
||||
const consumePendingResponse = () => {
|
||||
const messages = $messages.get()
|
||||
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
|
||||
@@ -108,7 +116,7 @@ export function useComposerVoice({
|
||||
onFatalError: () => setVoiceConversationActive(false),
|
||||
onSubmit: submitVoiceTurn,
|
||||
onTranscribeAudio,
|
||||
pendingResponse
|
||||
pendingResponse: pendingTurnResponse
|
||||
})
|
||||
|
||||
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
|
||||
|
||||
@@ -587,14 +587,22 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Enter is reserved for steering the live run — never a send.
|
||||
// Steer when there's a steerable draft, otherwise swallow it so it can't
|
||||
// surprise-send. (Plain Enter still queues while busy / sends when idle.)
|
||||
// Cmd/Ctrl+Enter queues a follow-up while a turn runs. Plain Enter steers
|
||||
// a text-only draft, so both live-turn actions stay reachable by keyboard.
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
if (canSteer) {
|
||||
steerDraft()
|
||||
if (busy && !disabled) {
|
||||
// As with plain Enter, source the just-typed content from the DOM so a
|
||||
// fast keypress cannot queue a stale draft.
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
|
||||
if (editorText !== draftRef.current) {
|
||||
draftRef.current = editorText
|
||||
setComposerText(editorText)
|
||||
}
|
||||
|
||||
queueDraft()
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -21,8 +21,8 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useModelControls } from '@/app/session/hooks/use-model-controls'
|
||||
import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
|
||||
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
|
||||
import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Dev-only: drive the credit-notice UX without a backend that can hit real
|
||||
// usage bands. Each trigger emits ONE synthetic `notification.show` /
|
||||
// `notification.clear` gateway event through the real fan-out
|
||||
// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in
|
||||
// `use-message-stream/gateway-event.ts` — toast render, key-replacement
|
||||
// escalation, TTL self-dismiss, native OS notification, and billing re-poll.
|
||||
//
|
||||
// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none
|
||||
// of this ships in a production build.
|
||||
|
||||
import type { GatewayEvent } from '@hermes/shared'
|
||||
|
||||
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { CreditCard } from '@/lib/icons'
|
||||
import { emitLocalGatewayEvent } from '@/store/gateway'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
interface NoticeStep {
|
||||
key: string
|
||||
level: string
|
||||
kind: 'sticky' | 'ttl'
|
||||
text: string
|
||||
ttl_ms?: number
|
||||
}
|
||||
|
||||
// Walks the same lifecycle the Nous credits tracker drives: usage escalates in
|
||||
// place (50→75→90, one key), then grant-spent, then the depleted/restored pair.
|
||||
// Wraps around. These are all separate SHOW steps; the stepper auto-clears the
|
||||
// previous notice when the key changes, so the demo shows one toast at a time
|
||||
// (real usage CAN stack these, but that's noise when you're eyeballing a single
|
||||
// transition). The same-key usage steps still demonstrate in-place escalation.
|
||||
const STEPS: readonly NoticeStep[] = [
|
||||
{ key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" },
|
||||
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" },
|
||||
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" },
|
||||
{ key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' },
|
||||
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
|
||||
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }
|
||||
]
|
||||
|
||||
let cursor = 0
|
||||
let lastShownKey: null | string = null
|
||||
|
||||
function clearNotice(key: string): void {
|
||||
emitLocalGatewayEvent({
|
||||
payload: { key },
|
||||
session_id: $activeSessionId.get() ?? '',
|
||||
type: 'notification.clear'
|
||||
} as GatewayEvent)
|
||||
}
|
||||
|
||||
function showNotice(step: NoticeStep): void {
|
||||
emitLocalGatewayEvent({
|
||||
payload: {
|
||||
id: `${step.key}:${Date.now()}`,
|
||||
key: step.key,
|
||||
kind: step.kind,
|
||||
level: step.level,
|
||||
text: step.text,
|
||||
ttl_ms: step.ttl_ms ?? null
|
||||
},
|
||||
session_id: $activeSessionId.get() ?? '',
|
||||
type: 'notification.show'
|
||||
} as GatewayEvent)
|
||||
}
|
||||
|
||||
/** Fire the next notice in the scripted sequence, wrapping at the end. */
|
||||
export function stepCreditsNoticeDemo(): void {
|
||||
const step = STEPS[cursor % STEPS.length]
|
||||
|
||||
// One toast at a time: retire the previous notice when we move to a new key.
|
||||
// (Same-key steps skip this, so the usage line still escalates in place.)
|
||||
if (lastShownKey && lastShownKey !== step.key) {
|
||||
clearNotice(lastShownKey)
|
||||
}
|
||||
|
||||
showNotice(step)
|
||||
lastShownKey = step.key
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
// The hotkey: Ctrl+Shift+C on every platform (Ctrl, not Cmd, so it can't clash
|
||||
// with a system Cmd chord and works the same on Windows/Linux). Matched on
|
||||
// `code` so it's keyboard-layout independent, and captured before the composer
|
||||
// so a focused input can't swallow it.
|
||||
function isTriggerChord(e: KeyboardEvent): boolean {
|
||||
return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey && e.code === 'KeyC'
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the dev trigger: a capture-phase hotkey (Ctrl+Shift+C), a ⌘K palette
|
||||
* entry, and a `window.__creditsDemo()` console hook. Returns a disposer.
|
||||
*/
|
||||
export function installCreditsNoticeDemo(): () => void {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isTriggerChord(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
stepCreditsNoticeDemo()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
;(window as unknown as { __creditsDemo?: () => void }).__creditsDemo = stepCreditsNoticeDemo
|
||||
|
||||
const disposePalette = registry.register({
|
||||
id: 'dev.creditsNotice',
|
||||
area: PALETTE_AREA,
|
||||
data: {
|
||||
id: 'dev.creditsNotice',
|
||||
icon: CreditCard,
|
||||
keywords: ['credits', 'notice', 'toast', 'billing', 'dev', 'demo'],
|
||||
label: 'Dev: cycle credit notices',
|
||||
run: stepCreditsNoticeDemo
|
||||
} satisfies PaletteContribution
|
||||
})
|
||||
|
||||
console.info('[dev] credit-notice demo ready — press Ctrl+Shift+C, or run window.__creditsDemo()')
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
disposePalette()
|
||||
delete (window as unknown as { __creditsDemo?: () => void }).__creditsDemo
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,15 @@ import { useEffect, useRef } from 'react'
|
||||
import { closeActiveTab } from '@/app/chat/close-tab'
|
||||
import { storedSessionIdForNotification } from '@/lib/session-ids'
|
||||
import { respondToApprovalAction } from '@/store/native-notifications'
|
||||
import { getRememberedRoute, getRememberedSessionId, setRememberedRoute, setRememberedSessionId } from '@/store/session'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import {
|
||||
$sessions,
|
||||
getRememberedRoute,
|
||||
getRememberedSessionId,
|
||||
rememberedSessionProfile,
|
||||
setRememberedRoute,
|
||||
setRememberedSessionId
|
||||
} from '@/store/session'
|
||||
import { onSessionsChanged } from '@/store/session-sync'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
@@ -63,7 +71,10 @@ export function useDesktopIntegrations({
|
||||
// you don't want to boot into a modal.
|
||||
useEffect(() => {
|
||||
if (routedSessionId) {
|
||||
setRememberedSessionId(routedSessionId)
|
||||
setRememberedSessionId(
|
||||
routedSessionId,
|
||||
rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get())
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOverlayView(appViewForPath(locationPathname))) {
|
||||
@@ -92,7 +103,7 @@ export function useDesktopIntegrations({
|
||||
return
|
||||
}
|
||||
|
||||
const last = getRememberedSessionId()
|
||||
const last = getRememberedSessionId($activeGatewayProfile.get())
|
||||
|
||||
if (last) {
|
||||
navigate(sessionRoute(last), { replace: true })
|
||||
@@ -100,8 +111,14 @@ export function useDesktopIntegrations({
|
||||
}, [locationPathname, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
|
||||
setRememberedSessionId(null)
|
||||
if (!resumeExhaustedSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const owner = rememberedSessionProfile($sessions.get(), resumeExhaustedSessionId, $activeGatewayProfile.get())
|
||||
|
||||
if (getRememberedSessionId(owner) === resumeExhaustedSessionId) {
|
||||
setRememberedSessionId(null, owner)
|
||||
}
|
||||
}, [resumeExhaustedSessionId])
|
||||
|
||||
@@ -155,8 +172,8 @@ export function useDesktopIntegrations({
|
||||
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
|
||||
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() =>
|
||||
void closeActiveTab(id => navigate(sessionRoute(id)))
|
||||
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(
|
||||
() => void closeActiveTab(id => navigate(sessionRoute(id)))
|
||||
)
|
||||
|
||||
return () => unsubscribe?.()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as HermesModule from '@/hermes'
|
||||
import { setSessions } from '@/store/session'
|
||||
import { sessionTileDelegate } from '@/store/session-states'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { useSessionTileDelegate } from './use-session-tile-delegate'
|
||||
|
||||
vi.mock('@/hermes', async importActual => ({
|
||||
...(await importActual<typeof HermesModule>()),
|
||||
getSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' }))
|
||||
}))
|
||||
|
||||
const { getSessionMessages } = await import('@/hermes')
|
||||
|
||||
const row = (over: Partial<SessionInfo>): SessionInfo =>
|
||||
({
|
||||
ended_at: null,
|
||||
id: 'live',
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 0,
|
||||
message_count: 1,
|
||||
model: null,
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
profile: 'default',
|
||||
source: null,
|
||||
started_at: 0,
|
||||
title: null,
|
||||
...over
|
||||
}) as SessionInfo
|
||||
|
||||
function renderTile(requestGateway: ReturnType<typeof vi.fn>) {
|
||||
renderHook(() =>
|
||||
useSessionTileDelegate({
|
||||
archiveSession: vi.fn(async () => undefined),
|
||||
branchStoredSession: vi.fn(async () => undefined),
|
||||
executeSlashCommand: vi.fn(async () => undefined) as never,
|
||||
removeSession: vi.fn(async () => undefined),
|
||||
requestGateway: requestGateway as never,
|
||||
runtimeIdByStoredSessionIdRef: { current: new Map() },
|
||||
sessionStateByRuntimeIdRef: { current: new Map() },
|
||||
updateSessionState: vi.fn()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('useSessionTileDelegate resumeTile', () => {
|
||||
beforeEach(() => {
|
||||
setSessions([])
|
||||
vi.mocked(getSessionMessages).mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setSessions([])
|
||||
})
|
||||
|
||||
it('carries the owning profile into a cold tile resume so it cannot fork profiles', async () => {
|
||||
// A tile opens a session owned by another profile. Resuming without the
|
||||
// profile lets the gateway fall back to the launch-profile DB and clone the
|
||||
// conversation into the wrong profile (#67603). The owning profile must ride
|
||||
// both the transcript prefetch and the resume RPC.
|
||||
setSessions([row({ id: 'stored-x', profile: 'ai-engineer' })])
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) =>
|
||||
method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never)
|
||||
)
|
||||
|
||||
renderTile(requestGateway)
|
||||
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x')
|
||||
|
||||
expect(runtimeId).toBe('runtime-1')
|
||||
expect(getSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer')
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-x',
|
||||
cols: 96,
|
||||
profile: 'ai-engineer'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves and carries a default-profile session explicitly', async () => {
|
||||
setSessions([row({ id: 'stored-y', profile: 'default' })])
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) =>
|
||||
method === 'session.resume' ? ({ session_id: 'runtime-2' } as never) : ({} as never)
|
||||
)
|
||||
|
||||
renderTile(requestGateway)
|
||||
await sessionTileDelegate()!.resumeTile('stored-y')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-y',
|
||||
cols: 96,
|
||||
profile: 'default'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { publishSessionState, setSessionTileDelegate } from '@/store/session-sta
|
||||
import type { SessionResumeResponse } from '@/types/hermes'
|
||||
|
||||
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
|
||||
import { resolveSessionProfile } from '../../session/hooks/use-session-actions/utils'
|
||||
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
@@ -66,9 +67,20 @@ export function useSessionTileDelegate({
|
||||
return existing
|
||||
}
|
||||
|
||||
// Resolve the owning profile before binding a runtime. A tile can open a
|
||||
// session from any profile, not just the active one; resuming (or
|
||||
// reading messages) without a profile lets the gateway fall back to the
|
||||
// launch-profile DB and fork the conversation into the wrong profile —
|
||||
// the same cross-profile bleed the recovery resumes had (#67603).
|
||||
const profile = await resolveSessionProfile(storedSessionId)
|
||||
|
||||
const [prefetch, resumed] = await Promise.all([
|
||||
getSessionMessages(storedSessionId).catch(() => null),
|
||||
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
|
||||
getSessionMessages(storedSessionId, profile).catch(() => null),
|
||||
requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
...(profile ? { profile } : {})
|
||||
})
|
||||
])
|
||||
|
||||
const runtimeId = resumed?.session_id
|
||||
|
||||
@@ -14,12 +14,12 @@ import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEff
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { DesktopOnboardingOverlay } from '@/components/onboarding'
|
||||
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { emitGatewayEvent } from '@/contrib/events'
|
||||
@@ -272,6 +272,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds)
|
||||
}, [navigate])
|
||||
|
||||
// Dev-only: install the credit-notice demo trigger (Ctrl+Shift+C / ⌘K palette
|
||||
// / window.__creditsDemo). Dynamic import inside the DEV guard so the module
|
||||
// is dropped from production builds.
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) {
|
||||
return
|
||||
}
|
||||
|
||||
let dispose: (() => void) | undefined
|
||||
|
||||
void import('./dev/credits-notice-demo').then(m => {
|
||||
dispose = m.installCreditsNoticeDemo()
|
||||
})
|
||||
|
||||
return () => dispose?.()
|
||||
}, [])
|
||||
|
||||
// Post-turn rehydrate from stored history (same behavior as DesktopController,
|
||||
// including finished-todos restoration).
|
||||
const hydrateFromStoredSession = useCallback(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
type GatewaySurvivor,
|
||||
stashGatewaySurvivor,
|
||||
survivorIsStale,
|
||||
takeGatewaySurvivor
|
||||
} from './gateway-hmr-survivor'
|
||||
|
||||
// A minimal stand-in for HermesGateway: the survivor cache only reads
|
||||
// `connectionState`. Cast through unknown so we don't drag the whole class in.
|
||||
function fakeGateway(state: 'idle' | 'connecting' | 'open' | 'closed' | 'error') {
|
||||
return { connectionState: state } as unknown as GatewaySurvivor['gateway']
|
||||
}
|
||||
|
||||
function makeSurvivor(state: Parameters<typeof fakeGateway>[0]): GatewaySurvivor {
|
||||
return { gateway: fakeGateway(state), profile: 'default', connection: null }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Drain any survivor a test parked but didn't take, so cases don't leak across
|
||||
// the shared globalThis slot.
|
||||
takeGatewaySurvivor()
|
||||
})
|
||||
|
||||
describe('gateway HMR survivor cache', () => {
|
||||
it('returns null when nothing is parked', () => {
|
||||
expect(takeGatewaySurvivor()).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips a parked survivor', () => {
|
||||
const survivor = makeSurvivor('open')
|
||||
stashGatewaySurvivor(survivor)
|
||||
expect(takeGatewaySurvivor()).toBe(survivor)
|
||||
})
|
||||
|
||||
it('is single-shot — a second take returns null', () => {
|
||||
stashGatewaySurvivor(makeSurvivor('open'))
|
||||
expect(takeGatewaySurvivor()).not.toBeNull()
|
||||
expect(takeGatewaySurvivor()).toBeNull()
|
||||
})
|
||||
|
||||
it('persists across re-imports via the globalThis slot', async () => {
|
||||
const survivor = makeSurvivor('open')
|
||||
stashGatewaySurvivor(survivor)
|
||||
|
||||
// A fresh import of the module (simulating an HMR module swap) must resolve
|
||||
// the same underlying store, not a reset one.
|
||||
const reimported = await import('./gateway-hmr-survivor')
|
||||
expect(reimported.takeGatewaySurvivor()).toBe(survivor)
|
||||
})
|
||||
|
||||
it('treats open / connecting sockets as adoptable', () => {
|
||||
expect(survivorIsStale(makeSurvivor('open'))).toBe(false)
|
||||
expect(survivorIsStale(makeSurvivor('connecting'))).toBe(false)
|
||||
})
|
||||
|
||||
it('treats closed / error / idle sockets as stale', () => {
|
||||
expect(survivorIsStale(makeSurvivor('closed'))).toBe(true)
|
||||
expect(survivorIsStale(makeSurvivor('error'))).toBe(true)
|
||||
expect(survivorIsStale(makeSurvivor('idle'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns a stale survivor from take() so the caller can close it', () => {
|
||||
const dead = makeSurvivor('closed')
|
||||
stashGatewaySurvivor(dead)
|
||||
const taken = takeGatewaySurvivor()
|
||||
expect(taken).toBe(dead)
|
||||
expect(taken && survivorIsStale(taken)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
// Keep the live primary gateway socket alive across Vite HMR so editing UI never
|
||||
// drops the agent session. Stash on dispose, re-adopt on remount. globalThis +
|
||||
// self-accept so this module's own reload doesn't reset the cache. Prod strips
|
||||
// import.meta.hot → byte-for-byte unchanged live unmount.
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
|
||||
export interface GatewaySurvivor {
|
||||
gateway: HermesGateway
|
||||
profile: string
|
||||
connection: HermesConnection | null
|
||||
}
|
||||
|
||||
// One slot on globalThis, keyed by a process-stable Symbol so repeated imports
|
||||
// (across hot reloads) resolve the exact same store.
|
||||
const SURVIVOR_KEY = Symbol.for('hermes.desktop.gatewaySurvivor')
|
||||
|
||||
interface SurvivorGlobal {
|
||||
[SURVIVOR_KEY]?: GatewaySurvivor | null
|
||||
}
|
||||
|
||||
function slot(): SurvivorGlobal {
|
||||
return globalThis as unknown as SurvivorGlobal
|
||||
}
|
||||
|
||||
/** Park the live gateway so the next module instance can re-adopt it. */
|
||||
export function stashGatewaySurvivor(survivor: GatewaySurvivor): void {
|
||||
slot()[SURVIVOR_KEY] = survivor
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the parked gateway, if any. Single-shot: the slot is cleared on read.
|
||||
* The caller decides whether to adopt or discard it via `survivorIsStale` — a
|
||||
* socket that died while parked (e.g. backend restart between edits) is still
|
||||
* returned so the caller can close it and boot fresh.
|
||||
*/
|
||||
export function takeGatewaySurvivor(): GatewaySurvivor | null {
|
||||
const store = slot()
|
||||
const survivor = store[SURVIVOR_KEY] ?? null
|
||||
store[SURVIVOR_KEY] = null
|
||||
|
||||
return survivor
|
||||
}
|
||||
|
||||
/** A parked survivor whose socket is no longer open — caller should discard it. */
|
||||
export function survivorIsStale(survivor: GatewaySurvivor): boolean {
|
||||
const state = survivor.gateway.connectionState
|
||||
|
||||
return state !== 'open' && state !== 'connecting'
|
||||
}
|
||||
|
||||
// Self-accept so editing THIS module doesn't blow away the cache it manages.
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
import { takeGatewaySurvivor } from './gateway-hmr-survivor'
|
||||
import { useGatewayBoot } from './use-gateway-boot'
|
||||
|
||||
// End-to-end-ish repro of the "remote VPS → stuck on CONNECTING, no Settings"
|
||||
@@ -131,6 +132,17 @@ function Harness({
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
beforeEach(() => {
|
||||
// Drop any parked gateway left by a prior file/case (globalThis slot).
|
||||
const leftover = takeGatewaySurvivor()
|
||||
|
||||
if (leftover) {
|
||||
try {
|
||||
leftover.gateway.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
vi.useFakeTimers()
|
||||
FakeWebSocket.mode = 'open'
|
||||
FakeWebSocket.instances = []
|
||||
@@ -152,6 +164,19 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
// Vitest keeps import.meta.hot truthy, so the boot effect's cleanup parks an
|
||||
// open gateway instead of tearing it down (the real HMR path). Drain + close
|
||||
// that survivor so the next test boots a fresh socket instead of adoptBoot().
|
||||
const survivor = takeGatewaySurvivor()
|
||||
|
||||
if (survivor) {
|
||||
try {
|
||||
survivor.gateway.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
vi.useRealTimers()
|
||||
;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor'
|
||||
|
||||
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
|
||||
// raise a recoverable boot error. Otherwise a dropped remote gateway loops the
|
||||
// backoff forever behind the fullscreen CONNECTING overlay with no way to reach
|
||||
@@ -337,9 +339,28 @@ export function useGatewayBoot({
|
||||
progress: 6
|
||||
})
|
||||
|
||||
const gateway = new HermesGateway()
|
||||
// HMR adoption: in a dev hot update, the previous effect instance parked its
|
||||
// still-open socket instead of closing it (see the cleanup below). Re-adopt
|
||||
// it so an edit doesn't drop the live agent session. A stale (closed) parked
|
||||
// socket is discarded and we boot fresh. No-op in production: import.meta.hot
|
||||
// is undefined there, so this folds to `null` and the whole survivor module
|
||||
// dead-code-eliminates out of the bundle.
|
||||
const survivor = import.meta.hot ? takeGatewaySurvivor() : null
|
||||
const adoptedFromHmr = Boolean(survivor && !survivorIsStale(survivor))
|
||||
|
||||
if (survivor && !adoptedFromHmr) {
|
||||
// Parked socket died between edits (e.g. backend restart) — release it.
|
||||
try {
|
||||
survivor.gateway.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const gateway = adoptedFromHmr ? survivor!.gateway : new HermesGateway()
|
||||
|
||||
callbacksRef.current.onGatewayReady(gateway)
|
||||
setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get()))
|
||||
setPrimaryGateway(gateway, survivor?.profile ?? normalizeProfileKey($activeGatewayProfile.get()))
|
||||
// Secondary (background-profile) sockets funnel into the same handler.
|
||||
configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) })
|
||||
|
||||
@@ -513,7 +534,42 @@ export function useGatewayBoot({
|
||||
}
|
||||
}
|
||||
|
||||
void boot()
|
||||
// Adopt the parked socket without re-running the full boot handshake: the
|
||||
// socket is already open, the backend session is untouched, and we already
|
||||
// know the profile. We only re-publish the connection, re-sync config +
|
||||
// sessions (cheap, and the backend may have moved on between edits), and
|
||||
// dismiss any boot overlay. This is what keeps a live, mid-stream session
|
||||
// intact across an HMR update.
|
||||
async function adoptBoot() {
|
||||
bootCompleted = true
|
||||
completeDesktopBoot()
|
||||
|
||||
if (survivor?.connection) {
|
||||
publish(survivor.connection)
|
||||
}
|
||||
|
||||
const profile = survivor?.profile ?? $activeGatewayProfile.get()
|
||||
$activeGatewayProfile.set(profile)
|
||||
void ensureGatewayForProfile(profile)
|
||||
|
||||
// Mirror the current (already-open) socket state into the composer so the
|
||||
// input doesn't sit disabled after the swap.
|
||||
reportPrimaryGatewayState(gateway.connectionState)
|
||||
|
||||
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
await callbacksRef.current.refreshSessions().catch(() => undefined)
|
||||
}
|
||||
|
||||
if (adoptedFromHmr) {
|
||||
void adoptBoot()
|
||||
} else {
|
||||
void boot()
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -532,6 +588,24 @@ export function useGatewayBoot({
|
||||
offExit()
|
||||
offWindowState?.()
|
||||
offBootProgress()
|
||||
|
||||
// HMR teardown vs. real unmount. On a hot update we must NOT close the
|
||||
// socket — that's the whole bug. Detach this instance's listeners (their
|
||||
// closures capture the disposed module), park the still-open gateway, and
|
||||
// let the freshly loaded effect re-adopt it. Secondaries are owned by the
|
||||
// gateway store (HMR-stable module state), so they survive untouched.
|
||||
// Production: import.meta.hot is undefined, so this branch never runs and
|
||||
// the original destructive teardown below is byte-for-byte preserved.
|
||||
if (import.meta.hot && gateway.connectionState === 'open') {
|
||||
stashGatewaySurvivor({
|
||||
gateway,
|
||||
profile: survivor?.profile ?? $activeGatewayProfile.get(),
|
||||
connection: $connection.get()
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
closeSecondaryGateways()
|
||||
gateway.close()
|
||||
publish(null)
|
||||
|
||||
@@ -66,6 +66,13 @@ export function OverlayView({
|
||||
'p-[calc(var(--titlebar-height)+0.625rem)]',
|
||||
'sm:p-[calc(var(--titlebar-height)+0.875rem)]'
|
||||
)}
|
||||
// Every OverlayView-based overlay (settings, command-center, agents, cron,
|
||||
// profiles, star map, …) covers the chat while the composer stays mounted
|
||||
// beneath it. This marker tells `composerFocusBlockedBySurface` to stand
|
||||
// the global type-to-focus / soft `/` / Enter down, so keystrokes don't
|
||||
// leak into the hidden composer (and the overlay's own bare-key shortcuts,
|
||||
// e.g. star map's Space, keep working).
|
||||
data-overlay-surface=""
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeOverlay()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { type MutableRefObject, useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
// A `clarify.request` must leave an answerable inline row even when the
|
||||
// `tool.start` that normally mounts it was missed (stream reconnect /
|
||||
// hydration race). Without it the sidebar says "needs input" but the
|
||||
// transcript has nowhere to render the choices, so the agent blocks forever.
|
||||
|
||||
const SID = 'session-1'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stateRef: MutableRefObject<Map<string, ClientSessionState>> | null = null
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
stateRef = sessionStateByRuntimeIdRef
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
}
|
||||
|
||||
const clarifyRequest = (payload: Record<string, unknown>) =>
|
||||
act(() => handleEvent!({ payload, session_id: SID, type: 'clarify.request' }))
|
||||
|
||||
const toolStart = (payload: Record<string, unknown>) =>
|
||||
act(() => handleEvent!({ payload, session_id: SID, type: 'tool.start' }))
|
||||
|
||||
function clarifyParts() {
|
||||
const messages = stateRef?.current.get(SID)?.messages ?? []
|
||||
|
||||
return messages.flatMap(m => m.parts).filter(p => p.type === 'tool-call' && p.toolName === 'clarify')
|
||||
}
|
||||
|
||||
describe('clarify.request stream hydration', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
stateRef = null
|
||||
clearClarifyRequest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearClarifyRequest()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('mounts an answerable clarify row when the tool.start row was missed', async () => {
|
||||
await mountStream()
|
||||
|
||||
clarifyRequest({ choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-1' })
|
||||
|
||||
const parts = clarifyParts()
|
||||
expect(parts).toHaveLength(1)
|
||||
expect(parts[0].type === 'tool-call' && parts[0].toolCallId).toBe('req-1')
|
||||
expect(parts[0].type === 'tool-call' && parts[0].args).toMatchObject({
|
||||
choices: ['yes', 'no'],
|
||||
question: 'Ship it?'
|
||||
})
|
||||
})
|
||||
|
||||
it('merges with the real tool.start row even though its id differs from the request id', async () => {
|
||||
await mountStream()
|
||||
|
||||
// Reality: tool.start carries the model's tool_call_id, clarify.request a
|
||||
// separately-generated request_id. They must still collapse to ONE card
|
||||
// (correlated by question), not two.
|
||||
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-abc' })
|
||||
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-2' })
|
||||
|
||||
expect(clarifyParts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not duplicate when clarify.request arrives before the tool.start row', async () => {
|
||||
await mountStream()
|
||||
|
||||
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-3' })
|
||||
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-xyz' })
|
||||
|
||||
expect(clarifyParts()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -15,15 +15,16 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
|
||||
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
|
||||
import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||
import { revealDesktopPane } from '@/store/pane-focus'
|
||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
@@ -407,9 +408,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
|
||||
if (typeof payload?.credential_warning === 'string' && payload.credential_warning) {
|
||||
requestDesktopOnboarding(payload.credential_warning)
|
||||
}
|
||||
requestDesktopOnboardingForCredentialWarning(payload?.credential_warning)
|
||||
|
||||
if (apply) {
|
||||
reportInstallMethodWarning(payload?.install_warning)
|
||||
@@ -677,21 +676,36 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
// over; the inline ClarifyTool reads the active session's entry.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
const question = typeof payload?.question === 'string' ? payload.question : ''
|
||||
const rawChoices = payload?.choices
|
||||
const choices = normalizeChoices(rawChoices)
|
||||
|
||||
if (requestId && question) {
|
||||
if (rawChoices != null && choices.length === 0) {
|
||||
warnDroppedChoices('gateway', question, rawChoices)
|
||||
}
|
||||
|
||||
setClarifyRequest({
|
||||
requestId,
|
||||
question,
|
||||
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
|
||||
choices: choices.length > 0 ? choices : null,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
// The transcript only renders the active session, so a background
|
||||
// clarify is otherwise invisible (the row just keeps spinning like
|
||||
// it's working). Flag the session so the sidebar shows a persistent
|
||||
// "needs input" indicator on its row — works for the active session
|
||||
// too, and survives alt-tab / window blur (unlike a toast).
|
||||
if (sessionId) {
|
||||
// `clarify.request` is the blocking event the Python side waits on,
|
||||
// while the inline UI normally mounts from the earlier `tool.start`
|
||||
// row. If that row was missed (stream reconnect / hydration race) the
|
||||
// sidebar still says "needs input" but there is nowhere to render the
|
||||
// choices. Upsert a stable pending clarify tool row from the request
|
||||
// itself so the prompt stays answerable; a real tool.start/complete
|
||||
// with the same request id merges rather than duplicates.
|
||||
upsertToolCall(sessionId, { args: { choices, question }, name: 'clarify', tool_id: requestId }, 'running')
|
||||
|
||||
// The transcript only renders the active session, so a background
|
||||
// clarify is otherwise invisible (the row just keeps spinning like
|
||||
// it's working). Flag the session so the sidebar shows a persistent
|
||||
// "needs input" indicator on its row — works for the active session
|
||||
// too, and survives alt-tab / window blur (unlike a toast).
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
@@ -852,6 +866,37 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
]
|
||||
}))
|
||||
}
|
||||
} else if (event.type === 'notification.show') {
|
||||
// Driver-agnostic agent notice (credits usage/grant/depleted/restored
|
||||
// from `agent/credits_tracker.py`). The Ink TUI renders these in its
|
||||
// status bar; the desktop renders them as toasts. The notice key doubles
|
||||
// as the toast id, so the escalating 50→75→90 credits line replaces in
|
||||
// place instead of stacking. Account-wide signal — shown regardless of
|
||||
// which session is focused.
|
||||
const notice = event.payload as AgentNoticePayload | undefined
|
||||
|
||||
showAgentNotice(notice)
|
||||
|
||||
// The urgent pair (access paused / restored) also breaks through as a
|
||||
// native OS notification when Hermes is backgrounded; dispatch is gated
|
||||
// by the user's notification prefs + backgrounded check.
|
||||
const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle'))
|
||||
|
||||
if (native) {
|
||||
dispatchNativeNotification(native)
|
||||
}
|
||||
|
||||
// A credits crossing moves the account balance. Settings → Billing polls
|
||||
// `billing.state` every 30s; nudge it so the page reflects the crossing
|
||||
// immediately instead of up to 30s late.
|
||||
if (notice?.key?.startsWith('credits.')) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||
}
|
||||
} else if (event.type === 'notification.clear') {
|
||||
// Key-matched dismissal (e.g. credits restored clears the depleted
|
||||
// notice). notify() keys the toast by the notice key, so this maps
|
||||
// straight to dismissNotification(key).
|
||||
clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
|
||||
} else if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
|
||||
@@ -501,27 +501,36 @@ export function useMessageStream({
|
||||
const existing = prev[index]
|
||||
const existingText = chatMessageText(existing).trim()
|
||||
|
||||
// The last assistant row is a sealed interim (a tool-call turn or a
|
||||
// verify-on-stop candidate — `message.interim` fires for BOTH, see
|
||||
// tui_gateway `_load_interim_assistant_messages`). When the final
|
||||
// completion is the SAME turn's reply, settle it onto that interim
|
||||
// instead of appending a second bubble. Continuity, not exact
|
||||
// equality: streaming can drop characters and the final may add a
|
||||
// trailing delta, so treat prefix-either-way as the same message.
|
||||
// (mergeFinalAssistantText, via completeMessage, does the real
|
||||
// text merge — replaces the interim's text with the full final.)
|
||||
const finalContinuesInterim = Boolean(
|
||||
existing.interim &&
|
||||
finalText &&
|
||||
existingText &&
|
||||
(finalText === existingText || finalText.startsWith(existingText) || existingText.startsWith(finalText))
|
||||
)
|
||||
|
||||
if (existing.pending || (!interimBoundaryPending && finalText && existingText === finalText)) {
|
||||
nextMessages = prev.map((message, messageIndex) =>
|
||||
messageIndex === index ? completeMessage(message) : message
|
||||
)
|
||||
} else if (
|
||||
interimBoundaryPending &&
|
||||
responsePreviewed &&
|
||||
finalText &&
|
||||
existingText &&
|
||||
finalText.startsWith(existingText)
|
||||
) {
|
||||
// The verification candidate was published provisionally as an
|
||||
// interim message and then reused as the terminal response
|
||||
// (continuation-budget fallback). Settle the interim in place
|
||||
// instead of creating a duplicate — the DB has one row, so the
|
||||
// live UI must agree. (#65919 review: duplicate-message blocker)
|
||||
//
|
||||
// Prefix match (not exact equality): the final response may be
|
||||
// the streamed text plus a trailing delta. mergeFinalAssistantText
|
||||
// (called via completeMessage) handles the actual merge — it
|
||||
// strips the old text parts and appends the full final text.
|
||||
} else if (interimBoundaryPending && (responsePreviewed || finalContinuesInterim)) {
|
||||
// Settle the interim in place instead of creating a duplicate —
|
||||
// the DB has one row, so the live UI must agree. Previously this
|
||||
// was gated on `responsePreviewed` alone, so a NON-previewed
|
||||
// tool-call turn whose final matched its sealed interim appended a
|
||||
// second bubble (the "renders twice: partial first copy + clean
|
||||
// final" bug, #63679). `finalContinuesInterim` closes that gap
|
||||
// for ordinary tool-call turns while `responsePreviewed` still
|
||||
// covers the verify-on-stop continuation-budget case even when the
|
||||
// final text was rewritten and no longer shares a prefix.
|
||||
nextMessages = prev.map((message, messageIndex) =>
|
||||
messageIndex === index ? completeMessage(message) : message
|
||||
)
|
||||
|
||||
@@ -186,18 +186,51 @@ describe('useMessageStream interim text sealing', () => {
|
||||
expect(getState().interimBoundaryPending).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an identical final completion distinct from an interim reply without response_previewed', async () => {
|
||||
it('settles an identical final onto a non-previewed interim (tool-call turn) instead of duplicating (#63679)', async () => {
|
||||
await mountStream()
|
||||
await start()
|
||||
|
||||
// A plain tool-call turn: the streamed text is sealed as an interim at the
|
||||
// tool boundary (no response_previewed — that flag is only for verify-on-
|
||||
// stop). The final completion is the SAME turn's reply. It must settle onto
|
||||
// the interim, not append a second bubble — the DB has one row. This is the
|
||||
// "renders twice" bug: partial streamed copy + clean final copy side by side.
|
||||
await interim('same reply')
|
||||
await complete('same reply')
|
||||
|
||||
// Without response_previewed, the interim and terminal replies are
|
||||
// distinct messages — the gateway didn't signal that the final reuses
|
||||
// the provisional candidate.
|
||||
const texts = assistantMessages()
|
||||
expect(texts.filter(t => t === 'same reply')).toHaveLength(2)
|
||||
expect(texts.filter(t => t === 'same reply')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles a prefix-extended final onto a non-previewed interim (streamed + trailing delta)', async () => {
|
||||
await mountStream()
|
||||
await start()
|
||||
|
||||
// The stream dropped/settled early at the tool boundary; the final adds a
|
||||
// trailing delta. Same turn — one bubble with the full final text.
|
||||
await delta('partial')
|
||||
await interim('partial')
|
||||
await complete('partial answer continued')
|
||||
|
||||
const texts = assistantMessages()
|
||||
expect(texts.filter(t => t.includes('partial'))).toHaveLength(1)
|
||||
expect(texts[0]).toBe('partial answer continued')
|
||||
})
|
||||
|
||||
it('appends a genuinely different final as its own bubble (two real assistant segments)', async () => {
|
||||
await mountStream()
|
||||
await start()
|
||||
|
||||
// The interim is one segment (pre-tool commentary); the final is different
|
||||
// content, not a continuation of it. These are two real messages and must
|
||||
// both render — the fix must not over-collapse distinct replies.
|
||||
await interim('let me check the files')
|
||||
await complete('the answer is 42')
|
||||
|
||||
const texts = assistantMessages()
|
||||
expect(texts).toContain('let me check the files')
|
||||
expect(texts).toContain('the answer is 42')
|
||||
expect(texts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('settles an identical final completion onto the interim when response_previewed', async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSession } from '@/hermes'
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
@@ -25,6 +26,7 @@ import { uploadComposerAttachment, usePromptActions } from '.'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
getSession: vi.fn(),
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000,
|
||||
setApiRequestProfile: vi.fn(),
|
||||
transcribeAudio: vi.fn()
|
||||
@@ -1834,6 +1836,99 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
|
||||
})
|
||||
|
||||
// #67603 (second symptom): a recovery resume must re-register on the session's
|
||||
// OWNING profile. Resuming on whichever profile is live forks the conversation
|
||||
// into the wrong profile's DB — the session then appears under both profiles.
|
||||
it('carries the owning profile from the cache into the recovery resume', async () => {
|
||||
setSessions(() => [sessionInfo({ id: STORED_SESSION_ID, profile: 'work' })])
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'prompt.submit') {
|
||||
submitAttempts += 1
|
||||
|
||||
if (submitAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
}
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await handle!.submitText('message after wake')).toBe(true)
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
|
||||
|
||||
setSessions(() => [])
|
||||
})
|
||||
|
||||
// The session lives on another profile and is outside the paginated sidebar
|
||||
// cache: resolve it by id across profiles rather than resuming profile-blind.
|
||||
it('resolves the owning profile across profiles when the session is not cached', async () => {
|
||||
// module-factory vi.fn is not reset by restoreAllMocks — reset explicitly in
|
||||
// the finally below so this resolved value never leaks into sibling tests.
|
||||
setSessions(() => [])
|
||||
vi.mocked(getSession).mockResolvedValue(sessionInfo({ id: STORED_SESSION_ID, profile: 'work' }))
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'prompt.submit') {
|
||||
submitAttempts += 1
|
||||
|
||||
if (submitAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
}
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await handle!.submitText('message after wake')).toBe(true)
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
|
||||
|
||||
vi.mocked(getSession).mockReset()
|
||||
setSessions(() => [])
|
||||
})
|
||||
|
||||
it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let submitAttempts = 0
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
ImageAttachResponse,
|
||||
SessionRedirectResponse
|
||||
} from '../../../types'
|
||||
import { resolveSessionProfile } from '../use-session-actions/utils'
|
||||
|
||||
import {
|
||||
applyBranchVisibility,
|
||||
@@ -601,9 +602,12 @@ export function usePromptActions({
|
||||
|
||||
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
|
||||
try {
|
||||
const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
|
||||
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
@@ -700,9 +704,12 @@ export function usePromptActions({
|
||||
// correction right after a reconnect isn't lost to the race.
|
||||
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
|
||||
try {
|
||||
const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
|
||||
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
@@ -24,6 +24,7 @@ import { setAwaitingResponse, setBusy, setMessages } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../../types'
|
||||
import { sessionContextDrift } from '../session-context-drift'
|
||||
import { resolveSessionProfile } from '../use-session-actions/utils'
|
||||
|
||||
import {
|
||||
_submitInFlight,
|
||||
@@ -384,9 +385,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
// background queue drain only has the durable id). Continue that target
|
||||
// conversation; only a genuine new-chat draft may create a new session.
|
||||
try {
|
||||
// Re-register on the session's OWNING profile — resuming on whichever
|
||||
// profile is live would fork the conversation into the wrong DB (#67603).
|
||||
const resumeProfile = await resolveSessionProfile(targetStoredSessionId)
|
||||
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: targetStoredSessionId,
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
const resumeDrift = sessionDriftReason()
|
||||
@@ -528,9 +534,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
// backend loop (#55578 symptom d) rejects the submit even though
|
||||
// the stored session is fine — resume + retry instead of erroring
|
||||
// out and losing the session binding.
|
||||
const resumeProfile = await resolveSessionProfile(recoverStoredSessionId)
|
||||
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: recoverStoredSessionId,
|
||||
source: 'desktop'
|
||||
source: 'desktop',
|
||||
...(resumeProfile ? { profile: resumeProfile } : {})
|
||||
})
|
||||
|
||||
const resumeRetryDrift = sessionDriftReason()
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSessionMessages, type SessionInfo } from '@/hermes'
|
||||
import { getSession, getSessionMessages, type SessionInfo } from '@/hermes'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile'
|
||||
@@ -43,6 +43,7 @@ import { useSessionActions } from './use-session-actions'
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
deleteSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
getSessionMessages: vi.fn(),
|
||||
listAllProfileSessions: vi.fn(),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
@@ -1088,6 +1089,40 @@ describe('branchStoredSession desktop source tagging', () => {
|
||||
source: 'desktop'
|
||||
})
|
||||
})
|
||||
|
||||
// #67603: right-clicking a session outside the paginated sidebar window is a
|
||||
// cache miss. Resolve its owning profile (cache → active → cross-profile) and
|
||||
// swap to it before reading the transcript / creating the branch, so the fork
|
||||
// is not created on whichever profile happens to be live.
|
||||
it('resolves and swaps to the parent profile when the branched session is not cached', async () => {
|
||||
setSessions([])
|
||||
vi.mocked(getSession).mockResolvedValue(storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' }))
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.create') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let branchStoredSession: ((storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) | null =
|
||||
null
|
||||
|
||||
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
|
||||
expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work')
|
||||
|
||||
vi.mocked(getSession).mockReset()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
|
||||
|
||||
@@ -1177,7 +1177,13 @@ export function useSessionActions({
|
||||
async (storedSessionId: string, sessionProfile?: string | null): Promise<boolean> => {
|
||||
clearNotifications()
|
||||
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
// Right-clicking a session outside the paginated sidebar window is a cache
|
||||
// miss: resolve it (cache → active backend → cross-profile) so the branch
|
||||
// is created on the parent's OWNING profile, not whichever is live (#67603).
|
||||
const stored =
|
||||
$sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ??
|
||||
(sessionProfile ? undefined : await resolveStoredSession(storedSessionId))
|
||||
|
||||
const profile = sessionProfile ?? stored?.profile
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
@@ -38,6 +39,32 @@ describe('applyRuntimeInfo approval mode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const initialOnboardingState = $desktopOnboarding.get()
|
||||
|
||||
describe('applyRuntimeInfo credential warnings', () => {
|
||||
beforeEach(() => {
|
||||
$desktopOnboarding.set({ ...initialOnboardingState, reason: null, requested: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$desktopOnboarding.set(initialOnboardingState)
|
||||
})
|
||||
|
||||
it('requests setup for the exact empty-key warning returned by the server', () => {
|
||||
const warning = "No API key configured for provider 'openrouter'. First message will fail."
|
||||
|
||||
applyRuntimeInfo({ credential_warning: warning })
|
||||
|
||||
expect($desktopOnboarding.get()).toMatchObject({ reason: warning, requested: true })
|
||||
})
|
||||
|
||||
it('ignores an auxiliary-provider warning', () => {
|
||||
applyRuntimeInfo({ credential_warning: 'OPENROUTER_API_KEY not set' })
|
||||
|
||||
expect($desktopOnboarding.get()).toMatchObject({ reason: null, requested: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSessionGoneError', () => {
|
||||
it('is true for 404 / session-not-found, false otherwise', () => {
|
||||
expect(isSessionGoneError(new Error('Request failed 404'))).toBe(true)
|
||||
@@ -361,6 +388,58 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority)
|
||||
})
|
||||
|
||||
// #67603: the gateway persists model-switch / personality notices as role=user
|
||||
// ([System: …], tui_gateway/server.py). A single trailing marker is already
|
||||
// handled by the latestAuthoritativeUser guard above, but TWO switches around
|
||||
// one turn put a marker BEFORE the committed prompt (shifting its ordinal) and
|
||||
// another AFTER it (so the prompt is no longer the last user row, so the text
|
||||
// guard can't rescue it). Naive ordinal pairing then pairs the optimistic row
|
||||
// against a marker, treats it as uncommitted, and re-appends it — the
|
||||
// duplicated user bubble stacked at the bottom of the chat.
|
||||
it('does not duplicate the optimistic prompt when markers bracket it (two model switches)', () => {
|
||||
const marker = (name: string) => `[System: The active model for this chat has changed to ${name}.]`
|
||||
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
msg('2-assistant', 'assistant', 'first answer'),
|
||||
msg('user-optimistic', 'user', 'second question')
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('s1-user', 'user', 'first'),
|
||||
msg('s2-assistant', 'assistant', 'first answer'),
|
||||
msg('s3-marker', 'user', marker('k2')),
|
||||
msg('s4-user', 'user', 'second question'),
|
||||
msg('s5-assistant', 'assistant', 'second answer'),
|
||||
msg('s6-marker', 'user', marker('k3'))
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
||||
})
|
||||
|
||||
it('still keeps a genuinely uncommitted optimistic turn when a marker is present', () => {
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first'),
|
||||
msg('2-assistant', 'assistant', 'first answer'),
|
||||
msg('user-optimistic', 'user', 'new question')
|
||||
]
|
||||
|
||||
// The marker is persisted but the new prompt has not committed yet — the
|
||||
// optimistic row must survive (marker exclusion must not over-correct).
|
||||
const next = [
|
||||
msg('1-user-stored', 'user', 'first'),
|
||||
msg('2-assistant-stored', 'assistant', 'first answer'),
|
||||
msg('3-marker-stored', 'user', '[System: The active model for this chat has changed to k3.]')
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
|
||||
'1-user-stored',
|
||||
'2-assistant-stored',
|
||||
'3-marker-stored',
|
||||
'user-optimistic'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendLiveSessionProjection', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
@@ -240,7 +240,17 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
||||
* history, not in-flight work. The latest authoritative user confirms whether
|
||||
* that tail has persisted; any authoritative assistant at the same ordinal
|
||||
* supersedes the local stream.
|
||||
*
|
||||
* Gateway bookkeeping markers (the model-switch / personality notices written
|
||||
* by tui_gateway/server.py) are persisted as role=user but are not user turns.
|
||||
* They must not take part in ordinal pairing on either side: a stored marker
|
||||
* between two real user turns shifts every later user ordinal, so the optimistic
|
||||
* row misses its committed copy and is appended a second time at the end of the
|
||||
* transcript — the duplicated user bubble of #67603.
|
||||
*/
|
||||
const isGatewaySystemMarker = (message: ChatMessage): boolean =>
|
||||
message.role === 'user' && chatMessageText(message).trimStart().startsWith('[System:')
|
||||
|
||||
export function preserveLocalPendingTurnMessages(
|
||||
nextMessages: ChatMessage[],
|
||||
previousMessages: ChatMessage[]
|
||||
@@ -253,6 +263,10 @@ export function preserveLocalPendingTurnMessages(
|
||||
const nextRoleCounts = new Map<ChatMessage['role'], number>()
|
||||
|
||||
for (const message of nextMessages) {
|
||||
if (isGatewaySystemMarker(message)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ordinal = nextRoleCounts.get(message.role) ?? 0
|
||||
nextRoleCounts.set(message.role, ordinal + 1)
|
||||
nextByRoleOrdinal.set(`${message.role}:${ordinal}`, message)
|
||||
@@ -269,6 +283,10 @@ export function preserveLocalPendingTurnMessages(
|
||||
const preserved: ChatMessage[] = []
|
||||
|
||||
for (const message of previousMessages) {
|
||||
if (isGatewaySystemMarker(message)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ordinal = previousRoleCounts.get(message.role) ?? 0
|
||||
previousRoleCounts.set(message.role, ordinal + 1)
|
||||
|
||||
@@ -497,6 +515,28 @@ export async function resolveStoredSession(storedSessionId: string): Promise<Ses
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile that owns a stored session, resolved through the same
|
||||
* cache → active-backend → cross-profile ladder as `resolveStoredSession`.
|
||||
*
|
||||
* Recovery `session.resume` calls (stale runtime id, session-not-found, wedged
|
||||
* loop) must re-register the conversation on ITS backend, not on whichever
|
||||
* profile happens to be live. Omitting the profile lets the gateway fall back to
|
||||
* the launch-profile DB (tui_gateway/server.py), which is how a session bleeds
|
||||
* from one profile into another (#67603, second symptom). A cache-only lookup
|
||||
* misses any session outside the paginated sidebar window, so route through the
|
||||
* resolver, which probes uncached ids across profiles.
|
||||
*/
|
||||
export async function resolveSessionProfile(storedSessionId: null | string): Promise<string | undefined> {
|
||||
if (!storedSessionId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const profile = (await resolveStoredSession(storedSessionId))?.profile?.trim()
|
||||
|
||||
return profile || undefined
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
@@ -517,9 +557,7 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
|
||||
reconcileApprovalModeForProfile($activeGatewayProfile.get(), info.approval_mode)
|
||||
}
|
||||
|
||||
if (info.credential_warning) {
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
requestDesktopOnboardingForCredentialWarning(info.credential_warning)
|
||||
|
||||
reportInstallMethodWarning(info.install_warning)
|
||||
|
||||
|
||||
@@ -188,7 +188,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
|
||||
const tombstones = $removedSessionIds.get()
|
||||
|
||||
const incoming = tombstones.size
|
||||
? recents.sessions.filter(s => !tombstones.has(s.id) && !(s._lineage_root_id && tombstones.has(s._lineage_root_id)))
|
||||
? recents.sessions.filter(
|
||||
s => !tombstones.has(s.id) && !(s._lineage_root_id && tombstones.has(s._lineage_root_id))
|
||||
)
|
||||
: recents.sessions
|
||||
|
||||
// Signature-gate the swap (same pattern as cron/messaging): a refresh
|
||||
|
||||
@@ -11,15 +11,22 @@ export const EMPTY_BILLING_VALUE = '—'
|
||||
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
|
||||
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
|
||||
|
||||
// Billing polls on its own while the page is mounted (react-query only ticks an
|
||||
// active observer), so the view stays live without a manual refresh control —
|
||||
// matching every other data view in the app. It pauses when the window is
|
||||
// backgrounded (refetchIntervalInBackground defaults to false).
|
||||
// The billing endpoint is the authoritative source of truth for balance / cap /
|
||||
// plan — the inference `x-nous-credits-*` headers are best-effort and can drift
|
||||
// out of sync (notably in team/org accounts where another member's spend moves
|
||||
// the shared balance without ever touching THIS client's headers). So the page
|
||||
// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a
|
||||
// fresh fetch every time it opens or regains focus, and it keeps polling every
|
||||
// 30s while mounted (react-query only ticks an active observer; it pauses when
|
||||
// the window is backgrounded — refetchIntervalInBackground defaults to false).
|
||||
// A `credits.*` notice crossing additionally invalidates ['billing','state'] to
|
||||
// pull the change in immediately rather than waiting for the next poll tick.
|
||||
const BILLING_QUERY_OPTIONS = {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false,
|
||||
staleTime: 30_000
|
||||
staleTime: 0
|
||||
} as const
|
||||
|
||||
export interface BillingSummaryItemView {
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { onComposerInsertRequest } from '@/app/chat/composer/focus'
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
import { ClarifyTool, readClarifyResult } from './clarify-tool'
|
||||
|
||||
// The live pending card only renders while its message is running. Force that so
|
||||
// keyboard-navigation tests can exercise ClarifyToolPending directly.
|
||||
vi.mock('@assistant-ui/react', () => ({
|
||||
useAuiState: () => true
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearClarifyRequest()
|
||||
$activeSessionId.set(null)
|
||||
$gateway.set(null)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderClarify(ui: ReactNode) {
|
||||
@@ -40,6 +53,40 @@ function settledClarifyProps(
|
||||
}
|
||||
}
|
||||
|
||||
function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessagePartProps {
|
||||
const args = { choices, question: 'Which deployment target?' }
|
||||
|
||||
return {
|
||||
addResult: vi.fn(),
|
||||
args,
|
||||
argsText: JSON.stringify(args),
|
||||
isError: false,
|
||||
respondToApproval: vi.fn(),
|
||||
result: undefined,
|
||||
resume: vi.fn(),
|
||||
status: { type: 'running' },
|
||||
toolCallId: 'clarify-live',
|
||||
toolName: 'clarify',
|
||||
type: 'tool-call'
|
||||
}
|
||||
}
|
||||
|
||||
function renderLiveClarify() {
|
||||
const request = vi.fn().mockResolvedValue({ ok: true })
|
||||
|
||||
$activeSessionId.set('session-1')
|
||||
$gateway.set({ request } as never)
|
||||
setClarifyRequest({
|
||||
choices: ['staging', 'production'],
|
||||
question: 'Which deployment target?',
|
||||
requestId: 'request-1',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
renderClarify(<ClarifyTool {...liveClarifyProps()} />)
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
describe('readClarifyResult', () => {
|
||||
it('reads question + user_response from the tool JSON payload', () => {
|
||||
expect(
|
||||
@@ -182,3 +229,112 @@ describe('ClarifyTool settled view', () => {
|
||||
expect(document.querySelector('[data-clarify-late-choices]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClarifyTool keyboard navigation', () => {
|
||||
it('cycles through choices and Other with the arrow keys', () => {
|
||||
renderLiveClarify()
|
||||
|
||||
const staging = screen.getByRole('button', { name: /staging/ })
|
||||
const production = screen.getByRole('button', { name: /production/ })
|
||||
const other = screen.getByPlaceholderText(/Other/)
|
||||
|
||||
expect(staging.getAttribute('data-highlighted')).toBe('true')
|
||||
expect(staging.getAttribute('aria-current')).toBe('true')
|
||||
expect(staging.getAttribute('aria-keyshortcuts')).toBe('A 1')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
expect(production.getAttribute('data-highlighted')).toBe('true')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true')
|
||||
expect(other.getAttribute('aria-current')).toBe('true')
|
||||
expect(other.getAttribute('aria-keyshortcuts')).toBe('C 3')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
expect(staging.getAttribute('data-highlighted')).toBe('true')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowUp' })
|
||||
expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true')
|
||||
})
|
||||
|
||||
it('selects by number and confirms the answer with Enter', async () => {
|
||||
const request = renderLiveClarify()
|
||||
|
||||
fireEvent.keyDown(window, { key: '2' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(request).toHaveBeenCalledWith('clarify.respond', {
|
||||
answer: 'production',
|
||||
request_id: 'request-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('focuses Other when its number is pressed and leaves typing keys alone', () => {
|
||||
renderLiveClarify()
|
||||
|
||||
const other = screen.getByPlaceholderText(/Other/)
|
||||
|
||||
fireEvent.keyDown(window, { key: '3' })
|
||||
expect(document.activeElement).toBe(other)
|
||||
|
||||
fireEvent.change(other, { target: { value: 'canary' } })
|
||||
fireEvent.keyDown(window, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(other)
|
||||
expect((other as HTMLTextAreaElement).value).toBe('canary')
|
||||
})
|
||||
|
||||
it('does not intercept keyboard events while an action button has focus', () => {
|
||||
const request = renderLiveClarify()
|
||||
const skip = screen.getByRole('button', { name: 'Skip' })
|
||||
|
||||
skip.focus()
|
||||
|
||||
expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true)
|
||||
expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true)
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClarifyTool pending marker', () => {
|
||||
it('marks a live choices card so type-to-focus yields its shortcut keys', () => {
|
||||
renderLiveClarify()
|
||||
|
||||
// The marker is what `composerFocusBlockedBySurface` keys off of, so the
|
||||
// global type-to-focus listener stands down and A/B/C… + 1-9 + Enter reach
|
||||
// the card instead of the composer.
|
||||
expect(document.querySelector('[data-clarify-choices]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not mark a free-text (no-choice) pending card', () => {
|
||||
$activeSessionId.set('session-1')
|
||||
$gateway.set({ request: vi.fn().mockResolvedValue({ ok: true }) } as never)
|
||||
setClarifyRequest({
|
||||
choices: null,
|
||||
question: 'Anything else?',
|
||||
requestId: 'request-1',
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
|
||||
const args = { question: 'Anything else?' }
|
||||
renderClarify(
|
||||
<ClarifyTool
|
||||
addResult={vi.fn()}
|
||||
args={args}
|
||||
argsText={JSON.stringify(args)}
|
||||
isError={false}
|
||||
respondToApproval={vi.fn()}
|
||||
result={undefined}
|
||||
resume={vi.fn()}
|
||||
status={{ type: 'running' }}
|
||||
toolCallId="clarify-free"
|
||||
toolName="clarify"
|
||||
type="tool-call"
|
||||
/>
|
||||
)
|
||||
|
||||
// No shortcuts → nothing to protect → composer type-to-focus stays live.
|
||||
expect(document.querySelector('[data-clarify-choices]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify'
|
||||
import { clearClarifyRequest, normalizeChoices, sessionClarifyRequest, warnDroppedChoices } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -54,11 +54,18 @@ function stringField(row: Record<string, unknown>, ...keys: string[]): string |
|
||||
|
||||
function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
const row = parseMaybeObject(args)
|
||||
const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null
|
||||
const rawChoices = row.choices
|
||||
const choices = normalizeChoices(rawChoices)
|
||||
|
||||
const question = stringField(row, 'question')
|
||||
|
||||
if (rawChoices != null && choices.length === 0 && question) {
|
||||
warnDroppedChoices('tool_args', question, rawChoices)
|
||||
}
|
||||
|
||||
return {
|
||||
question: stringField(row, 'question'),
|
||||
choices: choices && choices.length > 0 ? choices : null
|
||||
question,
|
||||
choices: choices.length > 0 ? choices : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,16 +138,20 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean
|
||||
* selects an answer) and the settled skip card (where a click drafts a
|
||||
* follow-up), so both stay visually identical. */
|
||||
function ChoiceButton({
|
||||
active = false,
|
||||
char,
|
||||
choice,
|
||||
disabled,
|
||||
keyShortcuts,
|
||||
onClick,
|
||||
selected = false,
|
||||
title
|
||||
}: {
|
||||
active?: boolean
|
||||
char: string
|
||||
choice: string
|
||||
disabled?: boolean
|
||||
keyShortcuts?: string
|
||||
onClick: () => void
|
||||
selected?: boolean
|
||||
title?: string
|
||||
@@ -149,20 +160,28 @@ function ChoiceButton({
|
||||
// tooltip on a <button> is banned by the no-native-title guard). It renders
|
||||
// the child untouched when `label` is falsy, so the live card (no tip) is
|
||||
// unaffected and only the settled skip card gets the hover hint.
|
||||
//
|
||||
// `active` is the keyboard cursor on the live card (arrow-key navigation);
|
||||
// it highlights the row and previews its key badge. The settled skip card
|
||||
// never passes it, so its rows stay plain.
|
||||
return (
|
||||
<Tip label={title}>
|
||||
<button
|
||||
aria-current={active || undefined}
|
||||
aria-keyshortcuts={keyShortcuts}
|
||||
className={cn(
|
||||
OPTION_ROW_CLASS,
|
||||
'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
active && 'bg-(--chrome-action-hover) text-(--ui-text-primary)',
|
||||
selected && 'text-(--ui-text-primary)'
|
||||
)}
|
||||
data-choice
|
||||
data-highlighted={active || undefined}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<KeyBadge char={char} selected={selected} />
|
||||
<KeyBadge char={char} preview={active} selected={selected} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
@@ -291,6 +310,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [selectedChoice, setSelectedChoice] = useState<string | null>(null)
|
||||
// The keyboard cursor. Indices 0..choices.length-1 are the options; the
|
||||
// trailing index (=== choices.length) is the "Other" free-text row.
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [otherFocused, setOtherFocused] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
@@ -339,12 +361,31 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
// confirms with Continue (or Enter from the field).
|
||||
const pendingAnswer = selectedChoice ?? (trimmedDraft || null)
|
||||
|
||||
const selectChoice = useCallback((choice: string) => {
|
||||
const selectChoice = useCallback((choice: string, index: number) => {
|
||||
// Picking a choice and typing are mutually exclusive answers.
|
||||
setDraft('')
|
||||
setSelectedChoice(choice)
|
||||
setActiveIndex(index)
|
||||
}, [])
|
||||
|
||||
// Keep the cursor in range when the choice set changes (never past "Other").
|
||||
useEffect(() => {
|
||||
setActiveIndex(index => Math.min(index, choices.length))
|
||||
}, [choices.length])
|
||||
|
||||
const moveActive = useCallback(
|
||||
(delta: number) => {
|
||||
const itemCount = choices.length + 1
|
||||
|
||||
// Arrow navigation is a move, not a pick — clear any staged answer so the
|
||||
// cursor and the selection can't disagree.
|
||||
setDraft('')
|
||||
setSelectedChoice(null)
|
||||
setActiveIndex(index => (index + delta + itemCount) % itemCount)
|
||||
},
|
||||
[choices.length]
|
||||
)
|
||||
|
||||
const submitAnswer = useCallback(() => {
|
||||
if (selectedChoice !== null) {
|
||||
void respond(selectedChoice)
|
||||
@@ -357,6 +398,27 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
}
|
||||
}, [respond, selectedChoice, trimmedDraft])
|
||||
|
||||
const activateActive = useCallback(() => {
|
||||
// A staged answer (picked choice or typed text) wins — confirm it.
|
||||
if (pendingAnswer) {
|
||||
submitAnswer()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise act on the highlighted row: a choice responds immediately, and
|
||||
// the trailing "Other" row focuses the free-text field.
|
||||
const choice = choices[activeIndex]
|
||||
|
||||
if (choice) {
|
||||
void respond(choice)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
textareaRef.current?.focus()
|
||||
}, [activeIndex, choices, pendingAnswer, respond, submitAnswer])
|
||||
|
||||
const handleTextareaKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
@@ -379,10 +441,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
[submitAnswer]
|
||||
)
|
||||
|
||||
// Letter shortcuts: A/B/C… pick the matching option, the trailing letter jumps
|
||||
// into "Other", and Enter confirms the current pick. Stands down whenever a
|
||||
// field is focused (you're typing, not navigating) so it never eats keystrokes
|
||||
// meant for the composer or the Other box.
|
||||
// Arrow keys move a visual cursor, 1-9 and A/B/C… pick directly, and Enter
|
||||
// confirms the current answer (or acts on the highlighted row). Stands down
|
||||
// whenever a focusable control (a field, a choice button, the action bar) is
|
||||
// focused, so it never eats keystrokes meant for the composer, the Other box,
|
||||
// or a button the user tabbed to.
|
||||
useEffect(() => {
|
||||
if (!ready || !hasChoices || submitting) {
|
||||
return
|
||||
@@ -395,7 +458,32 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
|
||||
if (
|
||||
active &&
|
||||
(active.isContentEditable || active.matches('a[href], button, input, select, textarea, [role="button"]'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveActive(event.key === 'ArrowDown' ? 1 : -1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (/^[1-9]$/.test(event.key)) {
|
||||
const index = Number(event.key) - 1
|
||||
|
||||
if (index < choices.length) {
|
||||
event.preventDefault()
|
||||
selectChoice(choices[index], index)
|
||||
} else if (index === choices.length) {
|
||||
event.preventDefault()
|
||||
setActiveIndex(index)
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -406,25 +494,26 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
if (index < choices.length) {
|
||||
event.preventDefault()
|
||||
selectChoice(choices[index])
|
||||
selectChoice(choices[index], index)
|
||||
} else if (index === choices.length) {
|
||||
event.preventDefault()
|
||||
setActiveIndex(index)
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && pendingAnswer) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submitAnswer()
|
||||
activateActive()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [choices, hasChoices, pendingAnswer, ready, selectChoice, submitAnswer, submitting])
|
||||
}, [activateActive, choices, hasChoices, moveActive, ready, selectChoice, submitting])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -449,7 +538,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ClarifyShell className="grid gap-2 px-2.5 py-2">
|
||||
// `data-clarify-choices` marks the panel as owning printable/Enter keys
|
||||
// while its A/B/C… shortcuts are live, so the global type-to-focus listener
|
||||
// (`composerFocusBlockedBySurface`) stands down and the letters reach this
|
||||
// card instead of being redirected into the composer.
|
||||
<ClarifyShell className="grid gap-2 px-2.5 py-2" data-clarify-choices={hasChoices ? '' : undefined}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
|
||||
<MessageQuestion aria-hidden className="mt-px size-4 shrink-0 text-(--ui-text-tertiary)" />
|
||||
@@ -460,23 +553,39 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
<div className="grid gap-px" role="group">
|
||||
{choices.map((choice, index) => (
|
||||
<ChoiceButton
|
||||
active={activeIndex === index}
|
||||
char={letterFor(index)}
|
||||
choice={choice}
|
||||
disabled={submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => selectChoice(choice)}
|
||||
keyShortcuts={`${letterFor(index)} ${index + 1}`}
|
||||
onClick={() => selectChoice(choice, index)}
|
||||
selected={selectedChoice === choice}
|
||||
/>
|
||||
))}
|
||||
<label className={cn(OPTION_ROW_CLASS, 'items-center')}>
|
||||
<KeyBadge char={letterFor(choices.length)} preview={otherFocused} selected={Boolean(trimmedDraft)} />
|
||||
<label
|
||||
className={cn(
|
||||
OPTION_ROW_CLASS,
|
||||
'items-center',
|
||||
activeIndex === choices.length && 'bg-(--chrome-action-hover)'
|
||||
)}
|
||||
data-highlighted={activeIndex === choices.length || undefined}
|
||||
>
|
||||
<KeyBadge
|
||||
char={letterFor(choices.length)}
|
||||
preview={otherFocused || activeIndex === choices.length}
|
||||
selected={Boolean(trimmedDraft)}
|
||||
/>
|
||||
<Textarea
|
||||
aria-current={activeIndex === choices.length || undefined}
|
||||
aria-keyshortcuts={`${letterFor(choices.length)} ${choices.length + 1}`}
|
||||
className={CLARIFY_TEXTAREA_CLASS}
|
||||
disabled={submitting}
|
||||
onBlur={() => setOtherFocused(false)}
|
||||
onChange={event => onDraftChange(event.target.value)}
|
||||
onFocus={() => {
|
||||
setSelectedChoice(null)
|
||||
setActiveIndex(choices.length)
|
||||
setOtherFocused(true)
|
||||
}}
|
||||
onKeyDown={handleTextareaKey}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { MarkdownTextContent } from './markdown-text'
|
||||
|
||||
const REMOTE_IMAGE_PATH = '/home/user/project/images/remote-preview.png'
|
||||
const REMOTE_IMAGE_DATA_URL = 'data:image/png;base64,cmVtb3RlLWltYWdl'
|
||||
|
||||
describe('MarkdownTextContent remote images', () => {
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path.startsWith('/api/fs/read-data-url?')) {
|
||||
return { dataUrl: REMOTE_IMAGE_DATA_URL }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
|
||||
let originalDesktop: typeof window.hermesDesktop
|
||||
|
||||
beforeEach(() => {
|
||||
api.mockClear()
|
||||
originalDesktop = window.hermesDesktop
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { api }
|
||||
})
|
||||
$connection.set({ mode: 'remote', profile: 'remote-work' } as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: originalDesktop
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the gateway bridge data URL through Streamdown to the zoomable image', async () => {
|
||||
render(<MarkdownTextContent isRunning={false} text={``} />)
|
||||
|
||||
const image = await screen.findByRole('img', { name: 'Remote preview' })
|
||||
|
||||
expect(image.getAttribute('src')).toBe(REMOTE_IMAGE_DATA_URL)
|
||||
expect(api).toHaveBeenCalledWith({
|
||||
path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fimages%2Fremote-preview.png',
|
||||
profile: 'remote-work'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,14 +20,14 @@ import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks'
|
||||
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
|
||||
import {
|
||||
downloadGatewayMediaFile,
|
||||
filePathFromMediaPath,
|
||||
gatewayMediaDataUrl,
|
||||
isInlineMediaSrc,
|
||||
isRemoteGateway,
|
||||
mediaExternalUrl,
|
||||
mediaKind,
|
||||
mediaName,
|
||||
mediaPathFromMarkdownHref,
|
||||
mediaStreamUrl
|
||||
mediaStreamUrl,
|
||||
resolveMediaDisplaySrc
|
||||
} from '@/lib/media'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -60,27 +60,13 @@ function preprocessWithTailRepair(text: string): string {
|
||||
}
|
||||
|
||||
async function mediaSrc(path: string): Promise<string> {
|
||||
if (/^(?:https?|data):/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
// Stream audio/video through the custom protocol: data URLs are capped and
|
||||
// load the whole file into memory, which broke playback for larger videos.
|
||||
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
|
||||
return mediaStreamUrl(path)
|
||||
}
|
||||
|
||||
// Remote gateway: the image lives on the gateway machine, so read it over the
|
||||
// authenticated API rather than this machine's disk.
|
||||
if (window.hermesDesktop && isRemoteGateway()) {
|
||||
return gatewayMediaDataUrl(path)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
return resolveMediaDisplaySrc(path)
|
||||
}
|
||||
|
||||
function useOpenMediaFile(path: string) {
|
||||
@@ -286,6 +272,65 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
|
||||
}
|
||||
|
||||
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
|
||||
const rawSrc = typeof src === 'string' ? src : ''
|
||||
const [resolvedSrc, setResolvedSrc] = useState(() => (rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : ''))
|
||||
const [failed, setFailed] = useState(false)
|
||||
const { open, openFailed } = useOpenMediaFile(rawSrc)
|
||||
const name = mediaName(rawSrc || String(alt || 'image'))
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
setFailed(false)
|
||||
setResolvedSrc(rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : '')
|
||||
|
||||
if (!rawSrc || isInlineMediaSrc(rawSrc)) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
void resolveMediaDisplaySrc(rawSrc)
|
||||
.then(value => {
|
||||
if (!cancelled) {
|
||||
setResolvedSrc(value)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [rawSrc])
|
||||
|
||||
if (!rawSrc) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="my-2 block text-sm text-muted-foreground">
|
||||
Couldn't load {name}.{' '}
|
||||
<button
|
||||
className="bg-transparent font-medium text-foreground underline underline-offset-4 decoration-current/20 hover:text-foreground"
|
||||
onClick={open}
|
||||
type="button"
|
||||
>
|
||||
Open image
|
||||
</button>
|
||||
{openFailed && <OpenMediaFailedNote name={name} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (!resolvedSrc) {
|
||||
return <span className="my-2 block text-sm text-muted-foreground">Loading {name}...</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<ZoomableImage
|
||||
alt={alt}
|
||||
@@ -295,7 +340,7 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
||||
)}
|
||||
containerClassName="my-2 block w-fit max-w-full"
|
||||
slot="aui_markdown-image"
|
||||
src={src}
|
||||
src={resolvedSrc}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -14,12 +14,21 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { GeneratedImage } from '@/components/chat/generated-image-result'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { generatedImageFromResult } from '@/lib/generated-images'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = props => {
|
||||
const { args, result } = props
|
||||
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
|
||||
|
||||
// The image card owns successful generations. Failed or malformed results
|
||||
// still need the normal tool row: it extracts the error text and gives the
|
||||
// user an honest, expandable failure rather than silently dropping the call.
|
||||
if (result !== undefined && !generatedImageFromResult(result)) {
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<GeneratedImage aspectRatio={aspectRatio} result={result} />
|
||||
|
||||
@@ -217,7 +217,10 @@ function assistantTodoMessage(
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantImageMessage(running = false): ThreadMessage {
|
||||
function assistantImageMessage(
|
||||
running = false,
|
||||
result: unknown = { image: 'https://cdn.example/cat.png', success: true }
|
||||
): ThreadMessage {
|
||||
return {
|
||||
id: `assistant-image-${running ? 'running' : 'done'}`,
|
||||
role: 'assistant',
|
||||
@@ -228,7 +231,7 @@ function assistantImageMessage(running = false): ThreadMessage {
|
||||
toolName: 'image_generate',
|
||||
args: { prompt: 'draw a cat' },
|
||||
argsText: JSON.stringify({ prompt: 'draw a cat' }),
|
||||
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
|
||||
...(running ? {} : { result })
|
||||
}
|
||||
],
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
@@ -243,6 +246,32 @@ function assistantImageMessage(running = false): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantTerminalMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-terminal-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'terminal-1',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'npm run check --workspace=apps/desktop' },
|
||||
argsText: JSON.stringify({ command: 'npm run check --workspace=apps/desktop' }),
|
||||
result: { exit_code: 0, stdout: 'all checks passed' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
interface StreamingControls {
|
||||
emitSecond: () => void
|
||||
complete: () => void
|
||||
@@ -634,4 +663,32 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
|
||||
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the normal tool row for failed image generations instead of dropping their error payload', async () => {
|
||||
const { container } = render(
|
||||
<MessageHarness
|
||||
message={assistantImageMessage(false, { error: 'FAL rejected the prompt', image: null, success: false })}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-tool-row] button')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('FAL rejected the prompt')
|
||||
})
|
||||
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeNull()
|
||||
expect(container.textContent).not.toContain('"success":false')
|
||||
})
|
||||
|
||||
it('shows the command prompt and exit code for terminal calls', async () => {
|
||||
const { container } = render(<MessageHarness message={assistantTerminalMessage()} />)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-tool-row] button')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('$ npm run check --workspace=apps/desktop')
|
||||
expect(container.textContent).toContain('exit 0')
|
||||
expect(container.textContent).toContain('all checks passed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,6 +76,38 @@ describe('buildToolView terminal exit-code status', () => {
|
||||
'error'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the command and exit code for the terminal transcript', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { command: 'npm run check --workspace=apps/desktop' },
|
||||
result: { exit_code: 0, output: 'done' },
|
||||
toolName: 'terminal'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.terminalCommand).toBe('npm run check --workspace=apps/desktop')
|
||||
expect(view.terminalExitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolView web-search query', () => {
|
||||
it('keeps the query separate from structured search results', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { query: 'Hermes Agent Desktop tool calls' },
|
||||
result: { web: [{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }] },
|
||||
toolName: 'web_search'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.searchQuery).toBe('Hermes Agent Desktop tool calls')
|
||||
expect(view.searchHits).toEqual([
|
||||
{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolView browser_navigate title', () => {
|
||||
|
||||
@@ -128,6 +128,12 @@ function readFileDisplayTarget(args: Record<string, unknown>, result: Record<str
|
||||
return [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function shellCommand(args: Record<string, unknown>): string {
|
||||
return (
|
||||
firstStringField(args, ['context', 'preview']) || firstStringField(args, ['command', 'code']) || contextValue(args)
|
||||
)
|
||||
}
|
||||
|
||||
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
|
||||
browser_click: {
|
||||
icon: 'globe',
|
||||
@@ -1315,10 +1321,7 @@ function dynamicTitle(
|
||||
}
|
||||
|
||||
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
|
||||
const command =
|
||||
firstStringField(args, ['context', 'preview']) ||
|
||||
firstStringField(args, ['command', 'code']) ||
|
||||
contextValue(args)
|
||||
const command = shellCommand(args)
|
||||
|
||||
if (command) {
|
||||
const action =
|
||||
@@ -1385,6 +1388,11 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
const searchHits =
|
||||
part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
|
||||
|
||||
const searchQuery =
|
||||
part.toolName === 'web_search'
|
||||
? firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord)
|
||||
: ''
|
||||
|
||||
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
|
||||
|
||||
// For shell/code tools we surface stdout and stderr as separate labeled
|
||||
@@ -1398,6 +1406,8 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
// field — otherwise the merged `detail` already covers it and double-
|
||||
// rendering would duplicate output.
|
||||
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
|
||||
const terminalCommand = part.toolName === 'terminal' ? shellCommand(argsRecord) : undefined
|
||||
const terminalExitCode = part.toolName === 'terminal' ? numericField(resultRecord, 'exit_code') : undefined
|
||||
|
||||
return {
|
||||
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
|
||||
@@ -1409,8 +1419,11 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
inlineDiff,
|
||||
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
|
||||
rendersAnsi: rendersAnsi || undefined,
|
||||
searchQuery: searchQuery || undefined,
|
||||
searchHits: searchHits?.length ? searchHits : undefined,
|
||||
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
|
||||
terminalCommand,
|
||||
terminalExitCode,
|
||||
stdout: hasSplitStreams ? stdout || undefined : undefined,
|
||||
status,
|
||||
subtitle,
|
||||
|
||||
@@ -40,11 +40,17 @@ export interface ToolView {
|
||||
* (terminal/execute_code) so the renderer knows to run them through
|
||||
* the ANSI parser instead of printing them as literals. */
|
||||
rendersAnsi?: boolean
|
||||
/** Original query, shown above structured web-search results. */
|
||||
searchQuery?: string
|
||||
searchHits?: SearchResultRow[]
|
||||
/** When the backend reports stderr as a separate stream (terminal /
|
||||
* execute_code), the renderer shows it as its own labeled, neutrally
|
||||
* tinted block under stdout — distinct from an error tone. */
|
||||
stderr?: string
|
||||
/** Terminal-only command shown as the prompt in the expanded transcript. */
|
||||
terminalCommand?: string
|
||||
/** Terminal-only process exit code, when the backend reported one. */
|
||||
terminalExitCode?: number
|
||||
/** When set, the renderer uses stdout+stderr as separate sections and
|
||||
* ignores the merged `detail`. */
|
||||
stdout?: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { shouldBoundToolGroup, UNBOUNDABLE_TOOLS } from './fallback'
|
||||
import { shouldBoundToolGroup, technicalTrace, UNBOUNDABLE_TOOLS } from './fallback'
|
||||
|
||||
describe('shouldBoundToolGroup', () => {
|
||||
it('bounds long runs of ordinary tool calls', () => {
|
||||
@@ -22,3 +22,16 @@ describe('UNBOUNDABLE_TOOLS', () => {
|
||||
expect(UNBOUNDABLE_TOOLS.has('image_generate')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('technicalTrace', () => {
|
||||
it('indents object payloads and persisted JSON strings', () => {
|
||||
expect(technicalTrace({ offset: 2, path: '/tmp/demo.txt' }, '{"success":true,"lines":["a","b"]}')).toBe(
|
||||
'Arguments:\n{\n "offset": 2,\n "path": "/tmp/demo.txt"\n}\n\nResult:\n{\n "success": true,\n "lines": [\n "a",\n "b"\n ]\n}'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves scalar strings untouched', () => {
|
||||
expect(technicalTrace(undefined, 'plain text')).toBe('Result:\nplain text')
|
||||
expect(technicalTrace(undefined, '"already quoted"')).toBe('Result:\n"already quoted"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,7 +62,6 @@ import {
|
||||
type ToolStatus,
|
||||
type ToolTitleAction
|
||||
} from './fallback-model'
|
||||
import { prettyJson } from './fallback-model/format'
|
||||
|
||||
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
|
||||
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
|
||||
@@ -104,23 +103,39 @@ interface ToolStatusCopy {
|
||||
statusRunning: string
|
||||
}
|
||||
|
||||
function rawTechnicalTrace(args: unknown, result: unknown): string {
|
||||
const parts = [args, result]
|
||||
.filter(value => value !== undefined && value !== null)
|
||||
.map(value => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
function prettyTechnicalValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return value
|
||||
}
|
||||
|
||||
return clampForDisplay(parts.join('\n'))
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
return parsed && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function technicalTrace(args: unknown, result: unknown): string {
|
||||
const parts = [
|
||||
['Arguments', args],
|
||||
['Result', result]
|
||||
]
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([label, value]) => `${label}:\n${prettyTechnicalValue(value)}`)
|
||||
|
||||
return clampForDisplay(parts.join('\n\n'))
|
||||
}
|
||||
|
||||
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
|
||||
@@ -363,7 +378,8 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
|
||||
const showDetail =
|
||||
!view.inlineDiff &&
|
||||
((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
|
||||
(Boolean(view.stdout || view.stderr) ||
|
||||
(view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
|
||||
(view.status !== 'error' && Boolean(view.detail) && !detailMatchesTitle && !detailMatchesSubtitle))
|
||||
|
||||
const renderDetailAsCode =
|
||||
@@ -373,21 +389,16 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
const hasSearchHits = Boolean(view.searchHits?.length)
|
||||
const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel
|
||||
|
||||
// Only web_search renders the raw JSON drilldown, so serialize the result
|
||||
// lazily here instead of prettyJson-ing every tool's result in buildToolView.
|
||||
const rawResult = useMemo(
|
||||
() => (part.toolName === 'web_search' && toolViewMode !== 'technical' ? prettyJson(part.result) : ''),
|
||||
[part.toolName, part.result, toolViewMode]
|
||||
)
|
||||
|
||||
const showRawSearchDrilldown =
|
||||
part.toolName === 'web_search' &&
|
||||
part.result !== undefined &&
|
||||
toolViewMode !== 'technical' &&
|
||||
Boolean(rawResult.trim())
|
||||
|
||||
const hasExpandableContent = Boolean(
|
||||
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
|
||||
view.imageUrl ||
|
||||
view.inlineDiff ||
|
||||
showDetail ||
|
||||
hasSearchHits ||
|
||||
view.stdout ||
|
||||
view.stderr ||
|
||||
view.terminalCommand ||
|
||||
view.terminalExitCode !== undefined ||
|
||||
toolViewMode === 'technical'
|
||||
)
|
||||
|
||||
// copyAction reads the uncapped view.detail; clampForDisplay below only bounds
|
||||
@@ -511,6 +522,9 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
text={copyAction.text}
|
||||
/>
|
||||
)}
|
||||
{part.toolName === 'terminal' && toolViewMode !== 'technical' && (
|
||||
<TerminalTranscript command={view.terminalCommand} exitCode={view.terminalExitCode} />
|
||||
)}
|
||||
{view.imageUrl && (
|
||||
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
|
||||
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
|
||||
@@ -518,6 +532,12 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)}
|
||||
{hasSearchHits && view.searchHits && (
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.searchQuery && (
|
||||
<p className="mb-1 flex min-w-0 gap-1.5 wrap-anywhere">
|
||||
<span className="shrink-0 font-medium text-(--ui-text-tertiary)">Search</span>
|
||||
<span>{view.searchQuery}</span>
|
||||
</p>
|
||||
)}
|
||||
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
|
||||
<SearchResultsList hits={view.searchHits} />
|
||||
</div>
|
||||
@@ -596,22 +616,16 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showRawSearchDrilldown && (
|
||||
<details className="max-w-full">
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>{copy.rawResponse}</summary>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>{rawResult}</pre>
|
||||
</details>
|
||||
)}
|
||||
{toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{rawTechnicalTrace(part.args, part.result)}
|
||||
{technicalTrace(part.args, part.result)}
|
||||
</pre>
|
||||
)}
|
||||
{toolViewMode === 'technical' && isFileEdit && view.inlineDiff && (
|
||||
<details className="max-w-full">
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0 cursor-pointer')}>Tool payload</summary>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||
{rawTechnicalTrace(part.args, part.result)}
|
||||
{technicalTrace(part.args, part.result)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
@@ -621,6 +635,40 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface TerminalTranscriptProps {
|
||||
command?: string
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
|
||||
if (!command && exitCode === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-[0.25rem] border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 py-1.5 font-mono text-[0.7rem] leading-relaxed">
|
||||
{command && (
|
||||
<code className="min-w-0 flex-1 whitespace-pre-wrap wrap-anywhere text-(--ui-text-secondary)">
|
||||
<span aria-hidden className="select-none text-(--ui-accent-secondary)">
|
||||
${' '}
|
||||
</span>
|
||||
{command}
|
||||
</code>
|
||||
)}
|
||||
{exitCode !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded bg-(--ui-bg-tertiary) px-1 py-px text-[0.6rem] tabular-nums',
|
||||
exitCode === 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
exit {exitCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A back-to-back run of this many tool calls collapses into the bounded,
|
||||
// auto-scrolling window; fewer than this stays a plain inline stack.
|
||||
const TOOL_GROUP_SCROLL_THRESHOLD = 3
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ExpandableBlock } from './expandable-block'
|
||||
|
||||
// jsdom has no ResizeObserver and reports scrollHeight === 0, so the block
|
||||
// never flips to `overflowing` on its own. Stub RO to fire immediately and
|
||||
// force a tall scrollHeight on the observed node so the toggle mounts.
|
||||
class TestResizeObserver {
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
|
||||
observe(target: Element) {
|
||||
Object.defineProperty(target, 'scrollHeight', { configurable: true, value: 400 })
|
||||
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver)
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ExpandableBlock', () => {
|
||||
it('lets horizontal scroll through and keeps the last line selectable', () => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
|
||||
const { container } = render(
|
||||
<ExpandableBlock>
|
||||
<pre data-testid="content">{'const x = 1\n'.repeat(20)}</pre>
|
||||
</ExpandableBlock>
|
||||
)
|
||||
|
||||
const inner = container.querySelector('[data-testid="content"]')!.parentElement!
|
||||
const toggle = screen.getByRole('button', { name: /expand|collapse/i })
|
||||
const fade = toggle.parentElement!
|
||||
|
||||
// Inner container allows horizontal scroll so wide code gets a scrollbar.
|
||||
expect(inner.className).toContain('overflow-x-auto')
|
||||
|
||||
// The full-width fade is a pure cue: it spans the bottom edge but must not
|
||||
// intercept pointer events, so the scrollbar drag and text selection on the
|
||||
// last line pass through to the content underneath.
|
||||
expect(fade.className).toContain('pointer-events-none')
|
||||
expect(fade.className).toContain('inset-x-0')
|
||||
|
||||
// Only the compact toggle is clickable, and it is pinned to the right edge
|
||||
// rather than spanning the full width (the old bug).
|
||||
expect(toggle.className).toContain('pointer-events-auto')
|
||||
expect(toggle.className).toContain('w-9')
|
||||
expect(toggle.className).not.toContain('inset-x-0')
|
||||
})
|
||||
|
||||
it('still toggles expanded state when the compact control is clicked', () => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
|
||||
render(
|
||||
<ExpandableBlock>
|
||||
<pre data-testid="content">{'line\n'.repeat(20)}</pre>
|
||||
</ExpandableBlock>
|
||||
)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Expand' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Collapse' }).getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -31,19 +31,30 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)} ref={innerRef}>
|
||||
<div
|
||||
className={cn('overflow-y-auto overflow-x-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
|
||||
ref={innerRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{overflowing && (
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
|
||||
</button>
|
||||
// The fade is a pure overflow cue and must not intercept pointer events:
|
||||
// it spans the full bottom edge (over the horizontal scrollbar of a wide
|
||||
// code block AND the block's last line), so making it clickable killed
|
||||
// both sideways scrolling and text selection. Keep the fade
|
||||
// `pointer-events-none` and pin the only clickable target — a compact
|
||||
// toggle — to the right edge, clear of the draggable scrollbar track.
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex h-7 justify-end bg-linear-to-t from-(--ui-chat-surface-background) to-transparent">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
className="pointer-events-auto flex h-7 w-9 cursor-pointer items-end justify-center pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
@@ -162,6 +162,30 @@ function BottomRightStack({
|
||||
)
|
||||
}
|
||||
|
||||
// Emphasize only the leading money figure ("$16.00" — the amount used) with the
|
||||
// accent color (semibold), leaving the rest of the line in its default muted
|
||||
// tone. No accent, or no figure in the message → render the text untouched.
|
||||
function renderMessage(message: string, accent?: string): ReactNode {
|
||||
const match = accent ? /\$\d+(?:\.\d{2})?/.exec(message) : null
|
||||
|
||||
if (!match) {
|
||||
return message
|
||||
}
|
||||
|
||||
const start = match.index
|
||||
const end = start + match[0].length
|
||||
|
||||
return (
|
||||
<>
|
||||
{message.slice(0, start)}
|
||||
<span className="font-semibold" style={{ color: accent }}>
|
||||
{match[0]}
|
||||
</span>
|
||||
{message.slice(end)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
const styles = tone[notification.kind]
|
||||
const Icon = styles.icon
|
||||
@@ -169,6 +193,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.notifications
|
||||
|
||||
// Nudge the icon down to sit on the first text line, in `ch` so it tracks the
|
||||
// toast's font size instead of a fixed rem. `accentColor` (when set) tints the
|
||||
// icon + message as a severity ramp, overriding the kind's default color.
|
||||
const accent = notification.accentColor
|
||||
const iconStyle: CSSProperties = { marginTop: '0.42ch', ...(accent ? { color: accent } : {}) }
|
||||
|
||||
return (
|
||||
<Alert
|
||||
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
|
||||
@@ -177,14 +207,15 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
variant={styles.variant}
|
||||
>
|
||||
{notification.icon ? (
|
||||
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" />
|
||||
<Codicon className={styles.iconClass} name={notification.icon} size="1rem" style={iconStyle} />
|
||||
) : (
|
||||
<Icon className={styles.iconClass} />
|
||||
<Icon className={styles.iconClass} style={iconStyle} />
|
||||
)}
|
||||
<div className="col-start-2 min-w-0">
|
||||
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
|
||||
<AlertDescription className="col-start-auto">
|
||||
<p className="m-0">{notification.message}</p>
|
||||
<p className="m-0">{renderMessage(notification.message, accent)}</p>
|
||||
{notification.meta && <p className="m-0 text-xs text-muted-foreground tabular-nums">{notification.meta}</p>}
|
||||
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
|
||||
{notification.action && (
|
||||
<Button
|
||||
|
||||
@@ -132,6 +132,7 @@ export const en: Translations = {
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
|
||||
elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).',
|
||||
gatewayAuthFailed: 'Gateway authentication failed — check your API_SERVER_KEY.',
|
||||
methodNotAllowed:
|
||||
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.',
|
||||
microphonePermission: 'Microphone permission was denied.',
|
||||
@@ -168,7 +169,8 @@ export const en: Translations = {
|
||||
turnDoneBody: 'The response is ready.',
|
||||
turnErrorTitle: 'Turn failed',
|
||||
backgroundDoneTitle: 'Background task finished',
|
||||
backgroundFailedTitle: 'Background task failed'
|
||||
backgroundFailedTitle: 'Background task failed',
|
||||
creditsTitle: 'Credits'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -292,6 +294,7 @@ export const en: Translations = {
|
||||
'composer.send': 'Send message',
|
||||
'composer.newline': 'Insert newline',
|
||||
'composer.steer': 'Steer the running turn',
|
||||
'composer.queue': 'Queue message',
|
||||
'composer.sendQueued': 'Send next queued turn',
|
||||
'composer.mention': 'Reference files, folders, URLs',
|
||||
'composer.slash': 'Slash command palette',
|
||||
@@ -377,6 +380,10 @@ export const en: Translations = {
|
||||
backgroundDone: {
|
||||
label: 'Background task finished',
|
||||
description: 'A backgrounded terminal command completed.'
|
||||
},
|
||||
credits: {
|
||||
label: 'Credit alerts',
|
||||
description: 'Credit access is paused or restored.'
|
||||
}
|
||||
},
|
||||
test: 'Send test notification',
|
||||
|
||||
@@ -133,6 +133,7 @@ export const ja = defineLocale({
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT には ELEVENLABS_API_KEY が必要です。',
|
||||
elevenLabsRejectedKey: 'ElevenLabs が API キーを拒否しました (401)。',
|
||||
gatewayAuthFailed: 'ゲートウェイ認証に失敗しました — API_SERVER_KEY を確認してください。',
|
||||
methodNotAllowed:
|
||||
'デスクトップバックエンドがそのリクエストを拒否しました (405 Method Not Allowed)。Hermes Desktop を再起動してください。',
|
||||
microphonePermission: 'マイクのアクセス許可が拒否されました。',
|
||||
@@ -169,7 +170,8 @@ export const ja = defineLocale({
|
||||
turnDoneBody: '応答の準備ができました。',
|
||||
turnErrorTitle: 'ターンが失敗しました',
|
||||
backgroundDoneTitle: 'バックグラウンドタスクが完了しました',
|
||||
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました'
|
||||
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました',
|
||||
creditsTitle: 'クレジット'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -262,6 +264,10 @@ export const ja = defineLocale({
|
||||
backgroundDone: {
|
||||
label: 'バックグラウンドタスク完了',
|
||||
description: 'バックグラウンドのターミナルコマンドが完了しました。'
|
||||
},
|
||||
credits: {
|
||||
label: 'クレジット通知',
|
||||
description: 'クレジットの利用が停止または復旧しました。'
|
||||
}
|
||||
},
|
||||
test: 'テスト通知を送信',
|
||||
|
||||
@@ -173,6 +173,7 @@ export interface Translations {
|
||||
errors: {
|
||||
elevenLabsNeedsKey: string
|
||||
elevenLabsRejectedKey: string
|
||||
gatewayAuthFailed: string
|
||||
methodNotAllowed: string
|
||||
microphonePermission: string
|
||||
openaiRejectedApiKey: string
|
||||
@@ -211,6 +212,7 @@ export interface Translations {
|
||||
turnErrorTitle: string
|
||||
backgroundDoneTitle: string
|
||||
backgroundFailedTitle: string
|
||||
creditsTitle: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +316,7 @@ export interface Translations {
|
||||
enableAllDesc: string
|
||||
focusedHint: string
|
||||
kinds: Record<
|
||||
'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError',
|
||||
'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError',
|
||||
{ label: string; description: string }
|
||||
>
|
||||
test: string
|
||||
|
||||
@@ -129,6 +129,7 @@ export const zhHant = defineLocale({
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
|
||||
elevenLabsRejectedKey: 'ElevenLabs 拒絕了該 API 金鑰 (401)。',
|
||||
gatewayAuthFailed: '閘道認證失敗 — 請檢查你的 API_SERVER_KEY。',
|
||||
methodNotAllowed: '桌面後端拒絕了該請求 (405 Method Not Allowed)。請嘗試重新啟動 Hermes Desktop。',
|
||||
microphonePermission: '麥克風權限已被拒絕。',
|
||||
openaiRejectedApiKey: 'OpenAI 拒絕了該 API 金鑰。',
|
||||
@@ -164,7 +165,8 @@ export const zhHant = defineLocale({
|
||||
turnDoneBody: '回覆已就緒。',
|
||||
turnErrorTitle: '本輪失敗',
|
||||
backgroundDoneTitle: '背景工作已完成',
|
||||
backgroundFailedTitle: '背景工作失敗'
|
||||
backgroundFailedTitle: '背景工作失敗',
|
||||
creditsTitle: '額度'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -256,6 +258,10 @@ export const zhHant = defineLocale({
|
||||
backgroundDone: {
|
||||
label: '背景工作完成',
|
||||
description: '背景終端機指令已完成。'
|
||||
},
|
||||
credits: {
|
||||
label: '額度提醒',
|
||||
description: '額度存取被暫停或恢復。'
|
||||
}
|
||||
},
|
||||
test: '傳送測試通知',
|
||||
|
||||
@@ -129,6 +129,7 @@ export const zh: Translations = {
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
|
||||
elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。',
|
||||
gatewayAuthFailed: '网关认证失败 — 请检查你的 API_SERVER_KEY。',
|
||||
methodNotAllowed: '桌面后端拒绝了该请求 (405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
|
||||
microphonePermission: '麦克风权限已被拒绝。',
|
||||
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
|
||||
@@ -164,7 +165,8 @@ export const zh: Translations = {
|
||||
turnDoneBody: '回复已就绪。',
|
||||
turnErrorTitle: '本轮失败',
|
||||
backgroundDoneTitle: '后台任务已完成',
|
||||
backgroundFailedTitle: '后台任务失败'
|
||||
backgroundFailedTitle: '后台任务失败',
|
||||
creditsTitle: '额度'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -283,6 +285,7 @@ export const zh: Translations = {
|
||||
'composer.send': '发送消息',
|
||||
'composer.newline': '插入换行',
|
||||
'composer.steer': '引导正在运行的回合',
|
||||
'composer.queue': '消息排队',
|
||||
'composer.sendQueued': '发送下一条排队消息',
|
||||
'composer.mention': '引用文件、文件夹、网址',
|
||||
'composer.slash': '斜杠命令面板',
|
||||
@@ -367,6 +370,10 @@ export const zh: Translations = {
|
||||
backgroundDone: {
|
||||
label: '后台任务完成',
|
||||
description: '后台终端命令已完成。'
|
||||
},
|
||||
credits: {
|
||||
label: '额度提醒',
|
||||
description: '额度访问被暂停或恢复。'
|
||||
}
|
||||
},
|
||||
test: '发送测试通知',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
chatMessageText,
|
||||
collectUnspokenTurnSpeech,
|
||||
mergeFinalAssistantText,
|
||||
preserveLocalAssistantErrors,
|
||||
reasoningPart,
|
||||
@@ -157,6 +158,39 @@ describe('toChatMessages', () => {
|
||||
|
||||
expect(chatMessageText(message)).toBe('@file:foo.ts\n\nlook')
|
||||
})
|
||||
|
||||
it('projects durable timeline kinds without inspecting their text', () => {
|
||||
const messages = toChatMessages([
|
||||
{ role: 'user', content: 'real user turn', timestamp: 1 },
|
||||
{ role: 'assistant', content: 'real assistant reply', timestamp: 2 },
|
||||
{
|
||||
role: 'user',
|
||||
content: 'opaque compaction payload',
|
||||
display_kind: 'hidden',
|
||||
timestamp: 3
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'opaque model context payload',
|
||||
display_kind: 'model_switch',
|
||||
timestamp: 4
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'opaque delegation context payload',
|
||||
display_kind: 'async_delegation_complete',
|
||||
timestamp: 5
|
||||
}
|
||||
])
|
||||
|
||||
expect(messages.map(message => message.role)).toEqual(['user', 'assistant', 'system', 'system'])
|
||||
expect(messages.map(chatMessageText)).toEqual([
|
||||
'real user turn',
|
||||
'real assistant reply',
|
||||
'model changed',
|
||||
'background agent work finished'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderMediaTags', () => {
|
||||
@@ -538,6 +572,37 @@ describe('upsertToolPart', () => {
|
||||
expect(summaries).toEqual(['Did 5 searches', 'Did 5 searches'])
|
||||
})
|
||||
|
||||
it('pairs a terminal completion with its context-only start when event IDs differ', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
{ context: 'echo "Hello from the terminal"', name: 'terminal', tool_id: 'terminal-start' },
|
||||
'running'
|
||||
)
|
||||
|
||||
const completed = upsertToolPart(
|
||||
started,
|
||||
{
|
||||
args: { command: 'echo "Hello from the terminal"' },
|
||||
name: 'terminal',
|
||||
result: { exit_code: 0, stdout: 'Hello from the terminal' },
|
||||
tool_id: 'terminal-complete'
|
||||
},
|
||||
'complete'
|
||||
)
|
||||
|
||||
const terminalParts = completed.filter(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'terminal'
|
||||
)
|
||||
|
||||
expect(terminalParts).toHaveLength(1)
|
||||
expect(terminalParts[0]?.toolCallId).toBe('terminal-complete')
|
||||
expect(terminalParts[0] && 'result' in terminalParts[0] ? terminalParts[0].result : undefined).toMatchObject({
|
||||
exit_code: 0,
|
||||
stdout: 'Hello from the terminal'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves query args when completion payload omits context', () => {
|
||||
const started = upsertToolPart(
|
||||
[],
|
||||
@@ -849,3 +914,79 @@ describe('mergeFinalAssistantText', () => {
|
||||
expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectUnspokenTurnSpeech', () => {
|
||||
const assistant = (id: string, text: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
|
||||
id,
|
||||
role: 'assistant',
|
||||
parts: text ? [{ type: 'text', text }] : [],
|
||||
...extra
|
||||
})
|
||||
|
||||
const user = (id: string, text: string): ChatMessage => ({
|
||||
id,
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text }]
|
||||
})
|
||||
|
||||
it('includes sealed interim narration AND the final answer of a tool-calling turn', () => {
|
||||
const messages = [
|
||||
user('u1', 'what time is it?'),
|
||||
assistant('a1', 'Let me check the clock.', { interim: true }),
|
||||
assistant('a2', 'It is 9 PM.')
|
||||
]
|
||||
|
||||
const speech = collectUnspokenTurnSpeech(messages, null)
|
||||
|
||||
expect(speech).not.toBeNull()
|
||||
expect(speech?.id).toBe('a1')
|
||||
expect(speech?.text).toBe('Let me check the clock.\n\nIt is 9 PM.')
|
||||
expect(speech?.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the binding id stable while later bubbles stream in', () => {
|
||||
const turnStart = [user('u1', 'go'), assistant('a1', 'Let me check.', { interim: true })]
|
||||
const first = collectUnspokenTurnSpeech(turnStart, null)
|
||||
|
||||
const turnLater = [...turnStart, assistant('a2', 'Still work', { pending: true })]
|
||||
const later = collectUnspokenTurnSpeech(turnLater, null)
|
||||
|
||||
expect(first?.id).toBe('a1')
|
||||
expect(later?.id).toBe('a1')
|
||||
// The earlier snapshot's text is a prefix of the later one — the live
|
||||
// session appends by length, so aggregation must be append-only.
|
||||
expect(later?.text.startsWith(first?.text ?? '')).toBe(true)
|
||||
expect(later?.pending).toBe(true)
|
||||
})
|
||||
|
||||
it('starts after the last spoken message and skips hidden/empty bubbles', () => {
|
||||
const messages = [
|
||||
assistant('a0', 'Spoken last turn.'),
|
||||
user('u1', 'next'),
|
||||
assistant('a1', '', { pending: false }),
|
||||
assistant('a2', 'hidden note', { hidden: true }),
|
||||
assistant('a3', 'The real reply.')
|
||||
]
|
||||
|
||||
const speech = collectUnspokenTurnSpeech(messages, 'a0')
|
||||
|
||||
expect(speech?.id).toBe('a3')
|
||||
expect(speech?.text).toBe('The real reply.')
|
||||
})
|
||||
|
||||
it('reports pending from the newest assistant bubble even when it has no text yet', () => {
|
||||
const messages = [assistant('a1', 'Narration done.', { interim: true }), assistant('a2', '', { pending: true })]
|
||||
|
||||
const speech = collectUnspokenTurnSpeech(messages, null)
|
||||
|
||||
expect(speech?.id).toBe('a1')
|
||||
expect(speech?.text).toBe('Narration done.')
|
||||
expect(speech?.pending).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when everything is spoken or there is no assistant text', () => {
|
||||
expect(collectUnspokenTurnSpeech([], null)).toBeNull()
|
||||
expect(collectUnspokenTurnSpeech([assistant('a1', 'Done.')], 'a1')).toBeNull()
|
||||
expect(collectUnspokenTurnSpeech([user('u1', 'hello'), assistant('a1', '')], null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,6 +150,59 @@ export function chatMessageText(message: ChatMessage): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
export interface UnspokenTurnSpeech {
|
||||
/** First unspoken assistant bubble — stable for the turn, the live speech session binds to it. */
|
||||
id: string
|
||||
/** Whether the newest assistant bubble is still streaming. */
|
||||
pending: boolean
|
||||
/** All unspoken assistant text in message order, bubbles joined on a blank line. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every unspoken assistant bubble after `lastSpokenId`, in order.
|
||||
*
|
||||
* A turn with tool calls produces several assistant bubbles — narration
|
||||
* ("Let me check…") sealed as interims, then the final answer as a fresh
|
||||
* bubble. Voice conversation speaks a turn through ONE live session bound to
|
||||
* one response id, so it needs all of that text as a single growing string;
|
||||
* selecting only one bubble silently drops everything after it. The blank-line
|
||||
* join is a sentence boundary for the server's cutter, so a sealed bubble's
|
||||
* tail is flushed as soon as the next bubble starts.
|
||||
*/
|
||||
export function collectUnspokenTurnSpeech(
|
||||
messages: ChatMessage[],
|
||||
lastSpokenId: string | null
|
||||
): UnspokenTurnSpeech | null {
|
||||
const spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
|
||||
|
||||
let id: string | null = null
|
||||
let pending = false
|
||||
const parts: string[] = []
|
||||
|
||||
for (const message of messages.slice(spokenIndex + 1)) {
|
||||
if (message.role !== 'assistant' || message.hidden) {
|
||||
continue
|
||||
}
|
||||
|
||||
pending = Boolean(message.pending)
|
||||
const text = chatMessageText(message).trim()
|
||||
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
id ??= message.id
|
||||
parts.push(text)
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { id, pending, text: parts.join('\n\n') }
|
||||
}
|
||||
|
||||
const normalizeWs = (value: string) => value.replace(/\s+/g, ' ').trim()
|
||||
|
||||
/**
|
||||
@@ -250,6 +303,29 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown
|
||||
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
|
||||
}
|
||||
|
||||
function transcriptContent(displayKind: SessionMessage['display_kind'], content: string): string | null {
|
||||
return displayKind === 'hidden' ? null : content
|
||||
}
|
||||
|
||||
function timelineDisplayContent(message: SessionMessage, content: string): string {
|
||||
if (message.display_kind === 'model_switch') {
|
||||
return 'model changed'
|
||||
}
|
||||
|
||||
if (message.display_kind === 'async_delegation_complete') {
|
||||
const count =
|
||||
message.display_metadata && 'task_count' in message.display_metadata
|
||||
? message.display_metadata.task_count
|
||||
: undefined
|
||||
|
||||
return count === undefined
|
||||
? 'background agent work finished'
|
||||
: `${count} background agent${count === 1 ? '' : 's'} finished`
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = {
|
||||
reasoning: reasoningPart,
|
||||
text: textPart
|
||||
@@ -356,7 +432,11 @@ function collectToolMatchValues(query: string, context: string, preview: string)
|
||||
|
||||
function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] {
|
||||
const payloadArgs = liveToolArgs(payload)
|
||||
const query = firstStringField(payloadArgs, ['search_term', 'query'])
|
||||
// `question` is clarify's identifying arg: a synthetic row hydrated from
|
||||
// `clarify.request` (a fresh request id) must correlate with the `tool.start`
|
||||
// row (the model's tool_call_id) so the two ids don't produce a duplicate
|
||||
// clarify card — same correlation ClarifyToolPending uses for request↔args.
|
||||
const query = firstStringField(payloadArgs, ['search_term', 'query', 'question', 'command', 'code', 'path'])
|
||||
const context = typeof payload?.context === 'string' ? payload.context.trim() : ''
|
||||
const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : ''
|
||||
|
||||
@@ -369,7 +449,7 @@ function toolPartMatchValues(part: ChatMessagePart): string[] {
|
||||
}
|
||||
|
||||
const args = part.args as Record<string, unknown>
|
||||
const query = firstStringField(args, ['search_term', 'query'])
|
||||
const query = firstStringField(args, ['search_term', 'query', 'question', 'command', 'code', 'path'])
|
||||
const context = typeof args.context === 'string' ? args.context.trim() : ''
|
||||
const preview = typeof args.preview === 'string' ? args.preview.trim() : ''
|
||||
|
||||
@@ -827,7 +907,17 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
}
|
||||
|
||||
const content = message.content || message.text || message.context || message.name
|
||||
const displayContent = displayContentForMessage(message.role, content)
|
||||
|
||||
const displayContent = transcriptContent(
|
||||
message.display_kind,
|
||||
timelineDisplayContent(message, displayContentForMessage(message.role, content))
|
||||
)
|
||||
|
||||
const displayRole =
|
||||
message.display_kind === 'model_switch' || message.display_kind === 'async_delegation_complete'
|
||||
? 'system'
|
||||
: message.role
|
||||
|
||||
const parts: ChatMessagePart[] = []
|
||||
|
||||
const reasoning =
|
||||
@@ -840,7 +930,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
}
|
||||
|
||||
if (displayContent) {
|
||||
parts.push(message.role === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent))
|
||||
parts.push(displayRole === 'assistant' ? assistantTextPart(displayContent) : textPart(displayContent))
|
||||
}
|
||||
|
||||
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
|
||||
@@ -894,8 +984,8 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: `${message.timestamp || Date.now()}-${index}-${message.role}`,
|
||||
role: message.role,
|
||||
id: `${message.timestamp || Date.now()}-${index}-${displayRole}`,
|
||||
role: displayRole,
|
||||
parts,
|
||||
timestamp: message.timestamp
|
||||
})
|
||||
|
||||
@@ -197,7 +197,8 @@ export interface KeybindReadonly {
|
||||
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
||||
{ id: 'composer.send', category: 'composer', keys: ['enter'] },
|
||||
{ id: 'composer.newline', category: 'composer', keys: ['shift+enter'] },
|
||||
{ id: 'composer.steer', category: 'composer', keys: ['mod+enter'] },
|
||||
{ id: 'composer.steer', category: 'composer', keys: ['enter'] },
|
||||
{ id: 'composer.queue', category: 'composer', keys: ['mod+enter'] },
|
||||
{ id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] },
|
||||
{ id: 'composer.mention', category: 'composer', keys: ['@'] },
|
||||
{ id: 'composer.slash', category: 'composer', keys: ['/'] },
|
||||
|
||||
@@ -72,6 +72,22 @@ describe('composerFocusBlockedBySurface', () => {
|
||||
expect(composerFocusBlockedBySurface()).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks while an overlay covers the chat (composer sits behind it)', () => {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.setAttribute('data-overlay-surface', '')
|
||||
document.body.append(overlay)
|
||||
|
||||
expect(composerFocusBlockedBySurface()).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks while a live clarify choices card owns its letter keys', () => {
|
||||
const card = document.createElement('div')
|
||||
card.setAttribute('data-clarify-choices', '')
|
||||
document.body.append(card)
|
||||
|
||||
expect(composerFocusBlockedBySurface()).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks when focus is inside a terminal', () => {
|
||||
const term = document.createElement('div')
|
||||
term.setAttribute('data-terminal', '')
|
||||
@@ -146,4 +162,16 @@ describe('composerFocusKeysAllowed', () => {
|
||||
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false)
|
||||
})
|
||||
|
||||
it('yields letter + Enter keys to a live clarify choices card', () => {
|
||||
const card = document.createElement('div')
|
||||
card.setAttribute('data-clarify-choices', '')
|
||||
document.body.append(card)
|
||||
|
||||
// The clarify card's own A/B/C… + Enter shortcuts must win over type-to-focus.
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false)
|
||||
expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: document.body }), 'enter')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,7 +38,7 @@ const ENTER_ACTIVATES = [
|
||||
].join(',')
|
||||
|
||||
const BLOCKING_SURFACE =
|
||||
'[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper]'
|
||||
'[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper],[data-overlay-surface],[data-clarify-choices]'
|
||||
|
||||
/** True when the focused control would normally handle Enter itself. */
|
||||
export function isActivateOnEnterTarget(target: EventTarget | null): boolean {
|
||||
@@ -47,7 +47,13 @@ export function isActivateOnEnterTarget(target: EventTarget | null): boolean {
|
||||
return Boolean(el && el !== document.body && el !== document.documentElement && el.closest(ENTER_ACTIVATES))
|
||||
}
|
||||
|
||||
/** Dialogs, menus, terminal, full pages, session switcher — they keep their keys. */
|
||||
/**
|
||||
* Dialogs, menus, terminal, full pages, session switcher, any open overlay
|
||||
* (settings / command-center / star map / …), and a live clarify choices card —
|
||||
* they keep their keys, so type-to-focus / soft `/` / Enter stand down rather
|
||||
* than stealing keystrokes those surfaces own (or leaking them into the composer
|
||||
* mounted behind an overlay).
|
||||
*/
|
||||
export function composerFocusBlockedBySurface(): boolean {
|
||||
return (
|
||||
switcherActive() ||
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
downloadGatewayMediaFile,
|
||||
filePathFromMediaPath,
|
||||
gatewayMediaDataUrl,
|
||||
isInlineMediaSrc,
|
||||
isRemoteGateway,
|
||||
mediaExternalUrl
|
||||
mediaExternalUrl,
|
||||
resolveMediaDisplaySrc
|
||||
} from './media'
|
||||
|
||||
describe('isRemoteGateway', () => {
|
||||
@@ -75,6 +77,68 @@ describe('mediaExternalUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveMediaDisplaySrc', () => {
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path.startsWith('/api/fs/read-data-url?')) {
|
||||
return { dataUrl: 'data:image/png;base64,ZHVtbXk=' }
|
||||
}
|
||||
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
api.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
it('recognizes inline image URLs', () => {
|
||||
expect(isInlineMediaSrc('https://example.com/a.png')).toBe(true)
|
||||
expect(isInlineMediaSrc('data:image/png;base64,ZHVtbXk=')).toBe(true)
|
||||
expect(isInlineMediaSrc('/Users/me/a.png')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves web, data, and relative markdown image sources unchanged', async () => {
|
||||
vi.stubGlobal('window', { hermesDesktop: { api } })
|
||||
$connection.set({ mode: 'remote', profile: 'remote-work' } as never)
|
||||
|
||||
await expect(resolveMediaDisplaySrc('https://example.com/a.png')).resolves.toBe('https://example.com/a.png')
|
||||
await expect(resolveMediaDisplaySrc('data:image/png;base64,ZHVtbXk=')).resolves.toBe(
|
||||
'data:image/png;base64,ZHVtbXk='
|
||||
)
|
||||
await expect(resolveMediaDisplaySrc('images/a.png')).resolves.toBe('images/a.png')
|
||||
await expect(resolveMediaDisplaySrc('./images/a.png')).resolves.toBe('./images/a.png')
|
||||
await expect(resolveMediaDisplaySrc('../images/a.png')).resolves.toBe('../images/a.png')
|
||||
expect(api).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads remote gateway-local file paths through the desktop fs bridge', async () => {
|
||||
vi.stubGlobal('window', { hermesDesktop: { api } })
|
||||
$connection.set({ mode: 'remote', profile: 'remote-work' } as never)
|
||||
|
||||
await expect(resolveMediaDisplaySrc('/Users/me/project/a b.png')).resolves.toBe('data:image/png;base64,ZHVtbXk=')
|
||||
expect(api).toHaveBeenCalledWith({
|
||||
path: '/api/fs/read-data-url?path=%2FUsers%2Fme%2Fproject%2Fa%20b.png',
|
||||
profile: 'remote-work'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads local desktop file paths from the local desktop shell', async () => {
|
||||
const readFileDataUrl = vi.fn(async () => 'data:image/png;base64,bG9jYWw=')
|
||||
|
||||
vi.stubGlobal('window', { hermesDesktop: { readFileDataUrl } })
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
|
||||
await expect(resolveMediaDisplaySrc('file:///Users/me/project/a%20b.png')).resolves.toBe(
|
||||
'data:image/png;base64,bG9jYWw='
|
||||
)
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('/Users/me/project/a b.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gatewayMediaDataUrl', () => {
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path.startsWith('/api/fs/read-data-url?')) {
|
||||
|
||||
@@ -58,6 +58,30 @@ export function mediaMarkdownHref(path: string): string {
|
||||
return `#media:${encodeURIComponent(path)}`
|
||||
}
|
||||
|
||||
export function isInlineMediaSrc(path: string): boolean {
|
||||
return /^(?:https?|data):/i.test(path)
|
||||
}
|
||||
|
||||
function isFileMediaPath(path: string): boolean {
|
||||
return /^(?:file:|\/|~\/|[a-z]:[\\/]|\\\\)/i.test(path)
|
||||
}
|
||||
|
||||
export async function resolveMediaDisplaySrc(path: string): Promise<string> {
|
||||
if (isInlineMediaSrc(path) || !isFileMediaPath(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
if (window.hermesDesktop && isRemoteGateway()) {
|
||||
return gatewayMediaDataUrl(path)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
}
|
||||
|
||||
// Resolve a media path to a URL the shell can open. Remote mode rewrites
|
||||
// gateway-local paths to an authenticated /api/files/download URL (the file
|
||||
// lives on the gateway, not this disk); local mode keeps the file:// form.
|
||||
|
||||
@@ -12,6 +12,24 @@ describe('isProviderSetupErrorMessage', () => {
|
||||
expect(isProviderSetupErrorMessage('set an API key (OPENROUTER_API_KEY) in ~/.hermes/.env')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the exact empty-key warning emitted in session.info', () => {
|
||||
expect(
|
||||
isProviderSetupErrorMessage("No API key configured for provider 'openrouter'. First message will fail.")
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match bare env var mentions from auxiliary warnings', () => {
|
||||
expect(isProviderSetupErrorMessage('OPENROUTER_API_KEY not set')).toBe(false)
|
||||
expect(isProviderSetupErrorMessage('Run `hermes setup` or set OPENROUTER_API_KEY.')).toBe(false)
|
||||
expect(
|
||||
isProviderSetupErrorMessage(
|
||||
'⚠ No auxiliary LLM provider configured — context compression will drop middle turns without a summary. Run `hermes setup` or set OPENROUTER_API_KEY.'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(isProviderSetupErrorMessage('OPENAI_API_KEY missing')).toBe(false)
|
||||
expect(isProviderSetupErrorMessage('ANTHROPIC_API_KEY not found')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match non-provider runtime failures', () => {
|
||||
expect(
|
||||
isProviderSetupErrorMessage('Selected runtime is not available. setup.status reports configured credentials.')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const PROVIDER_SETUP_ERROR_RE =
|
||||
/No (?:inference|Hermes) provider(?: is)? configured|no_provider_configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i
|
||||
/No (?:inference|Hermes) provider(?: is)? configured|no_provider_configured|set an API key/i
|
||||
|
||||
const SESSION_INFO_CREDENTIAL_WARNING_RE = /^No API key configured for provider '[^']*'\. First message will fail\.$/
|
||||
|
||||
export function isProviderSetupErrorMessage(message: null | string | undefined): boolean {
|
||||
const text = message?.trim()
|
||||
@@ -8,5 +10,5 @@ export function isProviderSetupErrorMessage(message: null | string | undefined):
|
||||
return false
|
||||
}
|
||||
|
||||
return PROVIDER_SETUP_ERROR_RE.test(text)
|
||||
return PROVIDER_SETUP_ERROR_RE.test(text) || SESSION_INFO_CREDENTIAL_WARNING_RE.test(text)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { beforeEach, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
type AgentNoticePayload,
|
||||
clearAgentNotice,
|
||||
nativeNoticeInput,
|
||||
noticeAccent,
|
||||
noticeToToast,
|
||||
showAgentNotice,
|
||||
splitMeta,
|
||||
stripGlyph,
|
||||
usageFraction
|
||||
} from './agent-notices'
|
||||
import { $notifications, clearNotifications } from './notifications'
|
||||
|
||||
function usage(overrides: Partial<AgentNoticePayload> = {}): AgentNoticePayload {
|
||||
return {
|
||||
key: 'credits.usage',
|
||||
kind: 'sticky',
|
||||
level: 'info',
|
||||
text: "• You've used $110.00 of your $220.00 cap",
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearNotifications()
|
||||
})
|
||||
|
||||
// ── noticeToToast: the whole mapping contract ────────────────────────────────
|
||||
|
||||
test('drops a notice with no text', () => {
|
||||
expect(noticeToToast(undefined)).toBeNull()
|
||||
expect(noticeToToast({ text: '' })).toBeNull()
|
||||
expect(noticeToToast({ text: ' ' })).toBeNull()
|
||||
})
|
||||
|
||||
test('level maps to toast kind (warn → warning)', () => {
|
||||
expect(noticeToToast(usage({ level: 'info' }))?.kind).toBe('info')
|
||||
expect(noticeToToast(usage({ level: 'warn' }))?.kind).toBe('warning')
|
||||
expect(noticeToToast(usage({ level: 'error' }))?.kind).toBe('error')
|
||||
expect(noticeToToast(usage({ level: 'success' }))?.kind).toBe('success')
|
||||
})
|
||||
|
||||
test('unknown / missing level falls back to info', () => {
|
||||
expect(noticeToToast({ text: 'x', level: 'bogus' })?.kind).toBe('info')
|
||||
expect(noticeToToast({ text: 'x' })?.kind).toBe('info')
|
||||
})
|
||||
|
||||
test('sticky notices never auto-dismiss', () => {
|
||||
expect(noticeToToast(usage({ kind: 'sticky' }))?.durationMs).toBe(0)
|
||||
})
|
||||
|
||||
test('ttl notice carries its ttl_ms as the duration', () => {
|
||||
const toast = noticeToToast({
|
||||
key: 'credits.restored',
|
||||
kind: 'ttl',
|
||||
level: 'success',
|
||||
text: '✓ restored',
|
||||
ttl_ms: 8000
|
||||
})
|
||||
|
||||
expect(toast?.durationMs).toBe(8000)
|
||||
})
|
||||
|
||||
test('ttl notice without a usable ttl_ms defers to notify()’s default', () => {
|
||||
expect(noticeToToast({ text: 'x', kind: 'ttl' })?.durationMs).toBeUndefined()
|
||||
expect(noticeToToast({ text: 'x', kind: 'ttl', ttl_ms: 0 })?.durationMs).toBeUndefined()
|
||||
})
|
||||
|
||||
test('the notice key is the toast id, falling back to id', () => {
|
||||
expect(noticeToToast(usage({ key: 'credits.usage' }))?.id).toBe('credits.usage')
|
||||
expect(noticeToToast({ text: 'x', id: 'n1', key: undefined })?.id).toBe('n1')
|
||||
})
|
||||
|
||||
test('the leading severity glyph is stripped from the toast message', () => {
|
||||
// The toast renders a kind icon, so the message must not double up on a glyph.
|
||||
expect(noticeToToast(usage())?.message).toBe("You've used $110.00 of your $220.00 cap")
|
||||
expect(noticeToToast(usage({ level: 'error', text: '✕ Credit access paused' }))?.message).toBe('Credit access paused')
|
||||
expect(noticeToToast(usage({ level: 'success', text: '✓ Credit access restored' }))?.message).toBe(
|
||||
'Credit access restored'
|
||||
)
|
||||
})
|
||||
|
||||
test('the trailing "· detail" is split off as a secondary meta line, not inlined', () => {
|
||||
// grant_spent still carries a `· detail` tail.
|
||||
const grant = noticeToToast({ key: 'credits.grant_spent', level: 'info', text: '• Grant spent · $12.00 top-up left' })
|
||||
expect(grant?.message).toBe('Grant spent')
|
||||
expect(grant?.meta).toBe('$12.00 top-up left')
|
||||
|
||||
// The usage line has no middot → whole line is the message, no meta.
|
||||
const plain = noticeToToast(usage())
|
||||
expect(plain?.message).toBe("You've used $110.00 of your $220.00 cap")
|
||||
expect(plain?.meta).toBeUndefined()
|
||||
})
|
||||
|
||||
test('splitMeta splits on the first space-middot-space only', () => {
|
||||
expect(splitMeta('Grant spent · $12.00 top-up left')).toEqual(['Grant spent', '$12.00 top-up left'])
|
||||
expect(splitMeta('Credit access restored')).toEqual(['Credit access restored', undefined])
|
||||
// Interior middots after the first split stay in the meta.
|
||||
expect(splitMeta('a · b · c')).toEqual(['a', 'b · c'])
|
||||
})
|
||||
|
||||
test('stripGlyph removes only a single leading severity glyph', () => {
|
||||
expect(stripGlyph('• Credits 50% used')).toBe('Credits 50% used')
|
||||
expect(stripGlyph('⚠ warn')).toBe('warn')
|
||||
expect(stripGlyph('✕ paused')).toBe('paused')
|
||||
expect(stripGlyph('✓ ok')).toBe('ok')
|
||||
// No leading glyph → unchanged; interior glyphs are preserved.
|
||||
expect(stripGlyph('Credits 50% used')).toBe('Credits 50% used')
|
||||
expect(stripGlyph('spent · $12.00 • top-up left')).toBe('spent · $12.00 • top-up left')
|
||||
})
|
||||
|
||||
// ── noticeAccent: severity color ramp keyed off $used / $cap ─────────────────
|
||||
|
||||
test('usageFraction derives $used / $cap from the notice text', () => {
|
||||
expect(usageFraction("You've used $15.00 of your $20.00 cap")).toBeCloseTo(0.75)
|
||||
expect(usageFraction("You've used $198.00 of your $220.00 cap")).toBeCloseTo(0.9)
|
||||
// Fewer than two amounts, or a zero cap → no fraction.
|
||||
expect(usageFraction('Grant spent')).toBeNull()
|
||||
expect(usageFraction("You've used $5.00 of your $0.00 cap")).toBeNull()
|
||||
expect(usageFraction(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
test('usage accent stays muted below 75%, then ramps orange → red', () => {
|
||||
expect(noticeAccent(usage({ text: "• You've used $10.00 of your $20.00 cap" }))).toBeUndefined() // 50%
|
||||
expect(noticeAccent(usage({ text: "• You've used $14.80 of your $20.00 cap" }))).toBeUndefined() // 74%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))).toBe(
|
||||
'var(--ui-orange)'
|
||||
) // 75%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $17.80 of your $20.00 cap" }))).toBe(
|
||||
'var(--ui-orange)'
|
||||
) // 89%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 90%
|
||||
expect(noticeAccent(usage({ level: 'warn', text: "⚠ You've used $20.00 of your $20.00 cap" }))).toBe('var(--ui-red)') // 100%
|
||||
})
|
||||
|
||||
test('terminal credit states carry their own accent; others stay default', () => {
|
||||
expect(noticeAccent({ key: 'credits.depleted', text: '✕ paused' })).toBe('var(--ui-red)')
|
||||
expect(noticeAccent({ key: 'credits.restored', text: '✓ restored' })).toBe('var(--ui-green)')
|
||||
expect(noticeAccent({ key: 'credits.grant_spent', text: '• Grant spent' })).toBeUndefined()
|
||||
expect(noticeAccent(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('noticeToToast attaches the band accent to the toast', () => {
|
||||
expect(noticeToToast(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))?.accentColor).toBe(
|
||||
'var(--ui-orange)'
|
||||
)
|
||||
expect(noticeToToast(usage({ text: "• You've used $10.00 of your $20.00 cap" }))?.accentColor).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── show / clear: rendered through the notifications store ────────────────────
|
||||
|
||||
test('showAgentNotice renders a toast; empty text is a no-op', () => {
|
||||
showAgentNotice(usage())
|
||||
expect($notifications.get()).toHaveLength(1)
|
||||
expect($notifications.get()[0]?.id).toBe('credits.usage')
|
||||
|
||||
showAgentNotice({ text: '' })
|
||||
expect($notifications.get()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('re-emitting the same key replaces the toast instead of stacking (50→75→90)', () => {
|
||||
showAgentNotice(usage({ level: 'info', text: "• You've used $10.00 of your $20.00 cap" }))
|
||||
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $15.00 of your $20.00 cap" }))
|
||||
showAgentNotice(usage({ level: 'warn', text: "⚠ You've used $18.00 of your $20.00 cap" }))
|
||||
|
||||
const toasts = $notifications.get().filter(item => item.id === 'credits.usage')
|
||||
expect(toasts).toHaveLength(1)
|
||||
expect(toasts[0]?.message).toBe("You've used $18.00 of your $20.00 cap")
|
||||
expect(toasts[0]?.kind).toBe('warning')
|
||||
})
|
||||
|
||||
test('clearAgentNotice dismisses only the matching key', () => {
|
||||
showAgentNotice(usage())
|
||||
showAgentNotice({ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ paused' })
|
||||
expect($notifications.get()).toHaveLength(2)
|
||||
|
||||
clearAgentNotice('credits.usage')
|
||||
const ids = $notifications.get().map(item => item.id)
|
||||
expect(ids).toContain('credits.depleted')
|
||||
expect(ids).not.toContain('credits.usage')
|
||||
|
||||
clearAgentNotice(undefined)
|
||||
expect($notifications.get()).toHaveLength(1)
|
||||
})
|
||||
|
||||
// ── nativeNoticeInput: only the urgent credit pair breaks through the OS ──────
|
||||
|
||||
test('only credits.depleted and credits.restored map to a native notification', () => {
|
||||
expect(nativeNoticeInput(usage({ key: 'credits.usage' }), 'Credits')).toBeNull()
|
||||
expect(nativeNoticeInput(usage({ key: 'credits.grant_spent' }), 'Credits')).toBeNull()
|
||||
expect(nativeNoticeInput({ text: 'x', key: undefined }, 'Credits')).toBeNull()
|
||||
expect(nativeNoticeInput({ text: '', key: 'credits.depleted' }, 'Credits')).toBeNull()
|
||||
})
|
||||
|
||||
test('the urgent pair maps to a global native input carrying the text as its body', () => {
|
||||
const depleted = nativeNoticeInput(
|
||||
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
|
||||
'Credits'
|
||||
)
|
||||
|
||||
expect(depleted).toEqual({
|
||||
body: '✕ Credit access paused · run /topup to top up',
|
||||
global: true,
|
||||
kind: 'credits',
|
||||
title: 'Credits'
|
||||
})
|
||||
|
||||
const restored = nativeNoticeInput(
|
||||
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 },
|
||||
'Credits'
|
||||
)
|
||||
|
||||
expect(restored?.kind).toBe('credits')
|
||||
expect(restored?.body).toBe('✓ Credit access restored')
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { NativeNotificationInput } from '@/store/native-notifications'
|
||||
import { dismissNotification, type NotificationInput, type NotificationKind, notify } from '@/store/notifications'
|
||||
|
||||
/**
|
||||
* Wire shape of a `notification.show` payload — the driver-agnostic
|
||||
* `AgentNotice` spine (`agent/credits_tracker.py`) as forwarded by
|
||||
* `tui_gateway/server.py`. Snake_case to match the wire.
|
||||
*
|
||||
* The `text` carries its own leading severity glyph (• ⚠ ✕ ✓) from the Python
|
||||
* policy — that's how the CLI/TUI render it (glyph in a status line, no separate
|
||||
* icon). The desktop toast is different: every toast renders a kind icon, so we
|
||||
* strip the leading glyph and let that icon carry severity (see `stripGlyph`),
|
||||
* otherwise the toast shows two markers. The native OS notification keeps the
|
||||
* glyph (it has no icon of ours).
|
||||
*
|
||||
* - `level` is severity: info | warn | error | success.
|
||||
* - `kind` is lifetime: `sticky` (stays until an explicit clear) or `ttl`
|
||||
* (self-expires after `ttl_ms`).
|
||||
*/
|
||||
export interface AgentNoticePayload {
|
||||
text?: string
|
||||
level?: string
|
||||
kind?: string
|
||||
ttl_ms?: null | number
|
||||
key?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const LEVEL_TO_TOAST_KIND: Record<string, NotificationKind> = {
|
||||
error: 'error',
|
||||
info: 'info',
|
||||
success: 'success',
|
||||
warn: 'warning'
|
||||
}
|
||||
|
||||
// The severity glyphs the Python notice policy prefixes (`•` `⚠` `✕`/`✗` `✓`),
|
||||
// optionally with a variation selector, plus trailing space. Stripped for the
|
||||
// desktop toast because the toast already renders a kind icon.
|
||||
const LEADING_GLYPH = /^[•⚠✕✗✓]\uFE0F?\s*/u
|
||||
|
||||
/** Drop a single leading severity glyph so the toast doesn't double up on it. */
|
||||
export function stripGlyph(text: string): string {
|
||||
return text.replace(LEADING_GLYPH, '')
|
||||
}
|
||||
|
||||
/** A `$12.34` money token, as the Nous notice policy formats amounts. */
|
||||
const MONEY = /\$(\d+(?:\.\d{2})?)/g
|
||||
|
||||
/**
|
||||
* Used fraction of the cap derived from a "You've used $X of your $Y cap" notice
|
||||
* — the first two money tokens are (used, cap). Returns a value in [0, 1], or
|
||||
* `null` when the line has no usable pair.
|
||||
*/
|
||||
export function usageFraction(text: string | undefined): null | number {
|
||||
const amounts = [...(text ?? '').matchAll(MONEY)].map(m => Number(m[1]))
|
||||
|
||||
if (amounts.length < 2 || !(amounts[1] > 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return amounts[0] / amounts[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Accent color for a credit notice, as a range/severity ramp. The usage gauge
|
||||
* keys off how much of the cap is spent ($used / $cap): it stays on the toast's
|
||||
* default muted color under 75%, then escalates to orange, then red, as the cap
|
||||
* nears. `credits.depleted` is red (paused) and `credits.restored` green.
|
||||
*
|
||||
* Returns a CSS color token or `undefined` (= keep the default muted color).
|
||||
* These reuse the app's existing usage palette (`--ui-*`; see the
|
||||
* `--context-usage-*` block in styles.css) — no new colors are introduced.
|
||||
*/
|
||||
export function noticeAccent(payload: AgentNoticePayload | undefined): string | undefined {
|
||||
if (payload?.key === 'credits.depleted') {
|
||||
return 'var(--ui-red)'
|
||||
}
|
||||
|
||||
if (payload?.key === 'credits.restored') {
|
||||
return 'var(--ui-green)'
|
||||
}
|
||||
|
||||
const frac = usageFraction(payload?.text)
|
||||
|
||||
if (frac === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (frac >= 0.9) {
|
||||
return 'var(--ui-red)'
|
||||
}
|
||||
|
||||
if (frac >= 0.75) {
|
||||
return 'var(--ui-orange)'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an agent notice to a toast input, or `null` when it carries no text.
|
||||
*
|
||||
* Pure and side-effect free so it can be unit-tested directly. The mapping is
|
||||
* the whole contract:
|
||||
* - `level` → toast kind (info/warn/error/success, warn→warning).
|
||||
* - `sticky` → `durationMs: 0` (persists); `ttl` → `durationMs: ttl_ms`.
|
||||
* - the notice `key` doubles as the toast `id`, so re-emitting the same key
|
||||
* REPLACES the prior toast — the credits 50→75→90 line escalates in place
|
||||
* instead of stacking, and a key-matched `notification.clear` can dismiss it.
|
||||
*/
|
||||
export function noticeToToast(payload: AgentNoticePayload | undefined): NotificationInput | null {
|
||||
const text = payload?.text?.trim()
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isTtl = payload?.kind === 'ttl'
|
||||
const ttl = typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0 ? payload.ttl_ms : undefined
|
||||
|
||||
// The Python notice text packs a trailing detail after a middot
|
||||
// (`… used · $220.00 cap`, `Grant spent · $12.00 top-up left`). On one CLI/TUI
|
||||
// status line that reads fine, but the toast follows the title-plus-description
|
||||
// convention (Sonner/shadcn): the primary status is the message and the detail
|
||||
// drops to a muted second line, instead of inlining a `·` separator.
|
||||
const [primary, meta] = splitMeta(stripGlyph(text))
|
||||
|
||||
return {
|
||||
// Icon + text tint by usage band (muted → orange → red); undefined keeps
|
||||
// the default muted color.
|
||||
accentColor: noticeAccent(payload),
|
||||
// sticky → 0 (never auto-dismiss); ttl with a ttl_ms → that value; a ttl
|
||||
// without a usable ttl_ms falls back to notify()'s per-kind default.
|
||||
durationMs: isTtl ? ttl : 0,
|
||||
id: payload?.key || payload?.id,
|
||||
kind: LEVEL_TO_TOAST_KIND[payload?.level ?? 'info'] ?? 'info',
|
||||
message: primary,
|
||||
meta
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a notice line into its primary status and a trailing detail on the first
|
||||
* ` · ` (space-middot-space). No middot → the whole line is the primary and
|
||||
* there's no meta.
|
||||
*/
|
||||
export function splitMeta(text: string): [primary: string, meta: string | undefined] {
|
||||
const at = text.indexOf(' · ')
|
||||
|
||||
if (at === -1) {
|
||||
return [text, undefined]
|
||||
}
|
||||
|
||||
return [text.slice(0, at), text.slice(at + 3) || undefined]
|
||||
}
|
||||
|
||||
/** Render a `notification.show` notice as a toast (no-op when it has no text). */
|
||||
export function showAgentNotice(payload: AgentNoticePayload | undefined): void {
|
||||
const toast = noticeToToast(payload)
|
||||
|
||||
if (toast) {
|
||||
notify(toast)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the toast a `notification.clear` targets. The clear only ever names a
|
||||
* `key`, which we used as the toast id, so this is a key-matched dismissal.
|
||||
*/
|
||||
export function clearAgentNotice(key: string | undefined): void {
|
||||
if (key) {
|
||||
dismissNotification(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Only these two credit notices are urgent enough to break through as a native
|
||||
// OS notification (when Hermes is backgrounded). The escalating usage line
|
||||
// (`credits.usage`) and the grant-spent notice stay in-app toasts only — they
|
||||
// aren't worth interrupting the user's OS for.
|
||||
const NATIVE_NOTICE_KEYS = new Set(['credits.depleted', 'credits.restored'])
|
||||
|
||||
/**
|
||||
* Map a notice to a native OS notification input, or `null` when it isn't one of
|
||||
* the urgent credit notices. Pure — the caller passes the localized `title` and
|
||||
* decides whether to dispatch. `global: true` because credit state is
|
||||
* account-wide, not tied to a chat session, so it should fire whenever the user
|
||||
* is away regardless of which session (if any) is focused. The notice `text`
|
||||
* already carries its glyph and is passed through as the raw body.
|
||||
*/
|
||||
export function nativeNoticeInput(
|
||||
payload: AgentNoticePayload | undefined,
|
||||
title: string
|
||||
): NativeNotificationInput | null {
|
||||
const text = payload?.text?.trim()
|
||||
|
||||
if (!text || !payload?.key || !NATIVE_NOTICE_KEYS.has(payload.key)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
body: text,
|
||||
global: true,
|
||||
kind: 'credits',
|
||||
title
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
$clarifyRequests,
|
||||
type ClarifyRequest,
|
||||
clearClarifyRequest,
|
||||
normalizeChoices,
|
||||
setClarifyRequest
|
||||
} from './clarify'
|
||||
import { $activeSessionId } from './session'
|
||||
@@ -79,3 +80,43 @@ describe('clarify store', () => {
|
||||
expect($clarifyRequests.get()['session-b']?.requestId).toBe('other')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeChoices', () => {
|
||||
it('returns empty array for null/undefined', () => {
|
||||
expect(normalizeChoices(null)).toEqual([])
|
||||
expect(normalizeChoices(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(normalizeChoices('hello')).toEqual([])
|
||||
expect(normalizeChoices(42)).toEqual([])
|
||||
expect(normalizeChoices({})).toEqual([])
|
||||
})
|
||||
|
||||
it('filters out non-string items', () => {
|
||||
expect(normalizeChoices(['a', 42, 'b', null, 'c'])).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('drops blank and whitespace-only strings', () => {
|
||||
expect(normalizeChoices(['a', '', 'b', ' ', 'c'])).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('drops strings with newlines', () => {
|
||||
expect(normalizeChoices(['a', 'b\nc', 'd'])).toEqual(['a', 'd'])
|
||||
})
|
||||
|
||||
it('drops strings over 200 chars', () => {
|
||||
const long = 'x'.repeat(201)
|
||||
const ok = 'y'.repeat(200)
|
||||
expect(normalizeChoices(['a', long, ok])).toEqual(['a', ok])
|
||||
})
|
||||
|
||||
it('drops empty items and keeps valid ones', () => {
|
||||
expect(normalizeChoices(['valid', ' ', '', 'also valid'])).toEqual(['valid', 'also valid'])
|
||||
})
|
||||
|
||||
it('returns empty array when nothing survives', () => {
|
||||
expect(normalizeChoices(['', ' ', null, undefined])).toEqual([])
|
||||
expect(normalizeChoices([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,36 @@ export interface ClarifyRequest {
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a choices array.
|
||||
*
|
||||
* Keeps non-blank, newline-free strings of length ≤ 200; drops everything else
|
||||
* and returns an empty array when nothing usable survives — the caller then
|
||||
* falls back to a free-text answer instead of dead buttons.
|
||||
*/
|
||||
export function normalizeChoices(choices: unknown): string[] {
|
||||
if (!Array.isArray(choices)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return choices.filter(
|
||||
(c): c is string => typeof c === 'string' && c.trim().length > 0 && c.length <= 200 && !c.includes('\n')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured warning for a clarify payload that arrived with choices but had
|
||||
* them all normalized away — keeps the remaining #69122 "no selectable choices"
|
||||
* triggers diagnosable in the field without dead constant fields.
|
||||
*/
|
||||
export function warnDroppedChoices(source: 'gateway' | 'tool_args', question: string, rawChoices: unknown): void {
|
||||
console.warn('[clarify] choices dropped after normalization', {
|
||||
choices_count: Array.isArray(rawChoices) ? rawChoices.length : 0,
|
||||
question_length: question.length,
|
||||
source
|
||||
})
|
||||
}
|
||||
|
||||
// Pending clarify requests keyed by the runtime session id that raised them.
|
||||
// Storing per-session (instead of one shared slot) lets a *background* session
|
||||
// park its clarify request while the user is looking at a different chat, then
|
||||
|
||||
@@ -20,30 +20,10 @@ const normKey = (profile: string | null | undefined): string => (profile ?? '').
|
||||
// narrow the getter to a constant across guards (it genuinely changes).
|
||||
const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open'
|
||||
|
||||
// The active gateway instance, exposed for inline message-stream components
|
||||
// (e.g. inline ClarifyTool, model overlays) that call gateway methods without
|
||||
// the instance threaded down through props.
|
||||
export const $gateway = atom<HermesGateway | null>(null)
|
||||
|
||||
interface RegistryConfig {
|
||||
onEvent: (event: GatewayEvent) => void
|
||||
}
|
||||
|
||||
let config: RegistryConfig | null = null
|
||||
|
||||
export function configureGatewayRegistry(cfg: RegistryConfig): void {
|
||||
config = cfg
|
||||
}
|
||||
|
||||
// ── Primary (window) backend ───────────────────────────────────────────────
|
||||
let primaryGateway: HermesGateway | null = null
|
||||
let primaryProfile = 'default'
|
||||
|
||||
export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void {
|
||||
primaryGateway = gateway
|
||||
primaryProfile = normKey(profile)
|
||||
}
|
||||
|
||||
// ── Secondary (pool) backends ──────────────────────────────────────────────
|
||||
interface Secondary {
|
||||
profile: string
|
||||
@@ -58,20 +38,97 @@ interface Secondary {
|
||||
wantOpen: boolean
|
||||
}
|
||||
|
||||
const secondaries = new Map<string, Secondary>()
|
||||
// ── HMR-stable module state ─────────────────────────────────────────────────
|
||||
// All mutable singletons (live sockets, active-profile routing, the event
|
||||
// registry) live in ONE container parked on globalThis, NOT in module-level
|
||||
// `let`/`const` bindings. Reason: this module is imported widely without an HMR
|
||||
// boundary that accepts it, so editing it (or anything that fans out to it)
|
||||
// makes Vite issue a FULL PAGE RELOAD — which would kill every live socket and
|
||||
// drop the agent session on an unrelated edit. Persisting the state on
|
||||
// globalThis + self-accepting HMR (bottom of file) turns that full reload into
|
||||
// an in-place hot update that preserves the sockets. Production strips
|
||||
// import.meta.hot, and a fresh page realm starts with an empty container, so the
|
||||
// runtime behavior is identical to plain module state.
|
||||
interface GatewayRegistryState {
|
||||
config: RegistryConfig | null
|
||||
primaryGateway: HermesGateway | null
|
||||
primaryProfile: string
|
||||
activeKey: string
|
||||
secondaries: Map<string, Secondary>
|
||||
$gateway: ReturnType<typeof atom<HermesGateway | null>>
|
||||
}
|
||||
|
||||
let activeKey = 'default'
|
||||
const STATE_KEY = Symbol.for('hermes.desktop.gatewayRegistryState')
|
||||
|
||||
function createRegistryState(): GatewayRegistryState {
|
||||
return {
|
||||
config: null,
|
||||
primaryGateway: null,
|
||||
primaryProfile: 'default',
|
||||
activeKey: 'default',
|
||||
secondaries: new Map<string, Secondary>(),
|
||||
// The active gateway instance, exposed for inline message-stream
|
||||
// components (inline ClarifyTool, model overlays) that call gateway
|
||||
// methods without the instance threaded down through props.
|
||||
$gateway: atom<HermesGateway | null>(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Dev only: park the singletons on globalThis so an HMR re-eval of this module
|
||||
// (self-accepted at the bottom) hands back the SAME live sockets/atoms instead
|
||||
// of resetting them — that's what keeps the agent session alive across UI edits.
|
||||
// `import.meta.hot` is undefined in production, so Vite dead-code-eliminates the
|
||||
// entire globalThis branch and prod uses a plain module-local singleton — no
|
||||
// globalThis, no Symbol.for. Both realms load the module once, so the container's
|
||||
// shape and lifetime are identical either way.
|
||||
function gatewayState(): GatewayRegistryState {
|
||||
if (import.meta.hot) {
|
||||
const store = globalThis as unknown as { [STATE_KEY]?: GatewayRegistryState }
|
||||
store[STATE_KEY] ??= createRegistryState()
|
||||
|
||||
return store[STATE_KEY]
|
||||
}
|
||||
|
||||
return createRegistryState()
|
||||
}
|
||||
|
||||
const g = gatewayState()
|
||||
|
||||
// Re-exported as a stable binding: the atom instance lives in `g`, so every hot
|
||||
// reload of this module hands back the SAME atom subscribers are already wired
|
||||
// to. (A fresh `atom()` per reload would orphan existing subscriptions.)
|
||||
export const $gateway = g.$gateway
|
||||
|
||||
export function configureGatewayRegistry(cfg: RegistryConfig): void {
|
||||
g.config = cfg
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed a synthetic event through the exact same fan-out a real socket frame
|
||||
* takes (`config.onEvent` → the desktop's `handleGatewayEvent`). Used by
|
||||
* dev-only tooling to exercise the real event branches (e.g. the credit-notice
|
||||
* demo) without a backend that can produce the event on demand. No-op until a
|
||||
* registry is configured.
|
||||
*/
|
||||
export function emitLocalGatewayEvent(event: GatewayEvent): void {
|
||||
g.config?.onEvent(event)
|
||||
}
|
||||
|
||||
export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void {
|
||||
g.primaryGateway = gateway
|
||||
g.primaryProfile = normKey(profile)
|
||||
}
|
||||
|
||||
export function isActivePrimary(): boolean {
|
||||
return activeKey === primaryProfile
|
||||
return g.activeKey === g.primaryProfile
|
||||
}
|
||||
|
||||
export function activeGateway(): HermesGateway | null {
|
||||
if (activeKey === primaryProfile) {
|
||||
return primaryGateway
|
||||
if (g.activeKey === g.primaryProfile) {
|
||||
return g.primaryGateway
|
||||
}
|
||||
|
||||
return secondaries.get(activeKey)?.gateway ?? primaryGateway
|
||||
return g.secondaries.get(g.activeKey)?.gateway ?? g.primaryGateway
|
||||
}
|
||||
|
||||
// Mirror a backend's connection state into the global composer state, but only
|
||||
@@ -79,19 +136,19 @@ export function activeGateway(): HermesGateway | null {
|
||||
// composer reflect the active profile's socket without a background reconnect
|
||||
// flipping the foreground enabled/disabled state.
|
||||
function reportGatewayState(profile: string, state: ConnectionState): void {
|
||||
if (normKey(profile) === activeKey) {
|
||||
if (normKey(profile) === g.activeKey) {
|
||||
setGatewayState(state)
|
||||
}
|
||||
}
|
||||
|
||||
export function reportPrimaryGatewayState(state: ConnectionState): void {
|
||||
reportGatewayState(primaryProfile, state)
|
||||
reportGatewayState(g.primaryProfile, state)
|
||||
}
|
||||
|
||||
function setActive(profile: string): void {
|
||||
activeKey = normKey(profile)
|
||||
g.activeKey = normKey(profile)
|
||||
const gateway = activeGateway()
|
||||
$gateway.set(gateway)
|
||||
g.$gateway.set(gateway)
|
||||
setGatewayState(gateway?.connectionState ?? 'closed')
|
||||
}
|
||||
|
||||
@@ -164,7 +221,7 @@ function createSecondary(profile: string): Secondary {
|
||||
wantOpen: true
|
||||
}
|
||||
|
||||
entry.offEvent = gateway.onEvent(event => config?.onEvent({ ...event, profile }))
|
||||
entry.offEvent = gateway.onEvent(event => g.config?.onEvent({ ...event, profile }))
|
||||
entry.offState = gateway.onState(state => {
|
||||
reportGatewayState(profile, state)
|
||||
|
||||
@@ -176,7 +233,7 @@ function createSecondary(profile: string): Secondary {
|
||||
}
|
||||
})
|
||||
|
||||
secondaries.set(profile, entry)
|
||||
g.secondaries.set(profile, entry)
|
||||
|
||||
return entry
|
||||
}
|
||||
@@ -190,11 +247,11 @@ function createSecondary(profile: string): Secondary {
|
||||
export async function openGatewayForProfile(profile: string): Promise<void> {
|
||||
const key = normKey(profile)
|
||||
|
||||
if (key === primaryProfile) {
|
||||
if (key === g.primaryProfile) {
|
||||
return
|
||||
}
|
||||
|
||||
const entry = secondaries.get(key) ?? createSecondary(key)
|
||||
const entry = g.secondaries.get(key) ?? createSecondary(key)
|
||||
entry.wantOpen = true
|
||||
|
||||
if (!isOpen(entry.gateway)) {
|
||||
@@ -207,13 +264,13 @@ export async function openGatewayForProfile(profile: string): Promise<void> {
|
||||
export async function ensureGatewayForProfile(profile: string): Promise<void> {
|
||||
const key = normKey(profile)
|
||||
|
||||
if (key === primaryProfile) {
|
||||
if (key === g.primaryProfile) {
|
||||
setActive(key)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let entry = secondaries.get(key)
|
||||
let entry = g.secondaries.get(key)
|
||||
|
||||
if (!entry) {
|
||||
entry = createSecondary(key)
|
||||
@@ -238,11 +295,11 @@ export async function ensureGatewayForProfile(profile: string): Promise<void> {
|
||||
// Reconnect the active gateway after a transient request failure. Primary
|
||||
// reconnects are owned by use-gateway-boot, so we only drive secondaries here.
|
||||
export async function ensureActiveGatewayOpen(): Promise<HermesGateway | null> {
|
||||
if (activeKey === primaryProfile) {
|
||||
return primaryGateway
|
||||
if (g.activeKey === g.primaryProfile) {
|
||||
return g.primaryGateway
|
||||
}
|
||||
|
||||
const entry = secondaries.get(activeKey)
|
||||
const entry = g.secondaries.get(g.activeKey)
|
||||
|
||||
if (!entry) {
|
||||
return null
|
||||
@@ -257,7 +314,7 @@ export async function ensureActiveGatewayOpen(): Promise<HermesGateway | null> {
|
||||
|
||||
// Wake signal (sleep/network/visibility): nudge every live secondary back open.
|
||||
export function reconnectSecondaryGateways(): void {
|
||||
for (const entry of secondaries.values()) {
|
||||
for (const entry of g.secondaries.values()) {
|
||||
if (!entry.wantOpen || isOpen(entry.gateway)) {
|
||||
continue
|
||||
}
|
||||
@@ -273,38 +330,47 @@ export function reconnectSecondaryGateways(): void {
|
||||
export function touchSecondaryGateways(): void {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
for (const entry of secondaries.values()) {
|
||||
for (const entry of g.secondaries.values()) {
|
||||
if (entry.wantOpen) {
|
||||
void desktop?.touchBackend?.(entry.profile).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tear a secondary down: stop its reconnect loop, detach listeners, close the
|
||||
// socket. Caller handles removal from the map.
|
||||
function disposeSecondary(entry: Secondary): void {
|
||||
entry.wantOpen = false
|
||||
clearTimer(entry)
|
||||
entry.offEvent()
|
||||
entry.offState()
|
||||
entry.gateway.close()
|
||||
}
|
||||
|
||||
// Close + evict secondaries whose profile is neither active nor in `keep`
|
||||
// (profiles with a running / needs-input session). Bounds cost to live work.
|
||||
export function pruneSecondaryGateways(keep: Set<string>): void {
|
||||
for (const [key, entry] of [...secondaries]) {
|
||||
if (key === activeKey || keep.has(key)) {
|
||||
for (const [key, entry] of [...g.secondaries]) {
|
||||
if (key === g.activeKey || keep.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
entry.wantOpen = false
|
||||
clearTimer(entry)
|
||||
entry.offEvent()
|
||||
entry.offState()
|
||||
entry.gateway.close()
|
||||
secondaries.delete(key)
|
||||
disposeSecondary(entry)
|
||||
g.secondaries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export function closeSecondaryGateways(): void {
|
||||
for (const entry of secondaries.values()) {
|
||||
entry.wantOpen = false
|
||||
clearTimer(entry)
|
||||
entry.offEvent()
|
||||
entry.offState()
|
||||
entry.gateway.close()
|
||||
for (const entry of g.secondaries.values()) {
|
||||
disposeSecondary(entry)
|
||||
}
|
||||
|
||||
secondaries.clear()
|
||||
g.secondaries.clear()
|
||||
}
|
||||
|
||||
// Self-accept so editing this module (or a fan-out that lands here) is an
|
||||
// in-place hot update instead of a full page reload — the live sockets in `g`
|
||||
// survive the swap. Dev-only: production strips import.meta.hot.
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept()
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ function migrateWorkspaceCollapsedIds(): Record<string, boolean> {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-derived (git-repo) projects the user has dismissed ("deleted") from the
|
||||
// overview. Keyed by repo-root path; persisted so they stay hidden. Explicit
|
||||
// projects are deleted for real instead — this only declutters the auto tier.
|
||||
|
||||
@@ -8,14 +8,15 @@ import { $activeSessionId } from './session'
|
||||
|
||||
// Native OS notifications (Electron `Notification`), separate from the in-app
|
||||
// toast feed in `notifications.ts`. Each kind toggles independently.
|
||||
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError'
|
||||
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError'
|
||||
|
||||
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
|
||||
'approval',
|
||||
'input',
|
||||
'turnDone',
|
||||
'turnError',
|
||||
'backgroundDone'
|
||||
'backgroundDone',
|
||||
'credits'
|
||||
]
|
||||
|
||||
// Blocking prompts — surface even while focused if they're for another session.
|
||||
@@ -30,7 +31,7 @@ const STORAGE_KEY = 'hermes:native-notifications'
|
||||
|
||||
const DEFAULT_PREFS: NativeNotificationPrefs = {
|
||||
enabled: true,
|
||||
kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
|
||||
kinds: { approval: true, backgroundDone: true, credits: true, input: true, turnDone: true, turnError: true }
|
||||
}
|
||||
|
||||
function readPrefs(): NativeNotificationPrefs {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, expect, test } from 'vitest'
|
||||
|
||||
import { $notifications, clearNotifications, notifyError } from './notifications'
|
||||
|
||||
beforeEach(() => {
|
||||
clearNotifications()
|
||||
})
|
||||
|
||||
function lastMessage(): string {
|
||||
return $notifications.get()[0]?.message ?? ''
|
||||
}
|
||||
|
||||
// Regression for #39365: a gateway auth 401 (bad API_SERVER_KEY) must not be
|
||||
// summarized as a provider (OpenAI/OpenRouter) API key problem.
|
||||
test('gateway_auth_failed error is summarized as gateway auth, not provider key', () => {
|
||||
notifyError(
|
||||
new Error(
|
||||
'401 {"error": {"message": "Invalid gateway API key (API_SERVER_KEY)", "type": "gateway_auth_error", "code": "gateway_auth_failed"}}'
|
||||
),
|
||||
'Request failed'
|
||||
)
|
||||
|
||||
expect(lastMessage()).toContain('API_SERVER_KEY')
|
||||
expect(lastMessage()).not.toMatch(/OpenAI/i)
|
||||
})
|
||||
|
||||
test('provider invalid_api_key error still maps to the OpenAI summary', () => {
|
||||
notifyError(
|
||||
new Error('401 {"error": {"message": "Incorrect API key provided", "code": "invalid_api_key"}}'),
|
||||
'Request failed'
|
||||
)
|
||||
|
||||
expect(lastMessage()).toMatch(/OpenAI rejected the API key/i)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user