jeo-code 0.8.12 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.8.13] - 2026-07-09
10
+ _"테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used._
11
+
12
+ ### Fixed
13
+ - **Routing vetoes now prefer equivalent credentialed tier models** (`src/commands/launch.ts`) — credential-missing, OAuth-not-servable, and local-provider-unreachable vetoes now scan `tierModelPool()` for the same prompt tier, exclude the rejected target, validate provider readiness, model servability, image support, and local-provider reachability, then switch to a session-stable equivalent before falling back to the session/default model. A misconfigured `roles.smol: "gpt-4o-mini"` with only Anthropic credentials now routes to `claude-haiku-4-5` instead of either failing on OpenAI or wasting a Sonnet turn.
14
+ - **`/route why` now explains equivalent fallback decisions** (`src/commands/launch.ts`) — when a veto switches to an alternate model, the route decision keeps the actual model used and carries a warning that names the rejected model, the reason, and the equivalent replacement.
15
+
16
+ ### Added
17
+ - **Equivalent-fallback regression coverage** (`test/launch-prompt-routing.test.ts`) — updated the credential, OpenAI OAuth servability, and local-provider reachability veto tests to assert the equivalent model path, `warnOnce` behavior, and `/route why` explanation.
18
+
19
+ ### Verified
20
+ - Focused routing suite: `bun test test/launch-prompt-routing.test.ts` — 23 pass / 0 fail.
21
+
9
22
  ## [0.8.12] - 2026-07-08
10
23
  _"제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time._
11
24
 
package/README.ja.md CHANGED
@@ -262,11 +262,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
262
262
  ## 変更履歴 (Changelog)
263
263
 
264
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
265
+ - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
265
266
  - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
266
267
  - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
267
268
  - **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
268
269
  - **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
269
- - **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
270
270
 
271
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
272
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -263,11 +263,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
263
263
  ## 변경 이력 (Changelog)
264
264
 
265
265
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
266
+ - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
266
267
  - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
267
268
  - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
268
269
  - **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
269
270
  - **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
270
- - **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
271
271
 
272
272
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
273
273
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -38,7 +38,7 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
38
38
 
39
39
  ## Highlights
40
40
 
41
- - **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama / LM Studio, plus 20+ OpenAI- and Anthropic-compatible clouds (Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai, …), all behind one uniform JSON tool loop. OAuth login happens from the input box (`/provider login`), every model pick persists as the new default, and prompt routing only auto-selects usable credentialed paths: Gemini OAuth goes through the provider-qualified `antigravity/*` agent set (Gemini 3.5 Flash tiers, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6), never public `google/gemini-*` rows that require `GEMINI_API_KEY`.
41
+ - **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama / LM Studio, plus 20+ OpenAI- and Anthropic-compatible clouds (Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai, …), all behind one uniform JSON tool loop. OAuth login happens from the input box (`/provider login`), every model pick persists as the new default, and prompt routing only auto-selects usable credentialed paths: Gemini OAuth goes through the provider-qualified `antigravity/*` agent set (Gemini 3.5 Flash tiers, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6), never public `google/gemini-*` rows that require `GEMINI_API_KEY`; if a configured route points at an unready provider, jeo switches to an equivalent credentialed tier model before falling back to the default.
42
42
  - **Edit integrity** — read output carries content anchors (`42ab|`); anchored edits are verified against the current file, re-mapped when lines shifted, and rejected with fresh content instead of corrupting.
43
43
  - **Self-correcting verification loop** — configure a post-edit hook (tsc / eslint / tests) and the agent *sees* the diagnostics and fixes them in-loop; a red hook blocks `done` until resolved.
44
44
  - **Real gates, no theater** — `ralplan` consensus is a repo-grounded critic subagent whose `[OKAY]` verdict is persisted and *required* by `jeo approve`; `ultragoal` reports honestly (a suite run is a global signal, never fabricated per-criterion passes).
@@ -80,7 +80,7 @@ Inside the `jeo` REPL (Tab autocompletes; `/` opens the palette).
80
80
  | `/provider login <name>` · `/logout` | OAuth login/logout from the input box |
81
81
  | `/agents [role]` · `/subagent` | Per-role (executor/planner/architect/critic) model · thinking · step config |
82
82
  | `/thinking [level]` | Show/set default reasoning budget (low…xhigh) |
83
- | `/route [status\|on\|off\|why]` | Toggle prompt-based model routing for this session · explain the last routing decision (auto-routes each turn to a tier-appropriate model among the models your configured credentials — OAuth or API key — actually serve) |
83
+ | `/route [status\|on\|off\|why]` | Toggle prompt-based model routing for this session · explain the last routing decision (auto-routes each turn to a tier-appropriate model among the models your configured credentials — OAuth or API key — actually serve, and switches to an equivalent tier model when a configured route is unready) |
84
84
  | `/fast [on\|off\|status]` | Toggle fast thinking mode when the active model advertises low reasoning |
85
85
  | `/skill` · `$<skill> [intent]` | List/run workflow skills (`$team "task"` style) |
86
86
  | `/view` · `/diff` · `/find` · `/search` | Code view, git diff, file/pattern search |
@@ -265,11 +265,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
265
265
  ## Changelog
266
266
 
267
267
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
268
+ - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
268
269
  - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
269
270
  - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
270
271
  - **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
271
272
  - **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
272
- - **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
273
273
 
274
274
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
275
275
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -262,11 +262,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
262
262
  ## 更新日志 (Changelog)
263
263
 
264
264
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
265
+ - **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
265
266
  - **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
266
267
  - **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
267
268
  - **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
268
269
  - **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
269
- - **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
270
270
 
271
271
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
272
272
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -10,7 +10,7 @@
10
10
  import * as fs from "node:fs/promises";
11
11
  import * as path from "node:path";
12
12
  import type { Message } from "./loop";
13
- import { extractJsonObject } from "./json";
13
+ import { extractJsonObjectWithSpan, tryExtractJsonObject } from "./json";
14
14
  import { nativeToolSchemasFor, normalizeNativeToolName } from "./tool-schemas";
15
15
  import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, bisectTool, type ToolResult } from "./tools";
16
16
  import { webSearchTool, setWebSearchActiveModel } from "./web-search";
@@ -469,7 +469,14 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
469
469
  { jsonMode: false, model: opts.model, maxTokens: opts.maxTokens, signal: opts.signal, sessionKey: opts.sessionKey },
470
470
  );
471
471
  const consolidated = wrapUp.trim();
472
- if (consolidated) {
472
+ // Defensive: the wrap-up call explicitly forbids tool/JSON output, but a
473
+ // degenerate model under a long, polluted turn can still emit tool-call-shaped
474
+ // JSON here. Never surface that raw to the user as if it were the answer —
475
+ // fall through to the plain stop message instead (same as an empty wrap-up).
476
+ const looksLikeToolCall = tryExtractJsonObject<any>(consolidated, { preferKeys: ["tool", "tools"] });
477
+ const isToolCallShaped = looksLikeToolCall && typeof looksLikeToolCall === "object" &&
478
+ (typeof looksLikeToolCall.tool === "string" || Array.isArray(looksLikeToolCall.tools));
479
+ if (consolidated && !isToolCallShaped) {
473
480
  pushAssistantTurn(history, consolidated, "", []);
474
481
  return finish({
475
482
  done: false,
@@ -671,10 +678,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
671
678
  responseText = await invokeCallLlm(history, {
672
679
  jsonMode: true,
673
680
  // NATIVE tool-calling: declare the ACTIVE toolset (read-only subagents
674
- // expose only their non-mutating tools). Capable adapters (anthropic …)
675
- // use these and re-serialize the structured call to canonical JSON; the
676
- // antigravity/ollama fallback ignores them. Only on the main step — never
677
- // the prose wrap-up call below.
681
+ // expose only their non-mutating tools). NATIVE-capable adapters
682
+ // (anthropic, gemini, antigravity — see supportsNativeTools) send these
683
+ // on the wire and re-serialize the structured call to canonical JSON;
684
+ // ollama (no native tool support) ignores them and relies solely on the
685
+ // JSON-in-prose protocol above. Only on the main step — never the prose
686
+ // wrap-up call below.
678
687
  tools: turnToolSchemas,
679
688
  model: opts.model,
680
689
  maxTokens: opts.maxTokens,
@@ -857,7 +866,14 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
857
866
 
858
867
  let invocation: any;
859
868
  try {
860
- invocation = extractJsonObject<any>(responseText, { preferKeys: ["tool", "tools"] });
869
+ const extracted = extractJsonObjectWithSpan<any>(responseText, { preferKeys: ["tool", "tools"] });
870
+ invocation = extracted.value;
871
+ // Trim to ONLY the matched JSON — a degenerate model that appends
872
+ // garbled/repeated text after a valid tool call must not have that
873
+ // garbage (a) shown to the user as this turn's assistant record, or
874
+ // (b) echoed back into its own history on the next call, where it
875
+ // would reinforce the corruption instead of self-correcting.
876
+ responseText = extracted.matched;
861
877
  } catch (err) {
862
878
  ev.onAssistant?.(responseText, null);
863
879
  // Prose salvage: a reply with no JSON object at all is a chat-style final
package/src/agent/json.ts CHANGED
@@ -15,26 +15,38 @@ export function extractJsonObject<T = unknown>(
15
15
  text: string,
16
16
  opts?: { preferKeys?: string[] },
17
17
  ): T {
18
+ return extractJsonObjectWithSpan<T>(text, opts).value;
19
+ }
20
+
21
+ /**
22
+ * Like {@link extractJsonObject}, but also reports the exact substring of `text`
23
+ * that was matched (verbatim, pre-repair) — the object itself, with none of the
24
+ * surrounding prose/fences/trailing commentary. Callers that persist the model's
25
+ * reply (e.g. into conversation history) use this to store ONLY the valid JSON:
26
+ * a degenerate model that appends garbled/repeated text after a valid tool call
27
+ * must not have that garbage echoed back into its own context on the next turn,
28
+ * where it would reinforce the corruption instead of self-correcting.
29
+ */
30
+ export function extractJsonObjectWithSpan<T = unknown>(
31
+ text: string,
32
+ opts?: { preferKeys?: string[] },
33
+ ): { value: T; matched: string } {
18
34
  const raw = text.trim();
19
35
  const preferKeys = opts?.preferKeys;
20
36
 
21
37
  // Fast path: already pure JSON (optionally with a trailing comma to repair).
22
38
  const fast = tryParse<T>(raw);
23
- if (fast !== undefined) return fast;
39
+ if (fast !== undefined) return { value: fast, matched: raw };
24
40
 
25
41
  // Strip common code fences and retry (pure, then trailing-comma-repaired).
26
42
  const defenced = raw.replace(/```(?:json|JSON)?/g, "").trim();
27
43
  const fromDefencedWhole = tryParse<T>(defenced);
28
- if (fromDefencedWhole !== undefined) return fromDefencedWhole;
44
+ if (fromDefencedWhole !== undefined) return { value: fromDefencedWhole, matched: defenced };
29
45
 
30
- const parsedFromDefenced = findAndParseBalancedObject<T>(defenced, preferKeys);
31
- if (parsedFromDefenced !== undefined) {
32
- return parsedFromDefenced;
33
- }
34
- const parsedFromRaw = findAndParseBalancedObject<T>(raw, preferKeys);
35
- if (parsedFromRaw !== undefined) {
36
- return parsedFromRaw;
37
- }
46
+ const fromDefenced = findAndParseBalancedObject<T>(defenced, preferKeys);
47
+ if (fromDefenced !== undefined) return fromDefenced;
48
+ const fromRaw = findAndParseBalancedObject<T>(raw, preferKeys);
49
+ if (fromRaw !== undefined) return fromRaw;
38
50
  throw new Error(`No parseable JSON object found in model output: ${truncate(raw, 200)}`);
39
51
  }
40
52
 
@@ -204,29 +216,33 @@ function extractBalancedObject(text: string, startIndex: number): string | null
204
216
  * after a stray JSON-looking object in reasoning prose); fall back to the first
205
217
  * parseable object when none carries a preferred key.
206
218
  */
207
- function findAndParseBalancedObject<T>(text: string, preferKeys?: string[]): T | undefined {
208
- let firstParsed: T | undefined;
219
+ function findAndParseBalancedObject<T>(
220
+ text: string,
221
+ preferKeys?: string[],
222
+ ): { value: T; matched: string } | undefined {
223
+ let first: { value: T; matched: string } | undefined;
209
224
  for (let i = 0; i < text.length; i++) {
210
225
  if (text[i] === "{") {
211
226
  const candidate = extractBalancedObject(text, i);
212
227
  if (candidate) {
213
228
  const parsed = tryParse<T>(candidate);
214
229
  if (parsed !== undefined) {
215
- if (firstParsed === undefined) firstParsed = parsed;
216
- if (!preferKeys || preferKeys.length === 0) return parsed;
230
+ const found = { value: parsed, matched: candidate };
231
+ if (first === undefined) first = found;
232
+ if (!preferKeys || preferKeys.length === 0) return found;
217
233
  if (
218
234
  parsed !== null &&
219
235
  typeof parsed === "object" &&
220
236
  preferKeys.some(k => k in (parsed as Record<string, unknown>))
221
237
  ) {
222
- return parsed;
238
+ return found;
223
239
  }
224
240
  // else: keep scanning for an object that carries a preferred key
225
241
  }
226
242
  }
227
243
  }
228
244
  }
229
- return firstParsed;
245
+ return first;
230
246
  }
231
247
 
232
248
  function truncate(s: string, n: number): string {
@@ -48,7 +48,8 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
48
48
  import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
49
49
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
50
50
  import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
51
- import { routePrompt, deriveCacheSessionKey, warnOnce, type RouteDecision } from "../agent/prompt-router";
51
+ import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, type RouteDecision } from "../agent/prompt-router";
52
+ import { isRateLimitError, isUsageLimitError } from "../util/retry";
52
53
 
53
54
  import { listAliases } from "../ai/model-registry";
54
55
  import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
@@ -882,6 +883,42 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
882
883
  // that write silently switch THIS running session's model mid-run. Per-session model
883
884
  // selection must stay session-local: running sessions never influence each other.
884
885
  const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
886
+ let routeCredentialNotice: string | undefined;
887
+ let routeVetoNote: string | undefined;
888
+ const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
889
+ const excluded = new Set([decision.model, ...exclude]);
890
+ const candidates = tierModelPool(decision.tier, turnConfig)
891
+ .filter(model => !excluded.has(model))
892
+ .filter(model => !images?.length || catalogMetadata(model)?.images !== false);
893
+ if (candidates.length === 0) return null;
894
+ const selected = selectFromPool(candidates, sessionId ? `${sessionId}:fallback:${decision.model}:${reason}` : undefined);
895
+ const start = Math.max(0, candidates.indexOf(selected));
896
+ const ordered = [...candidates.slice(start), ...candidates.slice(0, start)];
897
+ for (const candidate of ordered) {
898
+ const { resolved, provider } = await describeModel(candidate, turnConfig);
899
+ const status = await describeProvider(provider, turnConfig);
900
+ if (!status.ready || !modelServableWithConfig(provider, resolved, turnConfig)) continue;
901
+ if (provider === "ollama" || provider === "lmstudio") {
902
+ const localBaseUrl = provider === "ollama"
903
+ ? (turnConfig.ollamaBaseUrl ?? "http://localhost:11434")
904
+ : (turnConfig.lmstudioBaseUrl ?? "http://localhost:1234/v1");
905
+ if (!await isLocalProviderReachable(provider, localBaseUrl)) continue;
906
+ }
907
+ return candidate;
908
+ }
909
+ return null;
910
+ };
911
+ const fallbackDecision = async (decision: RouteDecision, reason: string, noticeKey: string, notice: string): Promise<RouteDecision | null> => {
912
+ const fallback = await equivalentRouteFallback(decision, reason);
913
+ if (!fallback) {
914
+ routeVetoNote = `routed model '${decision.model}' ${reason} — fell back to '${sessionModel || defaultModel}'`;
915
+ routeCredentialNotice = warnOnce(noticeKey, notice);
916
+ return null;
917
+ }
918
+ routeVetoNote = `routed model '${decision.model}' ${reason} — switched to equivalent '${fallback}'`;
919
+ routeCredentialNotice = warnOnce(noticeKey, `${notice} Switching to equivalent '${fallback}' for this turn.`);
920
+ return { ...decision, model: fallback, warning: decision.warning ? `${decision.warning}; ${routeVetoNote}` : routeVetoNote };
921
+ };
885
922
  let routed = routingEnabled && !sessionModel
886
923
  ? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length, sessionId }).catch(() => null)
887
924
  : null;
@@ -900,8 +937,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
900
937
  // the top of prompt-router.ts. Nulling `routed` here (rather than patching its
901
938
  // `.model`) lets the EXISTING `activeModel`/`lastRouteDecision` fallback lines
902
939
  // below handle the rest unchanged.
903
- let routeCredentialNotice: string | undefined;
904
- let routeVetoNote: string | undefined;
905
940
  if (routed) {
906
941
  // describeModel expands aliases (e.g. a hand-written `roles.high: "gpt"`)
907
942
  // exactly like the real call path, so the servability check below judges
@@ -909,23 +944,23 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
909
944
  const { resolved: routedResolved, provider: routedProvider } = await describeModel(routed.model, turnConfig);
910
945
  const status = await describeProvider(routedProvider, turnConfig);
911
946
  if (!status.ready) {
912
- routeVetoNote = `routed model '${routed.model}' (${routedProvider}) has no usable credential this turn — fell back to '${sessionModel || defaultModel}'`;
913
- routeCredentialNotice = warnOnce(
947
+ routed = await fallbackDecision(
948
+ routed,
949
+ `(${routedProvider}) has no usable credential this turn`,
914
950
  `prompt-router:provider-not-ready:${routedProvider}`,
915
- `[route] routed to '${routed.model}' (${routedProvider}) but that provider has no usable credential — falling back to the default model this turn. Run 'jeo auth login ${routedProvider}' or reconfigure routing.tiers/roles for a provider you're logged into.`,
951
+ `[route] routed to '${routed.model}' (${routedProvider}) but that provider has no usable credential. Run 'jeo auth login ${routedProvider}' or reconfigure routing.tiers/roles for a provider you're logged into.`,
916
952
  );
917
- routed = null;
918
953
  } else if (!modelServableWithConfig(routedProvider, routedResolved, turnConfig)) {
919
954
  // Provider readiness is necessary but not sufficient: an OAuth login only
920
955
  // serves specific models (auth matrix on modelServableWithConfig). Only
921
956
  // user-pinned routing.tiers/roles models can hit this branch —
922
957
  // auto-selected models already passed the same predicate.
923
- routeVetoNote = `routed model '${routed.model}' (${routedProvider}) is not servable by the stored ${routedProvider} credential this turn — fell back to '${sessionModel || defaultModel}'`;
924
- routeCredentialNotice = warnOnce(
958
+ routed = await fallbackDecision(
959
+ routed,
960
+ `(${routedProvider}) is not servable by the stored ${routedProvider} credential this turn`,
925
961
  `prompt-router:model-not-servable:${routed.model}`,
926
- `[route] routed to '${routed.model}' but the stored ${routedProvider} OAuth login cannot serve that model — falling back to the default model this turn. Set ${routedProvider.toUpperCase()}_API_KEY or reconfigure routing.tiers/roles to a model your login serves.`,
962
+ `[route] routed to '${routed.model}' but the stored ${routedProvider} OAuth login cannot serve that model. Set ${routedProvider.toUpperCase()}_API_KEY or reconfigure routing.tiers/roles to a model your login serves.`,
927
963
  );
928
- routed = null;
929
964
  } else if (routedProvider === "ollama" || routedProvider === "lmstudio") {
930
965
  // Live reachability gate for LOCAL providers only: `describeProvider` reports
931
966
  // ollama/lmstudio as `ready: true` unconditionally (keyless just means "no
@@ -945,19 +980,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
945
980
  : (turnConfig.lmstudioBaseUrl ?? "http://localhost:1234/v1");
946
981
  const reachable = await isLocalProviderReachable(routedProvider, localBaseUrl);
947
982
  if (!reachable) {
948
- routeVetoNote = `routed model '${routed.model}' (${routedProvider}) is unreachable at ${localBaseUrl} this turn — fell back to '${sessionModel || defaultModel}'`;
949
- routeCredentialNotice = warnOnce(
983
+ routed = await fallbackDecision(
984
+ routed,
985
+ `(${routedProvider}) is unreachable at ${localBaseUrl} this turn`,
950
986
  `prompt-router:provider-unreachable:${routedProvider}`,
951
- `[route] routed to '${routed.model}' (${routedProvider}) but ${localBaseUrl} is unreachable — falling back to the default model this turn. Start the ${routedProvider} server, or reconfigure routing.tiers/roles for a provider that is running.`,
987
+ `[route] routed to '${routed.model}' (${routedProvider}) but ${localBaseUrl} is unreachable. Start the ${routedProvider} server, or reconfigure routing.tiers/roles for a provider that is running.`,
952
988
  );
953
- routed = null;
954
989
  }
955
990
  }
956
991
  }
957
992
  lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && !sessionModel ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
958
993
  // Explicit /model always wins: `routed` is only ever computed when `!sessionModel`.
959
- const activeModel = routed?.model ?? (sessionModel || defaultModel);
960
- const activeThinking = routed?.thinking ?? sessionThinking;
994
+ let activeModel = routed?.model ?? (sessionModel || defaultModel);
995
+ let activeThinking = routed?.thinking ?? sessionThinking;
961
996
  // Provider-side prompt caches are keyed PER MODEL — reusing the bare `sessionId`
962
997
  // across a mid-session model switch (routePrompt changing activeModel turn to
963
998
  // turn) sends the same cache-correlation key to a different model/provider,
@@ -967,8 +1002,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
967
1002
  // `sessionId` is undefined only under `--no-session` — in that case there is no
968
1003
  // provider-side cache lineage to protect anyway, so forward `undefined` unchanged
969
1004
  // (matches the prior behavior of `sessionKey: sessionId` for that mode).
970
- const turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
971
- const contextTokens = catalogMetadata(activeModel)?.contextTokens;
1005
+ let turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
1006
+ let contextTokens = catalogMetadata(activeModel)?.contextTokens;
972
1007
 
973
1008
  // Resolve provider + dirty count up front — both are cheap and feed the live
974
1009
  // frame's footer. `turnConfig` is reused so describeModel does NOT re-read the
@@ -1273,18 +1308,47 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1273
1308
  tags: ["jeo", "launch"],
1274
1309
  });
1275
1310
  opik.startTurn();
1276
- result = await runAgentLoop(history, {
1311
+ const runLoopWithModel = (model: string, thinking: ThinkLevel | undefined, cacheKey: string | undefined, maxSteps = flags.maxSteps, budget?: { maxExtensions: number }) => runAgentLoop(history, {
1277
1312
  cwd,
1278
1313
  tools,
1279
- maxSteps: flags.maxSteps,
1280
- model: activeModel,
1281
- maxTokens: resolveMaxOutputTokens(activeModel, activeThinking),
1282
- reasoningEffort: activeThinking ? thinkingToReasoningEffort(activeThinking) : undefined,
1314
+ maxSteps,
1315
+ ...(budget ? { budget } : {}),
1316
+ model,
1317
+ maxTokens: resolveMaxOutputTokens(model, thinking),
1318
+ reasoningEffort: thinking ? thinkingToReasoningEffort(thinking) : undefined,
1283
1319
  signal: ac.signal,
1284
- sessionKey: turnSessionKey,
1320
+ sessionKey: cacheKey,
1285
1321
  steer: drainSteer,
1286
1322
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1287
1323
  });
1324
+ const routeFailureReason = (doneReason: unknown): string | null => {
1325
+ const message = String(doneReason ?? "");
1326
+ if (!message) return null;
1327
+ const asError = new Error(message);
1328
+ if (isRateLimitError(asError)) return "hit a rate limit";
1329
+ if (isUsageLimitError(asError)) return "hit a usage/quota limit";
1330
+ if (/does not recognize the requested model|model .*?(?:not found|not available|unavailable|unsupported|retired|gated)|\b(?:400|404)\b/i.test(message)) {
1331
+ return "is unavailable";
1332
+ }
1333
+ return null;
1334
+ };
1335
+ result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
1336
+ const routedFailureReason = routed && !result.done ? routeFailureReason(result.doneReason) : null;
1337
+ if (routed && routedFailureReason) {
1338
+ const fallback = await equivalentRouteFallback(routed, routedFailureReason, [activeModel]);
1339
+ if (fallback) {
1340
+ const previousModel = activeModel;
1341
+ activeModel = fallback;
1342
+ activeThinking = routed.thinking ?? sessionThinking;
1343
+ turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
1344
+ contextTokens = catalogMetadata(activeModel)?.contextTokens;
1345
+ lastRouteDecision = { ...routed, model: activeModel, warning: `routed model '${previousModel}' ${routedFailureReason}; switched to equivalent '${activeModel}'` };
1346
+ const notice = `[route] routed model '${previousModel}' ${routedFailureReason}; switching to equivalent '${activeModel}' for this turn.`;
1347
+ if (tui) tui.events().onNotice?.(notice);
1348
+ else console.log(notice);
1349
+ result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
1350
+ }
1351
+ }
1288
1352
  if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
1289
1353
  history.push({
1290
1354
  role: "user",