2357 Commits

Author SHA1 Message Date
chengyongru
584072cf63 refactor: restrict fallback_models to preset-only and clean up provider factory
- Restrict fallback_models to only reference preset names in model_presets.
- Add schema validation to reject unknown preset names in fallback_models.
- Remove build_provider_for_model() since bare model fallback is no longer supported.
- Simplify make_provider_factory() to only look up presets by name.
- Update onboard UI to remove "Add custom model" option from fallback chain.
- Update tests to use preset names instead of bare model strings in fallback chains.
- Fix test imports referencing deleted _make_provider function.
2026-05-08 20:16:06 +08:00
hanyuanling
7c270577e1 Refine fallback routing on model presets 2026-05-08 20:16:06 +08:00
LeftX
2e5930e355 feat: add fallback_models support for automatic model failover
When the primary model fails (finish_reason="error" after exhausting
provider-level retries), automatically try each model in the configured
fallback_models list. Supports cross-provider fallback via a cached
provider_factory that resolves the correct provider for each model string.

Config:
  agents.defaults.fallback_models: ["model-b", "provider/model-c"]

Changes:
- AgentDefaults: add fallback_models field
- AgentRunSpec: add fallback_models field
- AgentRunner: add provider_factory, _call_provider, _resolve_fallback_provider
- AgentLoop: accept and forward fallback_models + provider_factory
- nanobot.py: extract _make_provider_for_model, add _make_provider_factory
- cli/commands.py: add _make_cli_provider_factory, wire all AgentLoop sites
- tests/agent/test_runner_fallback.py: 8 test cases covering primary success,
  single/multi fallback, cross-provider, no-factory reuse, caching

Made-with: Cursor
2026-05-08 20:16:06 +08:00
chengyongru
83f437a088 feat(config): add model preset support for runtime model switching
Add ModelPresetConfig schema and model_presets dictionary to config,
enabling named bundles of model parameters (model, temperature,
max_tokens, reasoning_effort, context_window_tokens) that can be
switched atomically at runtime via the self tool.
2026-05-08 20:16:06 +08:00
chengyongru
e34b7fd086 fix(onboard): allow empty strings and falsy values in input fields
Fixes two related input-handling bugs in the onboard wizard:

1. _input_text treated "" as None, preventing users from clearing
   optional string fields or entering empty strings intentionally.

2. _input_model_with_autocomplete used `if value else None`, which
   discarded falsy values such as empty strings or 0.

To support clearing optional string fields, add _is_str_or_none() and
normalize empty strings to None inside _configure_pydantic_model only
when the field annotation is `str | None`. Required str fields keep
"" as a valid value.

Also included:
- Remember last selected item in provider/channel/model menus for
  better UX when configuring multiple items.
- Rename _SIMPLE_TYPES and _MENU_DISPATCH to lowercase to follow
  Python naming conventions (they are local variables, not constants).
- Remove unused imports in test file.

Extracted from PR #3358.
2026-05-08 13:13:20 +08:00
chengyongru
12005c20f0 fix(weixin): distinguish stale session from rate limit on ret=-2
Reference hermes-agent#17228 / #18100 / PR#18105.

iLink returns ret=-2 / errcode=-2 for two different reasons:
- stale context_token: errmsg is empty/None or "unknown error"
- genuine rate limit: errmsg is populated (e.g. "frequency limit")

Previously we swallowed all ret=-2 responses, which caused silent
message drops when the context_token was stale.

Changes:
- Add _is_stale_session_ret() to detect empty/"unknown error" errmsg
- _send_text/_send_media_file retry once without context_token on stale
  session signal, then raise on persistent failure so ChannelManager
  can retry with backoff
- Remove error-swallowing behavior
- Update tests to expect raises and add TestIsStaleSessionRet coverage
2026-05-08 09:41:12 +08:00
chengyongru
9fefb31344 fix(weixin): treat ret=-2 as non-fatal on sendmessage and align client_id format
The iLink sendmessage API frequently returns ret=-2 (parameter error / rate
limit / expired token) even when HTTP status is 200.  The openclaw reference
plugin ignores the JSON body for sendmessage entirely and only checks HTTP
status.  Our previous strict ret checking turned ret=-2 into RuntimeError,
causing ChannelManager retries which only made things worse.

Changes:
- _send_text: swallow ret=-2 after one retry without context_token.
  Log request body + response at warning level for diagnostics.
- _send_media_file: same ret=-2 swallowing.
- _generate_client_id: change format to ``nanobot:{timestamp}-{hex}`` to
  match openclaw-weixin ``{prefix}:{Date.now()}-{hex}``.
- Update tests to expect swallowing instead of raising for ret=-2.
2026-05-07 18:11:06 +08:00
chengyongru
28358980ed fix(weixin): retry send without expired context_token on ret=-2
When the iLink API returns ret=-2 (parameter error), it is often caused
by an expired context_token rather than a malformed payload. After a
gateway restart, the cached token can become stale within ~90 seconds if
no new inbound message refreshes it, causing all outbound replies to fail
silently.

Changes:
- _send_text: retry once without context_token when ret=-2 and a token
  was present; if the retry succeeds, clear the expired token from cache.
- Remove leftover @staticmethod on _check_response_error so self.logger
  and the body parameter work correctly.
- Bump WEIXIN_CHANNEL_VERSION from 2.1.1 -> 2.1.7 to match the reference
  openclaw-weixin plugin.
- Add tests covering the ret=-2 retry path, failure path, and no-token
  path.

References:
- openclaw/openclaw#61174 (context_token expiry after long agent turns)
- hermes-agent#21011 (ret=-2 rate limiting / parameter error)
2026-05-07 17:43:04 +08:00
chengyongru
e9f4a868a8 fix(weixin): check both ret and errcode on send to avoid silent drops
The iLink API signals failures through either `ret` or `errcode`.
`_poll_once` already checked both, but `_send_text` and `_send_media_file`
only checked `errcode`. When the API returned `ret != 0` with
`errcode == 0`, the send appeared successful but the message was never
delivered, causing the "still losing messages" issue.

- Add `_check_response_error` helper that validates both fields
- Use it in `_send_text` and `_send_media_file`
- Add debug log after successful text send for observability
- Add test for nonzero ret with zero errcode

Refs: previous inbound fix (suppress -> explicit try/except)
2026-05-07 16:37:31 +08:00
chengyongru
2a318d6991 fix(weixin): log exceptions instead of silently dropping messages in poll loop
Replace `with suppress(Exception)` in `_poll_once` message processing
and the `start()` poll loop with explicit `try/except` blocks that
log errors via `logger.exception`. Previously, any exception during
message processing (e.g. in `_handle_message`) was swallowed silently,
causing inbound messages to disappear without a trace.

Also add tests verifying that:
- `_poll_once` logs and continues when `_process_message` fails
- the poll loop logs and continues when `_poll_once` fails
2026-05-07 15:23:36 +08:00
chengyongru
22b3010bd0 Merge remote-tracking branch 'origin/main' into nightly 2026-05-07 00:46:59 +08:00
Xubin Ren
ac18a8baad feat(webui): add localized slash commands
Add a session-scoped slash command palette sourced from backend command metadata, and keep welcome-page quick actions localized across all WebUI languages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:20:28 +08:00
chengyongru
49c07aa45a style: address code review feedback
- Consistent "WeChat" prefix in context_token error message
- Use object() instead of httpx.AsyncClient() in new tests to avoid
  resource leak warnings
2026-05-06 23:52:50 +08:00
chengyongru
98c2f7cc27 fix(weixin): raise exceptions instead of silently dropping messages
_send_text() swallowed API errors (non-zero errcode) with just a
warning log, and send() had three silent return paths (no client,
session paused, no context_token). Neither triggered ChannelManager's
retry logic, causing persistent message loss until a new inbound
message refreshed the context_token.

Now all failure paths raise RuntimeError, matching BaseChannel's
contract and enabling proper retry behavior.
2026-05-06 23:52:50 +08:00
chengyongru
4efd904ccc fix(webui): require token_issue_secret for LAN access with frontend auth
When host is set to 0.0.0.0, the gateway now enforces that either token
or token_issue_secret must be configured — it refuses to start otherwise.

Bootstrap endpoint behavior:
- token_issue_secret configured: always validate regardless of source IP
  (handles reverse-proxy scenarios where all connections appear as localhost)
- No secret: only localhost can bootstrap (local dev mode)

The frontend shows an authentication form when bootstrap returns 401/403,
persists the secret in localStorage, and retries automatically on reload.
2026-05-06 23:51:51 +08:00
chengyongru
034bea1a44 fix(webui): require token_issue_secret for non-localhost bootstrap
The previous LAN-access fix (PR #3656) relaxed the bootstrap localhost
check when host was 0.0.0.0, but did not require any authentication —
any device on the network could obtain a token without credentials.

New behavior:
- token_issue_secret configured: always validate, regardless of source
  IP (handles reverse-proxy scenarios where all connections appear as
  localhost).
- No secret configured: only localhost can bootstrap (local dev mode).

This supersedes the host-based check from PR #3656.
2026-05-06 23:51:51 +08:00
chengyongru
bad584cb0e fix(webui): allow LAN access when host is 0.0.0.0
The webui bootstrap endpoint (/webui/bootstrap) rejected all non-localhost
connections with HTTP 403, preventing the embedded webui from working when
accessed from another device on the LAN — even when host was set to 0.0.0.0.

Skip the localhost check when the server is explicitly bound to 0.0.0.0 or ::,
since that signals intent to accept external connections.
2026-05-06 23:00:23 +08:00
Xubin Ren
790a03ec28 feat(webui): polish chat layout and titles
Align the WebUI sidebar and chat chrome with the updated design, and generate WebUI session titles asynchronously without blocking turns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 22:20:35 +08:00
Xubin Ren
d8fd4c80bf
Merge PR #3646: fix(transcription): retry Whisper calls on transient failures
fix(transcription): retry Whisper calls on transient failures
2026-05-06 21:52:33 +08:00
chengyongru
40b4e01b13 merge: resolve conflict with main in transcription.py
Keep _post_transcription_with_retry from PR branch, drop inline
httpx calls that were replaced by the shared retry helper.
2026-05-06 21:26:28 +08:00
chengyongru
4fad19dc17 fix: use sequential MCP server connections to prevent CPU spin
asyncio.create_task in connect_mcp_servers creates child tasks for
each MCP server, but close_mcp calls stack.aclose() from the main
task. anyio CancelScope requires enter/exit in the same task, so the
cross-task exit raises RuntimeError which gets silently caught. The
orphaned cancel scope keeps retrying via call_soon on every event
loop tick, consuming 100% CPU.

Fix: remove create_task/gather and connect servers sequentially in the
caller task. MCP servers are typically 1-2, so parallel connection
provides negligible benefit while introducing the cancel scope hazard.

Closes #3638
2026-05-06 21:18:51 +08:00
Tim O'Brien
99209a806d fix(tool_hints): pass max_length to abbreviate_path for is_path tools
The is_path branch in _fmt_known was not passing max_length to
abbreviate_path, so read_file, write_file, edit, list_dir, and
web_fetch always truncated paths at 40 chars regardless of config.

Now all three branches (is_path, is_command, fallback) honor the
configured toolHintMaxLength.
2026-05-06 21:18:39 +08:00
Tim O'Brien
67875d7a15 fix: wire toolHintMaxLength through AgentLoop constructors
The config field was added but never passed from config to AgentLoop.
The value was always falling back to the default (40) regardless of
what was set in config.json.

Now passes tool_hint_max_length through all AgentLoop() call sites:
- nanobot/nanobot.py (main bot)
- nanobot/cli/commands.py (CLI agent, dev, webui commands)

Also adds documentation in docs/configuration.md.
2026-05-06 21:18:39 +08:00
Tim O'Brien
daa4a25c9b feat(config): add toolHintMaxLength to control tool hint truncation
Add  to  config (default: 40, range: 20-500).
Controls how many characters of tool hints are shown in progress updates
(e.g. '$ cd …/project && npm test').

Set to 120+ to see full commands instead of truncated hints:

```json
{
  "agents": {
    "defaults": {
      "toolHintMaxLength": 120
    }
  }
}
```

- Thread max_length through format_tool_hints → _fmt_known/_fmt_mcp/_fmt_fallback
- Make path abbreviation in _abbreviate_command proportional to max_length
- Add TestToolHintMaxLength test class with 5 tests
- All 41 existing tests pass
2026-05-06 21:18:39 +08:00
hanyuanling
653de4a7ef fix(agent): gate provider progress deltas 2026-05-06 21:18:30 +08:00
chengyongru
05e0106592 refactor(logging): preserve tracebacks and add channel context
- Preserve tracebacks: logger.error in except blocks → logger.exception
- Channel context: BaseChannel injects self.logger = logger.bind(channel=name)
- Third-party bridge: redirect_lib_logging() replaces ad-hoc stdlib-to-loguru bridges
- Log levels: network timeouts downgraded from ERROR → WARNING
- Fix --verbose flag to actually work with loguru (set handler to DEBUG)
2026-05-06 21:17:45 +08:00
chengyongru
3437ff273f fix(transcription): address review nits on PR #3253
- Correct api_key type hint to str | None in _post_transcription_with_retry
- Remove unreachable final return ""
- Fix test_openai_missing_api_key_short_circuits to actually test
  missing-key path (use audio_file fixture so file exists)
- Fix PermissionError patch for Windows (patch class method instead
  of instance attribute)
2026-05-06 15:52:29 +08:00
mohamed-elkholy95
7ebf611be8 fix(transcription): retry Whisper calls and guard malformed responses
A single transient failure between the agent and an OpenAI/Groq Whisper
endpoint currently vanishes as `return ""` in transcribe(). The voice
message arrives as the empty string and there is no way to tell real
silence apart from a failed upload. A malformed but successful response
body is even worse: the JSON-decode error escapes the helper unhandled.

Add a shared `_post_transcription_with_retry` used by both providers.

Retry behaviour:
  - exponential backoff 1s -> 2s -> 4s, up to 3 retries (4 attempts)
  - retryable HTTP statuses: 408, 429, 500, 502, 503, 504
  - retryable exceptions: TimeoutException, ConnectError, ReadError,
    WriteError, RemoteProtocolError

Non-transient failures short-circuit to "" on the first attempt --
retrying a misconfigured key or a broken upload only burns rate-limit
quota. Branches that short-circuit:
  - missing API key, missing audio file
  - file-read errors (PermissionError, OSError) on the audio path,
    preserving the nightly contract for direct provider callers
  - HTTP auth/4xx body issues via raise_for_status()
  - response.json() parse failures
  - non-dict JSON payloads

Sharing one helper means OpenAI and Groq cannot drift apart silently.

Thread `language` through the helper. The multipart files dict is rebuilt
inside the per-attempt loop, so when a caller sets self.language the
`language` field is sent on every attempt -- not just the first.

Tests cover:
  - every advertised retryable status and exception, parameterized
  - language present on attempts 1 and 2 of a 503->200 sequence
  - language absent when unset; present when set (both providers)
  - malformed JSON body and non-dict JSON body short-circuit to ""
  - PermissionError on file read short-circuits with no HTTP attempt
  - max-attempts give-up, exponential-backoff schedule, auth no-retry,
    missing-key / missing-file short-circuit

Test stub fix: the _StubResponse in tests/channels/test_channel_plugins.py
declared no status_code, which the new helper reads for retry classification.
Set status_code = 200 so the stub advertises the successful response that
those tests already simulate. Also moved the two transcription-provider
imports to the top of that file (previously placed mid-file) so the file
is ruff-clean (E402).
2026-05-06 15:52:25 +08:00
chengyongru
c4b2d9f53b fix(transcription): address review nits on PR #3253
- Correct api_key type hint to str | None in _post_transcription_with_retry
- Remove unreachable final return ""
- Fix test_openai_missing_api_key_short_circuits to actually test
  missing-key path (use audio_file fixture so file exists)
- Fix PermissionError patch for Windows (patch class method instead
  of instance attribute)
2026-05-06 15:51:13 +08:00
mohamed-elkholy95
84e8aed6b1 fix(transcription): retry Whisper calls and guard malformed responses
A single transient failure between the agent and an OpenAI/Groq Whisper
endpoint currently vanishes as `return ""` in transcribe(). The voice
message arrives as the empty string and there is no way to tell real
silence apart from a failed upload. A malformed but successful response
body is even worse: the JSON-decode error escapes the helper unhandled.

Add a shared `_post_transcription_with_retry` used by both providers.

Retry behaviour:
  - exponential backoff 1s -> 2s -> 4s, up to 3 retries (4 attempts)
  - retryable HTTP statuses: 408, 429, 500, 502, 503, 504
  - retryable exceptions: TimeoutException, ConnectError, ReadError,
    WriteError, RemoteProtocolError

Non-transient failures short-circuit to "" on the first attempt --
retrying a misconfigured key or a broken upload only burns rate-limit
quota. Branches that short-circuit:
  - missing API key, missing audio file
  - file-read errors (PermissionError, OSError) on the audio path,
    preserving the nightly contract for direct provider callers
  - HTTP auth/4xx body issues via raise_for_status()
  - response.json() parse failures
  - non-dict JSON payloads

Sharing one helper means OpenAI and Groq cannot drift apart silently.

Thread `language` through the helper. The multipart files dict is rebuilt
inside the per-attempt loop, so when a caller sets self.language the
`language` field is sent on every attempt -- not just the first.

Tests cover:
  - every advertised retryable status and exception, parameterized
  - language present on attempts 1 and 2 of a 503->200 sequence
  - language absent when unset; present when set (both providers)
  - malformed JSON body and non-dict JSON body short-circuit to ""
  - PermissionError on file read short-circuits with no HTTP attempt
  - max-attempts give-up, exponential-backoff schedule, auth no-retry,
    missing-key / missing-file short-circuit

Test stub fix: the _StubResponse in tests/channels/test_channel_plugins.py
declared no status_code, which the new helper reads for retry classification.
Set status_code = 200 so the stub advertises the successful response that
those tests already simulate. Also moved the two transcription-provider
imports to the top of that file (previously placed mid-file) so the file
is ruff-clean (E402).
2026-05-06 15:51:13 +08:00
Tim O'Brien
fb313bd8d1 fix(tool_hints): pass max_length to abbreviate_path for is_path tools
The is_path branch in _fmt_known was not passing max_length to
abbreviate_path, so read_file, write_file, edit, list_dir, and
web_fetch always truncated paths at 40 chars regardless of config.

Now all three branches (is_path, is_command, fallback) honor the
configured toolHintMaxLength.
2026-05-06 13:45:47 +08:00
Tim O'Brien
7d3337a98e fix: wire toolHintMaxLength through AgentLoop constructors
The config field was added but never passed from config to AgentLoop.
The value was always falling back to the default (40) regardless of
what was set in config.json.

Now passes tool_hint_max_length through all AgentLoop() call sites:
- nanobot/nanobot.py (main bot)
- nanobot/cli/commands.py (CLI agent, dev, webui commands)

Also adds documentation in docs/configuration.md.
2026-05-06 13:45:47 +08:00
Tim O'Brien
f256d7ab9b feat(config): add toolHintMaxLength to control tool hint truncation
Add  to  config (default: 40, range: 20-500).
Controls how many characters of tool hints are shown in progress updates
(e.g. '$ cd …/project && npm test').

Set to 120+ to see full commands instead of truncated hints:

```json
{
  "agents": {
    "defaults": {
      "toolHintMaxLength": 120
    }
  }
}
```

- Thread max_length through format_tool_hints → _fmt_known/_fmt_mcp/_fmt_fallback
- Make path abbreviation in _abbreviate_command proportional to max_length
- Add TestToolHintMaxLength test class with 5 tests
- All 41 existing tests pass
2026-05-06 13:45:47 +08:00
Xubin Ren
e54fbfeb2a test(cron): avoid Windows timer race
Disable the externally updated cron job before yielding to the event loop so slow Windows CI cannot run the short-interval job before the test writes the update.
2026-05-06 00:43:00 +08:00
Xubin Ren
db14685a69 fix(agent): soften SSRF guard recovery
Keep private URL access blocked at the tool boundary, but return a clear non-retryable hint so the agent can recover conversationally instead of aborting the turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 00:43:00 +08:00
chengyongru
d97e177981 refactor(sdk): move SDKCaptureHook to agent/hook.py
Colocate the capture hook with the rest of the hook infrastructure
instead of inlining it in the top-level facade module.
2026-05-05 23:23:29 +08:00
Mohamed Elkholy
ca7877f272 fix(sdk): populate RunResult.tools_used and RunResult.messages
``Nanobot.run()`` has always documented ``RunResult.tools_used`` and
``RunResult.messages`` but actually returned ``[]`` for both, so SDK
consumers could never inspect which tools fired or what the final
message list looked like — the only useful field was ``content``.

This threads the data out via a tiny ``_SDKCaptureHook`` that installs
alongside any user-supplied hooks. The capture hook accumulates tool
names across iterations and snapshots the message list on each
``after_iteration`` call; the last snapshot reflects end-of-turn state.

Only the SDK facade is touched: ``AgentLoop.process_direct`` and
``AgentRunner`` signatures are unchanged, so channels / CLI / API paths
are unaffected.
2026-05-05 23:23:29 +08:00
Xubin Ren
4db50f2e32 fix(channels): reject unauthorized inbound before side effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 23:16:36 +08:00
Xubin Ren
1813fc5021 test(telegram): cover silent allowlist rejection
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 23:16:36 +08:00
DG Multica
5aa61e08d3 fix(telegram): ignore unauthorized users silently 2026-05-05 23:16:36 +08:00
futurist
358997554c fix-feishu-media-path 2026-05-05 22:28:44 +08:00
Jiajun Xie
9fa90b1034 fix: only advance dream_cursor on completed batches to prevent silent loss 2026-05-05 22:22:40 +08:00
chengyongru
c30e4d86f3 refactor(agent): simplify subagent concurrency with rejection over semaphore
Replace the asyncio.Semaphore queueing approach with a simple count
check in SpawnTool.execute(). When the concurrency limit is reached,
the tool returns an error string so the agent can perceive the reason
and adjust its behavior instead of silently queueing.

- Remove max_concurrent_subagents parameter threading through
  AgentLoop, commands.py, and nanobot.py
- SubagentManager reads the limit directly from AgentDefaults
- SpawnTool checks get_running_count() before calling spawn()
- Simplify tests to verify rejection behavior
2026-05-05 22:22:04 +08:00
chengyongru
3baa869fdb refactor(agent): simplify subagent concurrency with rejection over semaphore
Replace the asyncio.Semaphore queueing approach with a simple count
check in SpawnTool.execute(). When the concurrency limit is reached,
the tool returns an error string so the agent can perceive the reason
and adjust its behavior instead of silently queueing.

- Remove max_concurrent_subagents parameter threading through
  AgentLoop, commands.py, and nanobot.py
- SubagentManager reads the limit directly from AgentDefaults
- SpawnTool checks get_running_count() before calling spawn()
- Simplify tests to verify rejection behavior
2026-05-05 21:17:15 +08:00
MrBob
2103cd5602 feat(agent): limit subagent concurrency 2026-05-05 21:17:15 +08:00
chengyongru
5b45191cd9 refactor(sdk): move SDKCaptureHook to agent/hook.py
Colocate the capture hook with the rest of the hook infrastructure
instead of inlining it in the top-level facade module.
2026-05-04 23:37:09 +08:00
Mohamed Elkholy
a5fcf7786d fix(sdk): populate RunResult.tools_used and RunResult.messages
``Nanobot.run()`` has always documented ``RunResult.tools_used`` and
``RunResult.messages`` but actually returned ``[]`` for both, so SDK
consumers could never inspect which tools fired or what the final
message list looked like — the only useful field was ``content``.

This threads the data out via a tiny ``_SDKCaptureHook`` that installs
alongside any user-supplied hooks. The capture hook accumulates tool
names across iterations and snapshots the message list on each
``after_iteration`` call; the last snapshot reflects end-of-turn state.

Only the SDK facade is touched: ``AgentLoop.process_direct`` and
``AgentRunner`` signatures are unchanged, so channels / CLI / API paths
are unaffected.
2026-05-04 23:37:09 +08:00
04cb
9d6afd86b5 fix(provider): backfill DeepSeek reasoning_content instead of dropping history (#3554, #3584) 2026-05-04 12:14:38 +08:00
chengyongru
3ceabdecd5 feat(cli): support github-copilot in provider logout
Logout previously claimed to support github-copilot in --help text but had
no registered handler, so `provider logout github-copilot` failed with
"Logout not implemented". Add the handler, sharing token deletion with the
codex flow via `_delete_oauth_files`. Tighten handler-table types, fix the
codex test fixture filename, and cover github-copilot plus the unknown
provider path.
2026-05-04 12:10:06 +08:00
mikaku9944
807b8188e3 style(cli): use English for docstrings in oauth commands 2026-05-04 12:10:06 +08:00