Compare commits

...
83 Commits
Author SHA1 Message Date
SivanCola 484bca854f Merge pull request #5757 from SivanCola/feature/custom-provider-setup
Simplify custom provider setup / 简化自定义供应商接入配置
2026-07-02 10:05:33 +08:00
SivanCola 9ff0b20589 feat(desktop): simplify custom provider setup 2026-07-02 09:45:56 +08:00
SivanCola a2eaf80601 Merge pull request #5755 from ttmouse/feat/heartbeat-bot-push
feat: Heartbeat 定时任务输出实时推送到 Bot 渠道(飞书/QQ)
2026-07-02 09:44:31 +08:00
SivanCola cf2a8b061b Localize heartbeat bot push labels
Co-authored-by: SivanCola <[email protected]>
2026-07-02 03:06:32 +08:00
SivanCola fcd6180976 Fix heartbeat bot forwarding isolation
Co-authored-by: SivanCola <[email protected]>
2026-07-02 02:18:24 +08:00
SivanCola 6cbffaa78b Merge pull request #5754 from SivanCola/fix/anchor-edit-safety
Guard anchor edits after same-turn writes / 防止同回合锚点编辑失效
2026-07-02 01:58:50 +08:00
SivanCola 9ee254b2ec Fix anchor edit guard follow-ups 2026-07-02 01:44:07 +08:00
ttmouse cae565c158 feat: Heartbeat → Bot 渠道事件推送(双 Sink 分流) 2026-07-02 01:27:02 +08:00
SivanCola 3255803636 Guard anchor edits after same-turn writes 2026-07-02 01:05:53 +08:00
SivanCola c8ac697010 Merge pull request #5751 from SivanCola/fix/desktop-trash-ancillary-lag
Reduce desktop trash fallback hydration lag / 降低桌面端删除话题后的恢复卡顿
2026-07-02 00:39:24 +08:00
SivanCola 8d8bccd334 Merge pull request #5752 from SivanCola/fix/windows-classic-titlebar-tools
Fix Windows classic titlebar tool positioning / 修复 Windows 经典标题栏工具定位
2026-07-02 00:38:20 +08:00
SivanCola 3b0b9c0546 fix(desktop): keep checkpoint file arrays non-null 2026-07-02 00:32:52 +08:00
SivanCola 3608146829 Fix Windows classic titlebar tool positioning 2026-07-02 00:13:27 +08:00
SivanCola 3b3c9dba57 fix(desktop): reduce trash fallback hydration lag 2026-07-02 00:03:20 +08:00
SivanCola e1c2f46e8a Merge pull request #5743 from SivanCola/fix/desktop-code-preview-scroll
Fix workspace code preview scrolling / 修复工作区代码预览滚动
2026-07-01 23:27:44 +08:00
SivanCola 798c7ce85a fix(desktop): restore code preview scrolling 2026-07-01 23:06:04 +08:00
SivanCola 2ade3cd50d Merge pull request #5737 from esengine/fix/windows-frameless-titlebar
Fix Windows frameless titlebar controls / 修复 Windows 无边框标题栏控制
2026-07-01 23:01:58 +08:00
SivanCola da348d552e Merge pull request #5738 from SivanCola/fix/desktop-session-save-failures
Fix silent desktop session save failures / 修复桌面会话保存失败静默丢失
2026-07-01 22:46:02 +08:00
SivanCola 96431bc31f fix(desktop): surface session snapshot failures 2026-07-01 22:31:45 +08:00
SivanCola 2b56585f8f fix(desktop): add Windows frameless titlebar controls 2026-07-01 22:25:25 +08:00
SivanCola 4ea38a7a22 Merge pull request #5733 from SivanCola/fix/bash-cancel-wait-stall
Fix stalled bash cancel waits / 修复 bash 取消后等待卡死
2026-07-01 22:06:01 +08:00
SivanCola 5b8a9c0cd5 fix(bash): preserve wait error after cancel 2026-07-01 21:49:53 +08:00
SivanCola 1f4d811ac3 Merge pull request #5729 from SivanCola/feature/desktop-unread-topic-dot
Show unread topic attention dots / 显示未读会话提醒点
2026-07-01 21:39:05 +08:00
YHH 7cab46ac3a Merge pull request #5731 from esengine/feat/account-aware-skills
feat(skills): account-aware registry page with admin review
2026-07-01 21:38:13 +08:00
SivanCola 0a38be6852 fix(bash): release cancelled tracked waits 2026-07-01 21:38:00 +08:00
SivanCola 0ae11ebdef fix(desktop): omit hidden unread topic actions 2026-07-01 21:33:18 +08:00
yhh fdb99115d8 feat(skills): make the registry page account-aware with admin review
Reuse the site account client (scripts/auth.js) to show sign-in state in the nav, gate publishing behind a signed-in session, and reveal a moderator review queue (approve/reject) to admins. Adds currentAccount()/accountLogout() helpers to auth.js for account-aware pages.
2026-07-01 06:31:40 -07:00
SivanCola 2bcac618d7 feat(desktop): show unread topic attention dots 2026-07-01 21:13:10 +08:00
YHH 25184026bc Merge pull request #5728 from esengine/feat/registry-moderation
feat(registry): moderate submissions before they go live
2026-07-01 20:54:20 +08:00
yhh 3a1d04ae23 feat(registry): moderate submissions before they go live
Publishing now requires a verified email; non-admin submissions land as 'pending' and stay out of the listing, detail, and activity feed until an admin approves them. Adds /v1/admin review endpoints (approve / reject / hide / verify) gated on the admin role. Versions of an already-approved package stay live; admins publish straight to active.
2026-07-01 05:47:41 -07:00
SivanCola e73221c8b7 Merge pull request #5726 from esengine/feature/composer-guidance-queue
Add composer guidance queue controls / 新增 Composer 引导队列控制
2026-07-01 20:44:54 +08:00
YHH bd9923d644 Merge pull request #5725 from esengine/feat/skills-mcp-registry
feat: skill/MCP registry — worker + web discovery page
2026-07-01 20:28:37 +08:00
yhh 6892b5a035 feat(site): add skills registry page
Browse, search, sort, and publish community skills and MCP servers at /skills, with a live activity feed. Client-fetches the registry (PUBLIC_REGISTRY_ORIGIN, defaults to registry.reasonix.io) and links it from the home nav.
2026-07-01 05:21:13 -07:00
SivanCola 885ad5627f feat(desktop): add composer guidance queue controls 2026-07-01 20:21:08 +08:00
yhh e702eb082e feat(registry): add skill/MCP registry worker
Discovery layer over the existing install_source rail. Hono + D1: list/search published skills and MCP servers, record installs, and serve a live activity feed. Identity is delegated to id.reasonix.io; the worker stores only metadata and a source pointer, and never fetches or executes package content.
2026-07-01 05:21:07 -07:00
SivanCola ebd73457da Merge pull request #5711 from SivanCola/fix/compact-prompt-approvals
Compact approval prompts
2026-07-01 17:55:11 +08:00
YHH 3f037fa745 feat(site): bilingual community forum (EN / 中文) (#5715)
The /community pages shipped English-only; the rest of the site is
bilingual. Add the same EN/中文 toggle: a lang switch in the community
nav, .l-en/.l-zh spans for all static chrome, and the shared reasonix-lang
choice so the language carries over from the main site. The client
renders dynamic labels (categories, times, counts, badges) as toggled
spans too, and localizes the anti-spam/gate errors by code — so the whole
forum reads in either language.
2026-07-01 17:53:13 +08:00
SivanCola f83b84b896 fix(desktop): show approval details by default 2026-07-01 17:47:51 +08:00
SivanCola be59e66976 Merge pull request #5714 from SivanCola/feature/context-panel-usage-visuals
Refine context panel usage overview / 优化上下文面板用量概览
2026-07-01 17:42:36 +08:00
SivanCola d924d998a0 feat: refine context panel usage overview 2026-07-01 17:32:31 +08:00
YHH 8b04b164dc fix(forum): let staff bypass the trust-based post gates (#5713)
Categories with a high min_trust_to_post (e.g. Announcements at 4) were
unreachable: admins/moderators seed at trust 2, so the min_trust and
new-member link checks blocked them. Staff now bypass the trust gates
(and the rate limiter) while still needing a verified email.
2026-07-01 17:32:09 +08:00
SivanCola 118fe34378 Merge pull request #5704 from SivanCola/fix/session-profile-restore
fix(desktop): Restore session profile on switch / 修复切会话恢复权限与思考模式
2026-07-01 17:25:18 +08:00
SivanCola 170771fc3a fix(desktop): compact approval prompts 2026-07-01 17:19:20 +08:00
YHH 06fc964bc9 feat(site): community forum frontend at /community (#5708)
The human-facing forum, built on the approved design and unified into the
main site: Astro pages at reasonix.io/community (discussions home, thread,
new topic, guidelines). They're client-rendered against the
forum.reasonix.io API and gated on the shared id.reasonix.io session (the
rxid cookie is sent cross-subdomain), so there's no second login and no
separate app to deploy — it ships with the site via pages.yml.

Anti-spam surfaces here too: the composer only appears when signed in, and
the API's gate errors (verify email, links-restricted, rate limits) are
shown inline. Reuses the site's design tokens with self-hosted fonts.
API base is build-configurable via PUBLIC_FORUM_API (defaults to
https://forum.reasonix.io).
2026-07-01 17:16:45 +08:00
YHH 6643299111 feat(forum): community forum API with shared identity + anti-spam (#5706)
* feat(forum): community forum API with shared identity + anti-spam

First slice of the Reasonix Community forum: a new reasonix-forum worker
(forum.reasonix.io) backed by its own D1. Identity is the shared
id.reasonix.io session — it reads the .reasonix.io rxid cookie and
resolves it via /me (Bearer), the same pattern the crash dashboard uses —
so there's no second login.

Anti-abuse is built in at the identity/trust gate rather than per-post
content scanning, keeping it cheap: posting requires a verified email;
new (trust 0) members can't post links; per-trust daily caps + a per-IP
limiter throttle flooding; and N distinct flags auto-hide a post pending
moderation. Endpoints: categories/topics/posts read, create topic, reply,
and flag.

Bootstrap (out of band): wrangler d1 create reasonix-forum, apply
schema.sql + seed.sql, fill database_id. Frontend, deploy CI, and the
moderation queue land in follow-ups.

* ci(forum): provision D1 + auto-deploy forum.reasonix.io on merge

Fill the reasonix-forum database_id (D1 created) and add the deploy
workflow so merging to main-v2 ships forum.reasonix.io, matching the
accounts/crash worker pattern.
2026-07-01 17:15:03 +08:00
SivanCola 72ce3d8d93 fix(desktop): persist closed session profile 2026-07-01 17:07:07 +08:00
SivanCola 0aad3bd186 fix(desktop): restore session profile on switch 2026-07-01 16:44:08 +08:00
YHH 1858974dc7 feat(crash): unify dashboard auth onto id.reasonix.io identity (#5701)
The crash dashboard ran its own account system — a separate D1 users/
sessions table with its own password login — which fragmented identity
from id.reasonix.io. Make it consume the shared identity instead: it
resolves the .reasonix.io rxid cookie via id.reasonix.io/me (Bearer) and
keeps only a per-dashboard role in a new `access` table. Existing roles
migrate by email (migrate-access.sql); ADMIN_EMAILS force-promotes the
owner so a missed migration can't lock them out. Sign-in redirects to the
shared login; sign-out revokes the shared session.

Also: accounts /auth/logout accepts a Bearer token (so crash can revoke
server-side), and the site login honours a next to any *.reasonix.io.
2026-07-01 16:26:24 +08:00
SivanCola ac2f6695c5 Merge pull request #5700 from SivanCola/docs/attribute-todo-panel-contributors
Attribute TodoPanel contributor credit / 补充 TodoPanel 贡献署名
2026-07-01 16:19:41 +08:00
69d0185ff6 docs: leave attribution in PR history
Co-authored-by: dengbushi <[email protected]>
Co-authored-by: HHHEEEWWW <[email protected]>
2026-07-01 16:13:34 +08:00
4d8caaa944 docs: attribute todo panel contributors
Co-authored-by: dengbushi <[email protected]>
Co-authored-by: HHHEEEWWW <[email protected]>
2026-07-01 16:10:02 +08:00
SivanCola 5e40ed682b Merge pull request #5698 from SivanCola/fix/todo-panel-batch-collapse
fix(desktop): scope todo panel completion state / 修复待办面板跨会话完成态
2026-07-01 15:56:29 +08:00
SivanCola 1284d1c6c1 fix(desktop): scope todo panel state by session 2026-07-01 15:46:04 +08:00
YHH 3093f03585 feat(site): account UI — sign-in, register, profile, device approval (#5682)
The accounts API (id.reasonix.io) shipped without a web frontend, so no
one could register or sign in. Add Astro pages that consume the JSON API:
login (handles the post-verify ?verified banner), register, forgot/reset,
a gated account page (profile edit, password change, sign out, delete),
and a device-approval page — the human half of the CLI/desktop device
flow (enter the terminal's code, review, approve/reject). Reuses the
site's existing design tokens; all copy is bilingual like the rest of the
site. The API base is build-configurable via PUBLIC_ACCOUNTS_API and
defaults to https://id.reasonix.io.
2026-07-01 15:42:22 +08:00
YHH f39f06896f feat(accounts): device-authorization sign-in for CLI/desktop (#5681)
The worker only did cookie-based web login, so the CLI/desktop had no way
to authenticate without a browser redirect. Add an RFC 8628-style device
flow: /device/start issues a device_code (polled) plus a short user_code
the human approves at APP_ORIGIN/device while signed in; /device/poll
returns the minted cli session on the winning atomic claim. Only the
device code's peppered hash is stored, and the session token is created at
claim time, so it never lands in the DB. Session middleware now resolves a
Bearer token in addition to the cookie, so the one sessions table serves
both web and cli kinds.
2026-07-01 15:42:11 +08:00
YHH cb1a0bfd18 ci(accounts): sync RESEND_API_KEY from repo secret on deploy (#5695)
The worker reads env.RESEND_API_KEY at runtime from Cloudflare, but the
key was only stored as a GitHub Actions secret — where wrangler deploy
never pushes it — so mail silently fell back to the console stub. Add a
deploy step that pipes the RESEND_API_KEY repo secret into
wrangler secret put, giving the mail key a single source of truth and
removing the need for local wrangler auth. Guarded so a missing secret
just skips (stays in stub mode) instead of setting an empty key.
2026-07-01 15:26:57 +08:00
YHH c757d24483 ci(accounts): auto-deploy id.reasonix.io on merge to main-v2 (#5679)
* ci(accounts): auto-deploy id.reasonix.io on merge to main-v2

The accounts worker shipped without a deploy pipeline — only the crash
worker had one, so id.reasonix.io could never go live from CI. Mirror
deploy-crash-worker with the repo's standard pnpm setup (action-setup@v6
+ setup-node cache), path-filtered to workers/accounts/**. Migrations
and secrets stay manual and out of band, same as the crash worker.

* fix(accounts): send from the verified send.reasonix.io domain

MAIL_FROM was [email protected], but only send.reasonix.io is a
verified Resend domain (the apex isn't). Resend rejects sends from an
unverified from-domain, so every verification/reset email would 403.
Send from the verified subdomain instead.
2026-07-01 15:13:54 +08:00
SivanCola c38b0801cc Merge pull request #5691 from SivanCola/fix/transient-blank-session-delete
Fix delete fallback blank sessions / 修复删除后的空白会话残留
2026-07-01 15:05:16 +08:00
SivanCola 8d42210d7c fix(desktop): discard unused transient blank sessions 2026-07-01 14:55:19 +08:00
SivanCola 191772931f fix(desktop): keep delete fallback blanks out of project tree 2026-07-01 14:27:47 +08:00
SivanCola 56a53237f4 Merge pull request #5675 from SivanCola/fix/legacy-sidebar-project-recovery
Recover legacy sidebar projects on upgrade / 升级时恢复旧版侧栏项目
2026-07-01 11:49:53 +08:00
SivanCola c045230307 fix(desktop): serialize project index updates 2026-07-01 11:41:06 +08:00
SivanCola 946255ce89 fix(desktop): recover legacy sidebar projects on upgrade 2026-07-01 11:27:46 +08:00
SivanCola 397605ab13 Merge pull request #5674 from SivanCola/fix/mcp-remove-clears-failure
Clear stale MCP failure after removal / 删除 MCP 后清理残留失败状态
2026-07-01 11:13:59 +08:00
SivanColaandcyq1017 a753893797 fix(desktop): clear failed MCP status after removal
Co-authored-by: cyq1017 <[email protected]>
2026-07-01 11:08:30 +08:00
SivanCola 23bf5dd464 Merge pull request #5657 from CVEngineer66/feature/autoresearch-runtime
Add host-managed AutoResearch runtime
2026-07-01 04:53:00 +08:00
SivanCola c600b12693 Merge pull request #5643 from SauronSkywalker/dpi-zoom-restart
feat(desktop): add custom DPI zoom via WebView2 ZoomFactor
2026-07-01 04:51:57 +08:00
SivanCola d2cf3addc7 Merge pull request #5662 from SivanCola/fix/math-rendering-pr-integration
Consolidate math rendering fixes / 整合数学渲染修复
2026-07-01 04:51:17 +08:00
SivanCola f1951c5d1f Harden AutoResearch task store paths
Co-authored-by: SivanCola <[email protected]>
2026-07-01 01:01:35 +08:00
SivanCola 82a9d6829e Consolidate math rendering fixes 2026-07-01 00:54:52 +08:00
SivanCola 7981c96d70 fix(desktop): keep zoom preference authoritative
Co-authored-by: SivanCola <[email protected]>
2026-07-01 00:50:51 +08:00
SivanCola ac1b2ad45d Merge pull request #5661 from SivanCola/fix/hide-windows-probe-consoles
Hide Windows startup probe consoles / 隐藏 Windows 启动探测终端弹窗
2026-07-01 00:49:18 +08:00
SivanCola f00bb1ea85 Fix AutoResearch readiness evidence loop 2026-07-01 00:22:09 +08:00
SivanCola 68b7ea6c07 fix(desktop): hide zoom setting outside Windows
Co-authored-by: SivanCola <[email protected]>
2026-07-01 00:12:19 +08:00
SivanCola d239e346f6 fix(environment): hide startup probe consoles on Windows 2026-07-01 00:09:17 +08:00
wufengfan 5527e8e805 Keep AutoResearch out of default tool surface 2026-06-30 23:51:13 +08:00
wufengfan cf7ee9e400 Remove AutoResearch frontend UI surfaces 2026-06-30 23:35:21 +08:00
wufengfan c580472167 Reuse AutoResearch task for repeated goal updates 2026-06-30 23:07:03 +08:00
wufengfan 462fd5e281 Refresh status metadata for AutoResearch notices 2026-06-30 23:01:39 +08:00
wufengfan 8ddad32b4b Allow AutoResearch status bar item in config 2026-06-30 22:53:31 +08:00
wufengfan 2bc0d2f010 Add host-managed AutoResearch runtime 2026-06-30 22:40:43 +08:00
SauronSkywalker af13bc76be feat(desktop): add custom DPI zoom via WebView2 ZoomFactor
- Add ZoomFactor support in windows.Options (main.go)
- Add zoom_factor.go for JSON persistence of zoom setting
- Add dpiScale.ts for frontend zoom state management
- Add slider UI in Settings → Appearance with capsule thumb
- Add SetDesktopZoomFactor / GetDesktopZoomFactor / RestartApplication bindings
- Add locale keys for zoom setting (en/zh/zh-TW)

Setting changes require app restart to take effect (WebView2 engine-level).
2026-06-30 17:17:27 +08:00
wufengfan 4d767f3db4 Add AutoResearch runtime design spec 2026-06-29 22:21:11 +08:00
180 changed files with 17802 additions and 1362 deletions

No files matched your search

@@ -0,0 +1,49 @@
name: Deploy accounts worker
on:
push:
branches: [main-v2]
paths:
- 'workers/accounts/**'
- '.github/workflows/deploy-accounts-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
+38
View File
@@ -0,0 +1,38 @@
name: Deploy forum worker
on:
push:
branches: [main-v2]
paths:
- 'workers/forum/**'
- '.github/workflows/deploy-forum-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
+424 -54
View File
@@ -30,6 +30,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/internal/agent"
"reasonix/internal/autoresearch"
"reasonix/internal/billing"
"reasonix/internal/boot"
"reasonix/internal/botruntime"
@@ -524,6 +525,7 @@ func (a *App) restoreOrBuildTabs() {
// picked up by Load instead of falling back to built-in defaults.
_, _ = config.MigrateLegacyIfNeeded()
f := loadTabsFile()
_, _ = recoverLegacyProjectSidebarRoots(f)
_, _ = config.ResetOfficialProviderPricingOnUpgrade(config.UserConfigPath())
_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
@@ -633,7 +635,9 @@ func (a *App) snapshotAllTabs() {
tabs := a.runtimeTabsLocked()
a.mu.RUnlock()
for _, t := range tabs {
_ = a.snapshotTab(t)
if err := a.snapshotTab(t); err != nil {
slog.Warn("desktop: snapshot all tabs failed", "tab", t.ID, "err", err)
}
}
}
@@ -655,7 +659,9 @@ func (a *App) shutdown(context.Context) {
a.mu.RUnlock()
for _, t := range tabs {
if t.Ctrl != nil {
_ = a.snapshotTab(t)
if err := a.snapshotTab(t); err != nil {
slog.Warn("desktop: shutdown snapshot failed", "tab", t.ID, "err", err)
}
t.Ctrl.Close()
}
}
@@ -737,6 +743,7 @@ func (a *App) SubmitToTab(tabID, input string) error {
if ctrl == nil {
return workspaceNotReadyErr(tab)
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.SubmitDisplay(input, input)
return nil
}
@@ -753,6 +760,7 @@ func (a *App) submitUserTurnToTab(tabID, input string) bool {
if ctrl == nil {
return false
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.SubmitUserTurn(input, input)
return true
}
@@ -778,6 +786,7 @@ func (a *App) RunShellForTab(tabID, command string) error {
if ctrl == nil {
return workspaceNotReadyErr(tab)
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.RunShell(command)
return nil
}
@@ -803,6 +812,7 @@ func (a *App) SubmitDisplayToTab(tabID, display, input string) error {
if ctrl == nil {
return workspaceNotReadyErr(tab)
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.SubmitDisplay(display, input)
return nil
}
@@ -822,6 +832,7 @@ func (a *App) SubmitEditedDisplayToTab(tabID, display, input, original string) e
if ctrl == nil {
return workspaceNotReadyErr(tab)
}
a.ensureTabTopicIndexedForUserTurn(tab)
ctrl.SubmitEditedDisplay(display, input, original)
return nil
}
@@ -908,6 +919,43 @@ func (a *App) snapshotTab(tab *WorkspaceTab) error {
return ctrl.Snapshot()
}
func snapshotTabDirect(tab *WorkspaceTab) error {
if tab == nil || tab.ReadOnly || tab.Ctrl == nil {
return nil
}
return tab.Ctrl.Snapshot()
}
func (a *App) snapshotTabForAction(tab *WorkspaceTab, action string) error {
if err := a.snapshotTab(tab); err != nil {
a.reportTabSnapshotError(tab, action, err)
if strings.TrimSpace(action) == "" {
return fmt.Errorf("save current session: %w", err)
}
return fmt.Errorf("save current session before %s: %w", action, err)
}
return nil
}
func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error) {
if err == nil {
return
}
tabID := ""
if tab != nil {
tabID = tab.ID
}
slog.Warn("desktop: session snapshot failed", "tab", tabID, "action", action, "err", err)
if tab == nil || tab.sink == nil {
return
}
prefix := "Session autosave failed"
if strings.TrimSpace(action) != "" && action != "autosave" {
prefix = "Session save failed before " + action
}
tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: prefix + ": " + err.Error()})
}
func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string {
if tab == nil {
return ""
@@ -1309,6 +1357,45 @@ func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
}
func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
if tab == nil || strings.TrimSpace(tab.TopicID) != "" {
return
}
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
if strings.TrimSpace(scope) == "global" {
scope = "global"
workspaceRoot = ""
} else {
scope = "project"
workspaceRoot = normalizeProjectRoot(workspaceRoot)
}
topicID := newTopicID()
a.mu.Lock()
if current := a.tabs[tab.ID]; current == tab {
if strings.TrimSpace(tab.TopicID) != "" {
a.mu.Unlock()
return
}
tab.TopicID = topicID
tab.TopicTitle = defaultTopicTitle
a.saveTabsLocked()
} else {
if strings.TrimSpace(tab.TopicID) != "" {
a.mu.Unlock()
return
}
tab.TopicID = topicID
tab.TopicTitle = defaultTopicTitle
}
a.mu.Unlock()
_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
a.persistTabSessionPath(tab, tab.currentSessionPath())
a.emitProjectTreeChanged()
}
func messagesHaveConversationContent(messages []provider.Message) bool {
for _, msg := range messages {
if msg.Role != provider.RoleSystem {
@@ -1459,13 +1546,17 @@ func removeDesktopSessionArtifacts(path string) error {
type CheckpointMeta struct {
Turn int `json:"turn"`
Prompt string `json:"prompt"`
Files []string `json:"files"` // cumulative files RestoreCode would affect from this turn
Files []string `json:"files"` // stable preview of cumulative files RestoreCode would affect from this turn
FileCount int `json:"fileCount"` // full cumulative file count, including entries omitted from Files
FilesTruncated bool `json:"filesTruncated,omitempty"`
TurnFileCount int `json:"turnFileCount"` // files changed during this turn only
Time int64 `json:"time"` // unix milliseconds
CanCode bool `json:"canCode"`
CanConversation bool `json:"canConversation"`
}
const checkpointFilePreviewLimit = 60
// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.
func (a *App) Checkpoints() []CheckpointMeta {
return a.CheckpointsForTab("")
@@ -1501,23 +1592,48 @@ func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {
// files RestoreCode would actually affect from this turn.
hasCodeAfter := false
codeFileSet := make(map[string]bool, len(metas)*2)
codeFilePreview := []string{}
for i := len(out) - 1; i >= 0; i-- {
if len(out[i].Files) > 0 {
hasCodeAfter = true
}
for _, f := range out[i].Files {
if codeFileSet[f] {
continue
}
codeFileSet[f] = true
codeFilePreview = insertCheckpointFilePreview(codeFilePreview, f, checkpointFilePreviewLimit)
}
out[i].CanCode = hasCodeAfter
out[i].Files = make([]string, 0, len(codeFileSet))
for f := range codeFileSet {
out[i].Files = append(out[i].Files, f)
}
sort.Strings(out[i].Files) // map iteration is unordered; keep the list stable
out[i].FileCount = len(codeFileSet)
out[i].Files = append([]string{}, codeFilePreview...)
out[i].FilesTruncated = out[i].FileCount > len(out[i].Files)
}
return out
}
func insertCheckpointFilePreview(preview []string, path string, limit int) []string {
if limit <= 0 || path == "" {
return preview
}
idx := sort.SearchStrings(preview, path)
if idx < len(preview) && preview[idx] == path {
return preview
}
if len(preview) < limit {
preview = append(preview, "")
copy(preview[idx+1:], preview[idx:])
preview[idx] = path
return preview
}
if idx >= limit {
return preview
}
copy(preview[idx+1:], preview[idx:limit-1])
preview[idx] = path
return preview
}
// ToolResultForTab returns the full arguments and output for one tool call that
// were elided from the frontend's in-memory items[] for memory efficiency. The
// caller (frontend ToolCard) loads this on demand when the user expands a
@@ -2313,11 +2429,7 @@ func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
root = ""
}
if topicID == "" {
topic, err := a.CreateTopic(scope, root, "")
if err != nil {
return err
}
topicID = topic.ID
return a.openTransientBlankRuntime(scope, root)
}
var err error
if a.singleSurfaceLayoutEnabled() {
@@ -2330,6 +2442,56 @@ func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
return err
}
func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
scope = strings.TrimSpace(scope)
if scope != "project" {
scope = "global"
}
actualRoot := ""
if scope == "project" {
workspaceRoot = normalizeProjectRoot(workspaceRoot)
if workspaceRoot == "" {
return fmt.Errorf("workspaceRoot is required")
}
saveWorkspace(workspaceRoot)
_ = addProject(workspaceRoot, "")
actualRoot = workspaceRoot
} else {
actualRoot = globalWorkspaceRoot()
if err := os.MkdirAll(actualRoot, 0o755); err != nil {
return fmt.Errorf("create global workspace: %w", err)
}
}
model, toolApprovalMode := desktopNewSessionDefaults()
sessionPath, err := createEmptySessionFile(desktopSessionDir(actualRoot), model)
if err != nil {
return err
}
tab := &WorkspaceTab{
Scope: scope,
WorkspaceRoot: actualRoot,
TopicTitle: defaultTopicTitle,
SessionPath: sessionPath,
model: model,
tokenMode: boot.TokenModeFull,
mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
toolApprovalMode: toolApprovalMode,
disabledMCP: map[string]ServerView{},
}
a.mu.Lock()
tab.ID = a.newUniqueTabIDLocked()
tab.sink = &tabEventSink{tabID: tab.ID, app: a}
a.tabs[tab.ID] = tab
a.tabOrder = append(a.tabOrder, tab.ID)
a.activeTabID = tab.ID
a.saveTabsLocked()
a.mu.Unlock()
a.startTabControllerBuild(tab)
return nil
}
func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {
a.mu.RLock()
defer a.mu.RUnlock()
@@ -2607,11 +2769,13 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {
return nil
}
profile := loadTabSessionProfile(sessionPath)
ctrl := tab.Ctrl
if ctrl == nil {
a.mu.Lock()
tab.SessionPath = sessionPath
applyTabSessionProfile(tab, profile)
tab.Ready = false
tab.StartupErr = ""
tab.ActivityStatus = ""
@@ -2628,7 +2792,14 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
return nil
}
_ = a.snapshotTab(tab) // persist writable sessions before switching the view.
if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
return err
}
if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
if err := saveTabSessionMeta(tab, oldPath); err != nil {
return fmt.Errorf("save current session metadata before switching sessions: %w", err)
}
}
if tab.hasActiveRuntimeWork() {
if !a.detachRuntimeForReplacement(tab) {
return fmt.Errorf("current session runtime cannot be detached")
@@ -2640,6 +2811,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
a.mu.Lock()
tab.Ctrl = nil
tab.SessionPath = sessionPath
applyTabSessionProfile(tab, profile)
tab.Ready = false
tab.StartupErr = ""
tab.ActivityStatus = ""
@@ -3247,8 +3419,10 @@ func (a *App) RemoveWorkspace(dir string) error {
if !tabInWorkspace(tab, dir) {
continue
}
if tab.Ctrl != nil && !tab.ReadOnly {
_ = tab.Ctrl.Snapshot()
if err := snapshotTabDirect(tab); err != nil {
a.mu.Unlock()
slog.Warn("desktop: snapshot before removing workspace failed", "tab", id, "workspace", dir, "err", err)
return fmt.Errorf("save current session before removing workspace: %w", err)
}
a.markTabRemovedLocked(tab)
closeTabs = append(closeTabs, tab)
@@ -3314,24 +3488,23 @@ func migrateLegacyWorkspacesIntoProjects() {
if len(legacy) == 0 {
return
}
f := loadProjectsFile()
seen := make(map[string]bool, len(f.Projects)+len(legacy))
for _, p := range f.Projects {
seen[p.Root] = true
}
changed := false
for _, path := range legacy {
root := normalizeProjectRoot(path)
if root == "" || seen[root] {
continue
_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
seen := make(map[string]bool, len(f.Projects)+len(legacy))
for _, p := range f.Projects {
seen[p.Root] = true
}
f.Projects = append(f.Projects, desktopProject{Root: root})
seen[root] = true
changed = true
}
if changed {
_ = saveProjectsFile(f)
}
changed := false
for _, path := range legacy {
root := normalizeProjectRoot(path)
if root == "" || seen[root] {
continue
}
f.Projects = append(f.Projects, desktopProject{Root: root})
seen[root] = true
changed = true
}
return changed, nil
})
}
func workspaceName(path string) string {
@@ -4450,23 +4623,78 @@ func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
// Meta describes the session for the frontend's header and status line.
type Meta struct {
Label string `json:"label"`
Ready bool `json:"ready"`
StartupErr string `json:"startupErr,omitempty"`
EventChannel string `json:"eventChannel"`
Cwd string `json:"cwd"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
WorkspaceName string `json:"workspaceName,omitempty"`
WorkspacePath string `json:"workspacePath,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
ImageInputEnabled bool `json:"imageInputEnabled"`
AutoApproveTools bool `json:"autoApproveTools"`
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
CollaborationMode string `json:"collaborationMode"`
ToolApprovalMode string `json:"toolApprovalMode"`
TokenMode string `json:"tokenMode"`
Goal string `json:"goal,omitempty"`
GoalStatus string `json:"goalStatus,omitempty"`
Label string `json:"label"`
Ready bool `json:"ready"`
StartupErr string `json:"startupErr,omitempty"`
EventChannel string `json:"eventChannel"`
Cwd string `json:"cwd"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
WorkspaceName string `json:"workspaceName,omitempty"`
WorkspacePath string `json:"workspacePath,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
ImageInputEnabled bool `json:"imageInputEnabled"`
AutoApproveTools bool `json:"autoApproveTools"`
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
CollaborationMode string `json:"collaborationMode"`
ToolApprovalMode string `json:"toolApprovalMode"`
TokenMode string `json:"tokenMode"`
Goal string `json:"goal,omitempty"`
GoalStatus string `json:"goalStatus,omitempty"`
AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"`
}
type AutoResearchCompactView struct {
TaskID string `json:"taskId"`
Status string `json:"status"`
Iteration int `json:"iteration"`
PivotRequired bool `json:"pivotRequired"`
StaleCount int `json:"staleCount"`
}
type AutoResearchCriterionView struct {
ID string `json:"id"`
Description string `json:"description"`
Required bool `json:"required"`
EvidenceCount int `json:"evidenceCount"`
Status string `json:"status"`
}
type AutoResearchStatusView struct {
TaskID string `json:"taskId"`
Goal string `json:"goal"`
Status string `json:"status"`
Iteration int `json:"iteration"`
CurrentDirection string `json:"currentDirection"`
StaleCount int `json:"staleCount"`
PivotCount int `json:"pivotCount"`
PivotRequired bool `json:"pivotRequired"`
LastHeartbeatAt string `json:"lastHeartbeatAt"`
FindingCount int `json:"findingCount"`
OpenCriteria []AutoResearchCriterionView `json:"openCriteria"`
Blocker string `json:"blocker"`
TaskPath string `json:"taskPath"`
NextRequiredAction string `json:"nextRequiredAction"`
}
type AutoResearchFindingView struct {
ID string `json:"id"`
Kind string `json:"kind"`
Summary string `json:"summary"`
Source string `json:"source"`
Command string `json:"command,omitempty"`
Paths []string `json:"paths,omitempty"`
Accepted bool `json:"accepted"`
CreatedAt string `json:"createdAt"`
}
type AutoResearchEvidenceView struct {
ID string `json:"id"`
Kind string `json:"kind"`
Summary string `json:"summary"`
Source string `json:"source"`
Command string `json:"command,omitempty"`
Paths []string `json:"paths,omitempty"`
Accepted bool `json:"accepted"`
}
// Meta reports the model label, readiness, any startup error, the working
@@ -4529,9 +4757,140 @@ func (a *App) MetaForTab(tabID string) Meta {
TokenMode: tokenMode,
Goal: goal,
GoalStatus: goalStatus,
AutoResearch: compactAutoResearch(tab),
}
}
func compactAutoResearch(tab *WorkspaceTab) *AutoResearchCompactView {
if tab == nil || tab.Ctrl == nil {
return nil
}
summary, ok := tab.Ctrl.AutoResearchSummary()
if !ok || summary == nil || summary.TaskID == "" {
return nil
}
return &AutoResearchCompactView{
TaskID: summary.TaskID,
Status: summary.Status,
Iteration: summary.Iteration,
PivotRequired: summary.PivotRequired,
StaleCount: summary.StaleCount,
}
}
func autoResearchStatusView(summary *autoresearch.Summary) AutoResearchStatusView {
if summary == nil {
return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
}
open := make([]AutoResearchCriterionView, 0, len(summary.OpenCriteria))
for _, criterion := range summary.OpenCriteria {
open = append(open, AutoResearchCriterionView{
ID: criterion.ID,
Description: criterion.Description,
Required: criterion.Required,
EvidenceCount: criterion.EvidenceCount,
Status: criterion.Status,
})
}
return AutoResearchStatusView{
TaskID: summary.TaskID,
Goal: summary.Goal,
Status: summary.Status,
Iteration: summary.Iteration,
CurrentDirection: summary.CurrentDirection,
StaleCount: summary.StaleCount,
PivotCount: summary.PivotCount,
PivotRequired: summary.PivotRequired,
LastHeartbeatAt: summary.LastHeartbeatAt.Format(time.RFC3339),
FindingCount: summary.FindingCount,
OpenCriteria: open,
Blocker: summary.Blocker,
TaskPath: summary.TaskPath,
NextRequiredAction: summary.NextRequiredAction,
}
}
func (a *App) AutoResearchCurrent() AutoResearchStatusView {
return a.AutoResearchStatus("")
}
func (a *App) AutoResearchStatus(tabID string) AutoResearchStatusView {
tab := a.tabByID(tabID)
if tab == nil || tab.Ctrl == nil {
return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
}
summary, ok := tab.Ctrl.AutoResearchSummary()
if !ok {
return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
}
return autoResearchStatusView(summary)
}
func (a *App) AutoResearchList(tabID string) []AutoResearchStatusView {
tab := a.tabByID(tabID)
if tab == nil || tab.Ctrl == nil {
return []AutoResearchStatusView{}
}
summaries, ok := tab.Ctrl.AutoResearchList()
if !ok {
return []AutoResearchStatusView{}
}
out := make([]AutoResearchStatusView, 0, len(summaries))
for i := range summaries {
out = append(out, autoResearchStatusView(&summaries[i]))
}
return out
}
func (a *App) AutoResearchFindings(tabID string, limit int) []AutoResearchFindingView {
tab := a.tabByID(tabID)
if tab == nil || tab.Ctrl == nil {
return []AutoResearchFindingView{}
}
findings, ok := tab.Ctrl.AutoResearchFindings(limit)
if !ok {
return []AutoResearchFindingView{}
}
out := make([]AutoResearchFindingView, 0, len(findings))
for _, finding := range findings {
out = append(out, AutoResearchFindingView{
ID: finding.ID,
Kind: finding.Kind,
Summary: finding.Summary,
Source: finding.Source,
Command: finding.Command,
Paths: append([]string(nil), finding.Paths...),
Accepted: finding.Accepted,
CreatedAt: finding.CreatedAt.Format(time.RFC3339),
})
}
return out
}
func (a *App) AutoResearchOpenTask(tabID string) error {
status := a.AutoResearchStatus(tabID)
if strings.TrimSpace(status.TaskPath) == "" {
return os.ErrInvalid
}
return a.RevealPath(status.TaskPath)
}
func (a *App) AutoResearchRecordEvidence(tabID, criterionID string, input AutoResearchEvidenceView) error {
tab := a.tabByID(tabID)
if tab == nil || tab.Ctrl == nil {
return os.ErrInvalid
}
return tab.Ctrl.RecordAutoResearchEvidence(criterionID, control.AutoResearchEvidenceInput{
ID: input.ID,
Kind: input.Kind,
Summary: input.Summary,
Source: input.Source,
Command: input.Command,
Paths: append([]string(nil), input.Paths...),
Accepted: input.Accepted,
})
}
func (a *App) SetGoal(goal string) {
a.SetGoalForTab("", goal)
}
@@ -5515,6 +5874,9 @@ func (a *App) RemoveMCPServer(name string) error {
return err
}
if disconnected || removed {
if h := tab.Ctrl.Host(); h != nil {
h.ClearFailure(name)
}
a.mu.Lock()
delete(tab.disabledMCP, name)
tab.mcpOrder = removeServerOrder(tab.mcpOrder, name)
@@ -6180,7 +6542,9 @@ func (a *App) SetModelForTab(tabID, name string) error {
if prevPath == "" {
prevPath = tab.Ctrl.SessionPath()
}
_ = a.snapshotTab(tab)
if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
return err
}
carried = tab.Ctrl.History()
tab.Ctrl.Close()
}
@@ -6284,7 +6648,9 @@ func (a *App) SetEffortForTab(tabID, level string) error {
if prevPath == "" {
prevPath = tab.Ctrl.SessionPath()
}
_ = a.snapshotTab(tab)
if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
return err
}
carried = tab.Ctrl.History()
tab.Ctrl.Close()
}
@@ -6361,7 +6727,9 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
if prevPath == "" {
prevPath = oldCtrl.SessionPath()
}
_ = a.snapshotTab(tab)
if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil {
return err
}
carried = oldCtrl.History()
}
sharedHost := a.lookupSharedHost(tab.SharedHostKey)
@@ -6837,7 +7205,9 @@ func (a *App) RevealPath(path string) error {
return revealPath(path)
}
func revealPath(path string) error {
var revealPath = defaultRevealPath
func defaultRevealPath(path string) error {
switch goruntime.GOOS {
case "darwin":
return exec.Command("open", "-R", path).Start()
+119 -1
View File
@@ -49,7 +49,12 @@ func waitForFile(t *testing.T, path, want string) {
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
}
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
tab.saveMu.Lock()
idle := !tab.saving && !tab.saveAgain
@@ -132,6 +137,91 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
waitForAutosaveIdle(t, tab)
}
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
_ = a
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
tab.saveMu.Lock()
failures := tab.saveFailures
tab.saveMu.Unlock()
if failures == 0 {
t.Fatal("autosave failure should be recorded and retried")
}
if info, err := os.Stat(path); err != nil || !info.IsDir() {
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove blocked dir: %v", err)
}
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
tab.saveMu.Lock()
failures = tab.saveFailures
tab.saveMu.Unlock()
if failures != 0 {
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
}
}
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, _ := appWithTab(t, path)
a.tabs["target_tab"] = &WorkspaceTab{
ID: "target_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
a.tabOrder = []string{"test_tab", "target_tab"}
err := a.SetActiveTab("target_tab")
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
}
if a.activeTabID != "test_tab" {
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
}
}
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
target := filepath.Join(t.TempDir(), "target.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
if err := sess.Save(target); err != nil {
t.Fatalf("save target: %v", err)
}
loaded, err := agent.LoadSession(target)
if err != nil {
t.Fatalf("load target: %v", err)
}
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
t.Fatalf("rebind error = %v, want persistence failure", err)
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
// longer write the session file — even when it is in flight at the moment the
@@ -187,6 +277,34 @@ func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
}
}
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs[survivor.ID] = survivor
a.tabOrder = []string{tab.ID, survivor.ID}
err := a.CloseTab(tab.ID)
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
t.Fatalf("CloseTab error = %v, want persistence failure", err)
}
if _, ok := a.tabs[tab.ID]; !ok {
t.Fatal("tab was removed even though its session could not be saved")
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
// closing/drain logic is per-tab and must not leak to other tabs.
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
+41 -2
View File
@@ -3201,8 +3201,8 @@ func TestDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
if tab.TopicID == topicID {
t.Fatalf("fallback tab %q reused deleted topic %q", id, topicID)
}
if strings.TrimSpace(tab.TopicID) == "" {
t.Fatalf("fallback tab %q has empty topic ID", id)
if strings.TrimSpace(tab.TopicID) != "" {
t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
}
}
trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl")
@@ -4484,6 +4484,45 @@ args = ["serve"]
}
}
func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[[plugins]]
name = "broken"
command = "reasonix-missing-mcp-binary"
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer app.activeCtrl().Close()
recordMCPFailure(app.activeCtrl(), config.PluginEntry{
Name: "broken",
Command: "reasonix-missing-mcp-binary",
}, errors.New("connect: missing binary"))
view := app.Capabilities()
if len(view.Servers) != 1 || view.Servers[0].Name != "broken" || view.Servers[0].Status != "failed" {
t.Fatalf("Capabilities before remove = %+v, want broken failed", view.Servers)
}
if err := app.RemoveMCPServer("broken"); err != nil {
t.Fatalf("RemoveMCPServer(broken): %v", err)
}
if mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() still contains broken after remove: %+v", app.activeCtrl().Host().Failures())
}
view = app.Capabilities()
for _, s := range view.Servers {
if s.Name == "broken" {
t.Fatalf("Capabilities after remove still contains broken: %+v", view.Servers)
}
}
}
func TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"log"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
const botForwardSendTimeout = 30 * time.Second
const botForwardQueueSize = 64
// ── Forward target ──────────────────────────────────────────────────────────
// botForwardTarget identifies one remote chat to send forwarded events to.
type botForwardTarget struct {
ConnID string
Domain string
ChatID string
ChatType bot.ChatType
}
// ── Event forwarder ─────────────────────────────────────────────────────────
// botEventForwarder implements event.Sink and forwards relevant events to
// connected bot channels through the desktopBotRuntime. It is attached to a
// tabEventSink when a heartbeat task should push AI output to IM channels.
//
// It accumulates Text events and sends them as complete messages on TurnDone
// (and occasionally during generation when the buffer grows large enough), so
// the remote side sees progressive streaming output rather than one big blob.
type botEventForwarder struct {
runtime *desktopBotRuntime
targets []botForwardTarget
mu sync.Mutex
buf strings.Builder
queueMu sync.Mutex
queue chan string
closed bool
closeOnce sync.Once
}
// newBotEventForwarder creates a forwarder that sends to all given targets.
// runtime may be nil — Emit calls are then no-ops.
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
f := &botEventForwarder{
runtime: runtime,
targets: targets,
queue: make(chan string, botForwardQueueSize),
}
go f.run()
return f
}
// Emit implements event.Sink. It forwards text and lifecycle events to the
// connected bot channels; reasoning, tool dispatch, and other internal events
// are dropped to avoid noisy IM output.
func (f *botEventForwarder) Emit(e event.Event) {
if f.runtime == nil || len(f.targets) == 0 {
return
}
switch e.Kind {
case event.TurnStarted:
f.mu.Lock()
f.buf.Reset()
f.mu.Unlock()
case event.Text:
f.mu.Lock()
f.buf.WriteString(e.Text)
size := f.buf.Len()
f.mu.Unlock()
// Flush opportunistically when the buffer crosses a threshold, so long
// streams (e.g. "tell me three jokes") produce multiple messages.
if size >= 400 {
f.flush()
}
case event.TurnDone:
f.flush()
f.Close()
case event.ApprovalRequest:
// The heartbeat turn belongs to the desktop tab controller, not the bot
// gateway session, so remote /approve replies cannot satisfy this ID.
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
text += "\n请回到桌面窗口处理。"
f.sendToAll(text)
case event.AskRequest:
var qb strings.Builder
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
for i, q := range e.Ask.Questions {
if i > 0 {
qb.WriteString("\n")
}
qb.WriteString(q.Prompt)
}
qb.WriteString("\n请回到桌面窗口处理。")
f.sendToAll(qb.String())
case event.Notice:
if e.Level == event.LevelWarn {
f.sendToAll("⚠️ " + e.Text)
}
case event.CompactionStarted:
f.sendToAll("🔄 正在压缩上下文...")
}
}
// flush sends the accumulated buffer as one message per target channel.
func (f *botEventForwarder) flush() {
f.mu.Lock()
text := strings.TrimSpace(f.buf.String())
if text == "" {
f.mu.Unlock()
return
}
f.buf.Reset()
f.mu.Unlock()
f.sendToAll(text)
}
// sendToAll dispatches text to every target channel. Errors are logged and
// non-fatal; a failed target does not block other targets.
func (f *botEventForwarder) sendToAll(text string) {
text = strings.TrimSpace(text)
if f.runtime == nil || len(f.targets) == 0 || text == "" {
return
}
f.queueMu.Lock()
defer f.queueMu.Unlock()
if f.closed {
return
}
select {
case f.queue <- text:
default:
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
}
}
func (f *botEventForwarder) run() {
for text := range f.queue {
f.sendToAllNow(text)
}
}
func (f *botEventForwarder) sendToAllNow(text string) {
for _, tgt := range f.targets {
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
ChatID: tgt.ChatID,
ChatType: tgt.ChatType,
Text: text,
})
cancel()
if err != nil {
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
}
}
}
func (f *botEventForwarder) Close() {
f.closeOnce.Do(func() {
f.flush()
f.queueMu.Lock()
f.closed = true
close(f.queue)
f.queueMu.Unlock()
})
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
type desktopForwardTestAdapter struct {
platform bot.Platform
name string
messages chan bot.InboundMessage
entered chan struct{}
release chan struct{}
sent chan bot.OutboundMessage
once sync.Once
}
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
return &desktopForwardTestAdapter{
platform: bot.PlatformFeishu,
name: "forward-test",
messages: make(chan bot.InboundMessage),
entered: make(chan struct{}),
release: make(chan struct{}),
sent: make(chan bot.OutboundMessage, 8),
}
}
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
func (a *desktopForwardTestAdapter) Name() string { return a.name }
func (a *desktopForwardTestAdapter) Start(context.Context) error {
return nil
}
func (a *desktopForwardTestAdapter) Stop() error { return nil }
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
return nil
}
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
return a.messages
}
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
a.once.Do(func() { close(a.entered) })
select {
case <-a.release:
case <-ctx.Done():
return bot.SendResult{}, ctx.Err()
}
a.sent <- msg
return bot.SendResult{MessageID: "sent"}, nil
}
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
ID: "feishu-lark",
Domain: "lark",
Platform: bot.PlatformFeishu,
Adapter: adapter,
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
return &desktopBotRuntime{gw: gw}
}
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
done := make(chan struct{})
go func() {
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("Emit blocked behind slow bot send")
}
select {
case <-adapter.entered:
case <-time.After(500 * time.Millisecond):
t.Fatal("adapter send did not start")
}
forwarder.Close()
close(adapter.release)
select {
case <-adapter.sent:
case <-time.After(500 * time.Millisecond):
t.Fatal("queued bot message was not sent after adapter release")
}
}
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
close(adapter.release)
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
ID: "approval-1",
Tool: "shell",
Subject: "run command",
}})
forwarder.Close()
select {
case msg := <-adapter.sent:
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
}
if !strings.Contains(msg.Text, "桌面") {
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("approval notice was not sent")
}
}
+60
View File
@@ -258,3 +258,63 @@ func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
return true
}
// SendToAdapter sends a message through the running gateway's adapter
// identified by connID. Returns an error if the gateway is not running
// or no matching adapter is found.
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
r.mu.Lock()
gw := r.gw
r.mu.Unlock()
if gw == nil {
return bot.SendResult{}, nil // gateway not running — silent no-op
}
return gw.SendToAdapter(ctx, connID, domain, msg)
}
// Running returns true if the bot gateway is currently active.
func (r *desktopBotRuntime) Running() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.gw != nil
}
// ForwardTargets returns the list of bot forward targets derived from the
// current config's bot connections and their session mappings. Each mapping
// produces one target (connID + chatID + chatType) for event forwarding.
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
if cfg == nil {
return nil
}
var targets []botForwardTarget
seen := make(map[botForwardTarget]bool)
for _, conn := range cfg.Bot.Connections {
if !conn.Enabled {
continue
}
connID := botruntime.ConnectionRuntimeID(conn)
domain := strings.TrimSpace(conn.Domain)
for _, sm := range conn.SessionMappings {
remoteID := strings.TrimSpace(sm.RemoteID)
if remoteID == "" {
continue
}
chatType := bot.ChatDM
if sm.ChatType != "" {
chatType = bot.ChatType(sm.ChatType)
}
target := botForwardTarget{
ConnID: connID,
Domain: domain,
ChatID: remoteID,
ChatType: chatType,
}
if seen[target] {
continue
}
seen[target] = true
targets = append(targets, target)
}
}
return targets
}
+31
View File
@@ -63,6 +63,37 @@ func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
}
}
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Enabled: true,
SessionMappings: []config.BotConnectionSessionMapping{
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
},
}}
targets := newDesktopBotRuntime().ForwardTargets(cfg)
if len(targets) != 2 {
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
}
seen := map[string]bool{}
for _, target := range targets {
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
if seen[key] {
t.Fatalf("duplicate target %q in %+v", key, targets)
}
seen[key] = true
}
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
t.Fatalf("targets = %+v, want group and dm targets", targets)
}
}
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
+74
View File
@@ -25,6 +25,28 @@ func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
}
}
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
t.Helper()
raw, err := json.Marshal(metas)
if err != nil {
t.Fatal(err)
}
var payload []struct {
Files json.RawMessage `json:"files"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
for i, item := range payload {
if string(item.Files) == "null" {
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
}
if len(item.Files) == 0 || item.Files[0] != '[' {
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
}
}
}
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
// reverts files touched in that turn or any later one, so a turn with no file
// changes of its own can still rewind code when a later turn changed files. The
@@ -77,4 +99,56 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
}
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
}
if len(metas[2].Files) != 0 {
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
for i := 0; i < checkpointFilePreviewLimit+5; i++ {
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
}
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(metas))
}
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
}
if len(metas[0].Files) != checkpointFilePreviewLimit {
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
}
if !metas[0].FilesTruncated {
t.Fatal("turn 0 should mark file preview as truncated")
}
if metas[0].TurnFileCount != 0 {
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
}
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
@@ -16,7 +16,16 @@ const transpiled = ts.transpileModule(source, {
}).outputText;
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
const { shouldShowTodoPanel, todoDismissalKey } = await import(moduleUrl);
const {
dismissedTodoKeyForScope,
scopedTodoBatchKey,
scopedTodoDismissalKey,
shouldOpenTodoPanelByDefault,
shouldShowTodoPanel,
todoBatchKey,
todoDismissalKey,
todoPanelScope,
} = await import(moduleUrl);
const completedTodos = [
{ content: "Inspect the report", status: "completed" },
@@ -42,6 +51,66 @@ assert.equal(
false,
"a user dismissal still hides that exact todo list",
);
const completedKey = todoDismissalKey(completedTodos);
const dismissedBySession = new Set([scopedTodoDismissalKey("session:a", completedKey)]);
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
false,
"a completed todo dismissal hides the list in the session where it was closed",
);
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:b", dismissedBySession, completedKey), completedTodos),
true,
"a completed todo dismissal in one session does not hide another session's list",
);
dismissedBySession.add(scopedTodoDismissalKey("session:b", completedKey));
assert.equal(
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
false,
"closing another session's todo does not forget the first session dismissal",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-a",
activeTab: {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "/sessions/a.jsonl",
},
}),
"session:/sessions/a.jsonl",
"a restored history session uses its session path as the todo panel scope",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-b",
activeTab: {
id: "tab-a",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
sessionPath: "/sessions/a.jsonl",
},
eventChannel: "desktop-events",
}),
"tab:tab-b",
"a stale active-tab fallback must not scope dismissal to the previous session",
);
assert.equal(
todoPanelScope({
activeTabId: "tab-c",
activeTab: {
id: "tab-c",
scope: "project",
workspaceRoot: "/repo",
topicId: "topic-a",
},
}),
"tab:tab-c",
"an unsaved topic session uses its tab id so sibling sessions do not share todo state",
);
assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel");
assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel");
@@ -61,6 +130,36 @@ assert.notEqual(
todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
"real progress produces a fresh dismissal key",
);
assert.equal(
todoBatchKey(activeTodos),
todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
"status progress stays in the same todo batch",
);
assert.equal(
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
scopedTodoBatchKey("session:a", todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }])),
"status progress keeps the same scoped open-state key",
);
assert.notEqual(
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
scopedTodoBatchKey("session:b", todoBatchKey(activeTodos)),
"the same task list in a different session gets isolated open state",
);
assert.notEqual(
todoBatchKey(activeTodos),
todoBatchKey([{ content: "Run a different task", status: "in_progress" }]),
"new task content creates a new todo batch",
);
assert.equal(
shouldOpenTodoPanelByDefault(activeTodos),
true,
"new incomplete todo batches open by default",
);
assert.equal(
shouldOpenTodoPanelByDefault(completedTodos),
false,
"completed todo batches collapse by default",
);
const iterations = 200_000;
const started = performance.now();
+185 -12
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
@@ -10,8 +10,11 @@ import {
Activity,
CircleHelp,
Command,
Copy as RestoreIcon,
Download,
Minus,
Search,
Square,
SquarePen,
PanelLeft,
PanelRight,
@@ -29,6 +32,7 @@ import {
Brain,
Cpu,
Palette,
X,
} from "lucide-react";
import { useToast } from "./lib/toast";
import { asArray } from "./lib/array";
@@ -59,7 +63,15 @@ import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
import "./custom/features/heartbeat/heartbeat.css";
import { CopyButton } from "./components/CopyButton";
import { parseTodos } from "./lib/tools";
import { shouldShowTodoPanel, todoDismissalKey } from "./lib/todoVisibility";
import {
dismissedTodoKeyForScope,
scopedTodoBatchKey,
scopedTodoDismissalKey,
shouldShowTodoPanel,
todoBatchKey,
todoDismissalKey,
todoPanelScope,
} from "./lib/todoVisibility";
import {
type BotConnectionView,
type BotRuntimeStatusView,
@@ -182,9 +194,70 @@ function normalizeDesktopLayoutStyle(style: string | undefined): DesktopLayoutSt
return "classic";
}
const SHOW_CONTEXT_DOCK = true;
const DISMISSED_TODO_STORAGE_KEY = "todoPanel:dismissedKeys";
const MAX_DISMISSED_TODO_KEYS = 160;
type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string };
type WorkspaceInsertTarget = "composer" | "planRevision";
type DesktopPlatform = "darwin" | "windows" | "linux";
function WindowsWindowControls() {
const [maximised, setMaximised] = useState(false);
const syncMaximised = useCallback(() => {
void app.IsMainWindowMaximised()
.then(setMaximised)
.catch(() => setMaximised(false));
}, []);
useEffect(() => {
syncMaximised();
window.addEventListener("resize", syncMaximised);
window.addEventListener("focus", syncMaximised);
return () => {
window.removeEventListener("resize", syncMaximised);
window.removeEventListener("focus", syncMaximised);
};
}, [syncMaximised]);
const toggleMaximise = useCallback(() => {
void app.ToggleMaximiseMainWindow()
.then(() => window.setTimeout(syncMaximised, 80))
.catch(() => undefined);
}, [syncMaximised]);
return (
<div className="windows-window-controls" aria-label="Window controls">
<button
className="windows-window-control windows-window-control--minimize"
type="button"
aria-label="Minimize window"
title="Minimize"
onClick={() => void app.MinimiseMainWindow()}
>
<Minus size={13} strokeWidth={1.9} />
</button>
<button
className="windows-window-control windows-window-control--maximize"
type="button"
aria-label="Maximize or restore window"
aria-pressed={maximised}
title={maximised ? "Restore" : "Maximize"}
onClick={toggleMaximise}
>
{maximised ? <RestoreIcon size={12} strokeWidth={1.75} /> : <Square size={11} strokeWidth={1.8} />}
</button>
<button
className="windows-window-control windows-window-control--close"
type="button"
aria-label="Close window"
title="Close"
onClick={() => void app.CloseMainWindow()}
>
<X size={13} strokeWidth={1.9} />
</button>
</div>
);
}
type HistoryViewState =
| { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] }
| { kind: "history"; source: "all"; sessions: SessionMeta[] }
@@ -231,6 +304,29 @@ type SidebarImConnectionDetailProps = {
onManageAllowlist: () => void;
};
function loadDismissedTodoKeys(): Set<string> {
try {
const saved = window.localStorage.getItem(DISMISSED_TODO_STORAGE_KEY);
if (!saved) return new Set();
const parsed = JSON.parse(saved) as unknown;
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0));
} catch {
return new Set();
}
}
function saveDismissedTodoKeys(keys: ReadonlySet<string>): void {
try {
window.localStorage.setItem(
DISMISSED_TODO_STORAGE_KEY,
JSON.stringify(Array.from(keys).slice(-MAX_DISMISSED_TODO_KEYS)),
);
} catch {
/* ignore quota errors */
}
}
function isSidebarImConnection(connection: BotConnectionView): boolean {
return connection.provider === "feishu" || connection.provider === "weixin";
}
@@ -652,6 +748,21 @@ function browserPlatformOverride(): DesktopPlatform | null {
return null;
}
const GUIDANCE_QUEUE_MOCK_ITEMS = [
"先确认发送后输入框为什么残留刚发的消息,再决定修哪里。",
"保持真实 steer 协议不变,只调整前端乐观队列和按钮状态。",
"最后补后端 submit 悬挂时的回归测试,确保输入框会立刻释放。",
] as const;
function browserMockScenarioParam(): string {
if (typeof window === "undefined" || window.runtime) return "";
return new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase() ?? "";
}
function isGuidanceMockScenario(value: string): boolean {
return value === "guidance" || value === "guide" || value === "steer";
}
function detectBrowserPlatform(): DesktopPlatform {
const override = browserPlatformOverride();
if (override) return override;
@@ -904,6 +1015,8 @@ export default function App() {
const unsub = onEvent((e) => {
if (e.kind === "turn_done") {
setDockRefreshKey((v) => v + 1);
}
if (e.kind === "turn_done") {
if (!e.err) playSuccessChime();
}
});
@@ -1393,7 +1506,11 @@ export default function App() {
// a stale local dismissal cannot hide work that still blocks final readiness;
// completed lists collapse automatically and can then be dismissed. The
// dismissal key is still based on stable todo content/state so history reloads
// do not resurrect the same finished list under a different event id.
// do not resurrect the same finished list under a different event id. The
// batch key ignores status changes so progress within the same task list does
// not look like a brand-new task batch. Dismissal and open state are scoped to
// the active session/topic/tab so different projects and sessions do not hide
// or reopen each other's todo panels.
const todoEntry = useMemo(() => {
for (let i = state.items.length - 1; i >= 0; i--) {
const it = state.items[i];
@@ -1405,9 +1522,30 @@ export default function App() {
}, [state.items]);
const todoItem = todoEntry?.item ?? null;
const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]);
const [dismissedTodo, setDismissedTodo] = useState<string | null>(null);
const [dismissedTodoKeys, setDismissedTodoKeys] = useState<Set<string>>(loadDismissedTodoKeys);
const todoKey = useMemo(() => todoDismissalKey(todos), [todos]);
const todoBatch = useMemo(() => todoBatchKey(todos), [todos]);
const todoScope = useMemo(
() => todoPanelScope({ activeTab, activeTabId, eventChannel: state.meta?.eventChannel }),
[activeTab, activeTabId, state.meta?.eventChannel],
);
const dismissedTodo = useMemo(
() => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey),
[dismissedTodoKeys, todoKey, todoScope],
);
const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]);
const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]);
const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos);
const dismissTodos = useCallback(() => {
if (!scopedTodoKey) return;
setDismissedTodoKeys((current) => {
if (current.has(scopedTodoKey)) return current;
const next = new Set(current);
next.add(scopedTodoKey);
saveDismissedTodoKeys(next);
return next;
});
}, [scopedTodoKey]);
const sessionTitle = topicTitle(activeTab);
const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);
@@ -2124,6 +2262,16 @@ export default function App() {
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
}, [transcriptItems, rewindState]);
const latestGuidanceConsumed = useMemo(() => {
for (let i = state.items.length - 1; i >= 0; i--) {
const item = state.items[i];
if (item.kind === "notice" && item.text.startsWith("↪ ")) {
return { key: item.id, text: item.text.slice(2) };
}
}
return null;
}, [state.items]);
// send wrapper: commits any pending optimistic rewind before sending.
const commitThenSend = useCallback(async (displayText: string, submitText?: string) => {
if (activeTab?.readOnly) throw new Error("channel session is read-only");
@@ -2621,7 +2769,7 @@ export default function App() {
setProjectRevision((value) => value + 1);
const tabs = await refreshTabMetas();
if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) {
await syncActiveTab(true);
await syncActiveTab(false);
}
}, [activeTabId, refreshTabMetas, syncActiveTab]);
@@ -2678,6 +2826,8 @@ export default function App() {
: t("sidebar.collapse");
const sidebarNavTooltipDisabled = !sidebarCollapsed;
const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
const workspacePanelResetWidth = rightDockDetailActive
? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
: defaultRightDockTreeWidth();
@@ -2699,6 +2849,15 @@ export default function App() {
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
const sidebarWorkbench = desktopLayoutStyle === "workbench";
const windowsFramelessChrome = desktopPlatform === "windows";
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (!windowsFramelessChrome) return;
const target = event.target as HTMLElement | null;
if (!target?.closest(".app-chrome, .topicbar, .workbench-dock__tools")) return;
if (target.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return;
event.preventDefault();
void app.ToggleMaximiseMainWindow();
}, [windowsFramelessChrome]);
// Creation keeps the classic sidebar/chat structure while gating chrome tweaks
// behind its own style flag so classic/workbench remain unchanged.
const appChromeHidden = sidebarWorkbench || sidebarCreation;
@@ -2713,11 +2872,13 @@ export default function App() {
<ShellExpandProvider>
<ShellHotkeys />
<TextSizeHotkeys />
<div
ref={appRef}
className={[
<div
ref={appRef}
onDoubleClickCapture={handleWindowsTitlebarDoubleClick}
className={[
"app",
`app--${desktopPlatform}`,
windowsFramelessChrome ? "app--windows-frameless" : "",
browserPreviewChrome ? "app--browser-preview" : "",
sidebarWorkbench ? "app--workbench" : "",
sidebarCreation ? "app--creation" : "",
@@ -3278,7 +3439,14 @@ export default function App() {
{!sidebarImDetailConnection && (
<footer className="footer" ref={footerRef}>
{showTodos && <TodoPanel todos={todos} onDismiss={() => setDismissedTodo(todoKey)} />}
{showTodos && (
<TodoPanel
key={scopedTodoBatch}
stateKey={scopedTodoBatch}
todos={todos}
onDismiss={dismissTodos}
/>
)}
{rewindState && (
<UndoRewindBanner
meta={{
@@ -3370,6 +3538,9 @@ export default function App() {
retry={state.retry}
transientDismissSignal={transientOverlayDismissSignal}
sessionKey={composerSessionKey}
guidanceConsumedKey={latestGuidanceConsumed?.key}
guidanceConsumedText={latestGuidanceConsumed?.text}
guidanceQueuePreviewItems={guidanceQueueMockItems}
/>
<StatusBar
context={state.context}
@@ -3385,9 +3556,9 @@ export default function App() {
modelLabel={state.meta?.label}
labelStyle={statusBarStyle}
items={statusBarItems}
workspacePath={state.meta?.workspacePath || state.meta?.workspaceRoot || state.meta?.cwd}
workspaceName={state.meta?.workspaceName}
gitBranch={state.meta?.gitBranch}
workspacePath={state.meta?.workspacePath || state.meta?.workspaceRoot || state.meta?.cwd}
workspaceName={state.meta?.workspaceName}
gitBranch={state.meta?.gitBranch}
/>
</footer>
)}
@@ -3519,6 +3690,7 @@ export default function App() {
initialTab={settingsTarget}
initialFocus={settingsFocus ?? undefined}
agentRunning={state.running}
desktopPlatform={desktopPlatform}
onClose={() => {
setSettingsFocus(null);
setSettingsTarget(null);
@@ -3563,6 +3735,7 @@ export default function App() {
<HeartbeatPanel open={heartbeatOpen} onClose={() => setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => {
void handleOpenTopic(scope, workspaceRoot, topicId);
}} />
{windowsFramelessChrome && <WindowsWindowControls />}
</div>
</ShellExpandProvider>
);
@@ -252,6 +252,30 @@ for (const selector of [
);
}
for (const selector of [
".app--windows-frameless .app-chrome--native-tabs",
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs",
]) {
const paddingRight = finalDeclaration(selector, "padding-right") ?? "";
ok(
finalDeclaration(selector, "--windows-frameless-titlebar-tools-offset") === "var(--windows-window-controls-safe)" &&
paddingRight.includes("--windows-frameless-titlebar-tools-offset") &&
paddingRight.includes("--chrome-panel-control-size") &&
!paddingRight.includes("--chrome-right-toggle-offset"),
`${selector} keeps titlebar tools fixed beside the Windows controls`,
);
}
for (const selector of [
".app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
]) {
ok(
finalDeclaration(selector, "right") === "calc(var(--windows-frameless-titlebar-tools-offset) + 8px)",
`${selector} stays fixed outside the Windows window controls`,
);
}
for (const selector of [
".layout--workbench-chrome-hidden",
":root[data-theme-style] .layout--workbench-chrome-hidden",
@@ -196,5 +196,34 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "tool-approval",
tool: "bash",
subject: "npm run build\n\nRun the build command to verify frontend artifacts.",
},
});
const subject = document.querySelector(".approval-subject");
ok(subject != null, "tool approval shows its full subject by default");
eq(
subject?.textContent,
"npm run build\n\nRun the build command to verify frontend artifacts.",
"default-open tool approval keeps the complete subject visible",
);
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/bridge-drag-rejection.test.ts
import { isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
import { isTransientWailsIPCError, isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
let passed = 0;
let failed = 0;
@@ -58,6 +58,21 @@ eq(
false,
"keeps ErrorEvent invalid argument visible without a recent native file drag",
);
eq(
isTransientWailsIPCError(new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.", "InvalidStateError")),
true,
"suppresses Wails IPC calls made before the websocket is open",
);
eq(
isTransientWailsIPCError(new TypeError("Cannot read properties of null (reading 'send')")),
true,
"suppresses Wails IPC calls made after the websocket is torn down",
);
eq(
isTransientWailsIPCError(new Error("backend returned an application error")),
false,
"keeps ordinary bridge failures visible",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -568,5 +568,176 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer({
running: true,
guidanceQueuePreviewItems: ["confirm the send lifecycle", "keep steer protocol unchanged", "add a hanging submit regression"],
});
let guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 2, "running guidance preview shows a compact queue preview");
ok(guidanceItems[0]?.textContent?.includes("confirm the send lifecycle") === true, "guidance preview shows the first seeded item");
ok(guidanceItems[1]?.textContent?.includes("keep steer protocol unchanged") === true, "guidance preview shows the second seeded item");
eq(document.querySelectorAll(".composer-guidance-item__guide").length, 2, "guidance preview exposes a guide action for each visible item");
let guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
ok(guidanceMore?.textContent?.includes("1 more queued") === true, "guidance preview summarizes overflow items");
eq(guidanceMore?.getAttribute("aria-expanded"), "false", "guidance overflow starts collapsed");
if (!guidanceMore) throw new Error("guidance overflow button did not render");
await act(async () => {
guidanceMore.click();
await flushTimers();
});
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 3, "guidance overflow expands the remaining queued items");
ok(guidanceItems[2]?.textContent?.includes("add a hanging submit regression") === true, "expanded guidance preview shows the hidden item");
guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
ok(guidanceMore?.textContent?.includes("Collapse") === true, "expanded guidance overflow can be collapsed");
eq(guidanceMore?.getAttribute("aria-expanded"), "true", "guidance overflow reports expanded state");
if (!guidanceMore) throw new Error("guidance collapse button did not render");
await act(async () => {
guidanceMore.click();
await flushTimers();
});
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 2, "guidance overflow collapses back to the compact preview");
await rerender({ guidanceQueuePreviewItems: ["only the latest preview seed"] });
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
eq(guidanceItems.length, 1, "guidance preview refreshes when the seed changes");
ok(guidanceItems[0]?.textContent?.includes("only the latest preview seed") === true, "guidance preview renders the refreshed seed");
await rerender({ running: false });
ok(document.querySelector(".composer-guidance-item") === null, "guidance preview clears when the mock turn stops");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
running: true,
onSend: (displayText, submitText) => {
calls.send.push(displayText);
calls.submit.push(submitText);
},
});
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
ok(sendButton.disabled === true, "running steer button stays disabled without input");
await rerender({ insertRequest: { id: 4, text: "keep the files small", mode: "replace" } });
ok(sendButton.disabled === false, "running steer button enables after text input");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "", "running composer queues guidance without sending it immediately");
eq(textarea.value, "", "queued running guidance clears the composer draft");
const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
if (!guidanceItem) throw new Error("running guidance chip did not render");
ok(guidanceItem.textContent?.includes("keep the files small") === true, "running guidance chip shows queued text");
ok(document.querySelector(".composer-guidance-head")?.textContent?.includes("Queued guidance 1") === true, "running guidance shelf shows queued count");
const guideButton = guidanceItem.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("running guidance guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(calls.send.join(","), "keep the files small", "queued guidance sends through onSend when guided");
ok(document.querySelector(".composer-guidance-item") === null, "queued guidance clears after being guided");
await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
const dismissButton = dismissibleGuidanceItem.querySelector(".composer-guidance-item__action") as HTMLButtonElement | null;
if (!dismissButton) throw new Error("running guidance dismiss button did not render");
await act(async () => {
dismissButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders again after another queued item");
await rerender({ guidanceConsumedKey: "s1", guidanceConsumedText: "prefer the smaller diff" });
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when steer is consumed");
await rerender({ insertRequest: { id: 7, text: "then stop showing the chip", mode: "replace" } });
await act(async () => {
sendButton.click();
await flushTimers();
});
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders before turn stop");
await rerender({ running: false });
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when the turn stops");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer({
running: true,
submitDisabled: true,
onSend: (displayText) => {
calls.send.push(displayText);
},
});
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
await rerender({ insertRequest: { id: 7, text: "steer while activating", mode: "replace" } });
ok(sendButton.disabled === false, "running guidance queue ignores controller submitDisabled");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.join(","), "", "running guidance queues while controllerReady is false");
const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("running guidance guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(calls.send.join(","), "steer while activating", "queued guidance can be guided while controllerReady is false");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
import { contextBreakdown, contextCostDisplay, contextSourceRows, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
let passed = 0;
@@ -49,7 +49,7 @@ eq(
42_124,
"legend values sum to used context tokens",
);
eq(Math.round(mock.otherPct), 33, "donut endpoint follows used/window percent");
eq(Math.round(mock.otherPct), 33, "usage endpoint follows used/window percent");
const issue5283 = contextBreakdown(6888, 1_000_000, 6840, 48, 48);
eq(
@@ -90,9 +90,16 @@ eq(
reasoningPct: 0,
otherPct: 0,
},
"unknown context window keeps donut segments empty",
"unknown context window keeps usage segments empty",
);
console.log("\ncontext window status");
eq(contextWindowStatus(33, 80), { tone: "good", key: "context.windowStatusHealthy" }, "low usage stays healthy");
eq(contextWindowStatus(72, 80), { tone: "notice", key: "context.windowStatusWatch" }, "usage near compact threshold warns early");
eq(contextWindowStatus(80, 80), { tone: "warn", key: "context.windowStatusPastCompact" }, "compact threshold reached takes warning tone");
eq(contextWindowStatus(91, 80), { tone: "warn", key: "context.windowStatusNearLimit" }, "near hard limit overrides compact status");
console.log("\ncontext panel cost");
const infoCost = contextCostDisplay({
@@ -118,6 +125,10 @@ console.log("\ncontext panel cache rate");
eq(formatCacheHitRate(99_950, 50), "99.95%", "cache hit rate preserves two decimal places");
eq(formatCacheHitRate(0, 10_000), "0.00%", "cache hit rate shows zero when usage data exists");
eq(formatCacheHitRate(0, 0), "-", "cache hit rate stays empty before usage data exists");
eq(cacheHitTone(8700, 1300), "good", "healthy cache hit rate uses positive tone");
eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
console.log("\ncontext panel source rows");
@@ -133,6 +133,17 @@ check("$\\frac{a}{b}$", () => isLikelyInlineMath("\\frac{a}{b}") === true);
check("$f(x)$", () => isLikelyInlineMath("f(x)") === true);
check("$x+1$", () => isLikelyInlineMath("x+1") === true);
console.log("\nisLikelyInlineMath — classifier gaps from PR #4543");
check("$\\tfrac12$", () => isLikelyInlineMath("\\tfrac12") === true);
check("$\\sqrt2$", () => isLikelyInlineMath("\\sqrt2") === true);
check("$SO(3,1)$", () => isLikelyInlineMath("SO(3,1)") === true);
check("$SU(2)$", () => isLikelyInlineMath("SU(2)") === true);
check("$GL(n)$", () => isLikelyInlineMath("GL(n)") === true);
check("$K = -iJ$", () => isLikelyInlineMath("K = -iJ") === true);
check("$p = +\\alpha$", () => isLikelyInlineMath("p = +\\alpha") === true);
check("$+$", () => isLikelyInlineMath("+") === true);
check("$=$", () => isLikelyInlineMath("=") === true);
console.log("\nisLikelyInlineMath — currency/link (NOT math)");
check("$5", () => isLikelyInlineMath("5") === false);
check("$10", () => isLikelyInlineMath("10") === false);
@@ -153,6 +164,15 @@ check("uppercase $I$ → math (math name in non-English prose)", () => isLikelyI
check("uppercase $A$ → math", () => isLikelyInlineMath("A") === true);
check("uppercase $V$ → math", () => isLikelyInlineMath("V") === true);
console.log("\nisLikelyInlineMath — primed letters and bracketed labels");
check("$S'$ → math (primed letter)", () => isLikelyInlineMath("S'") === true);
check("$y''$ → math (double prime)", () => isLikelyInlineMath("y''") === true);
check("$f'(x)$ → math (primed function)", () => isLikelyInlineMath("f'(x)") === true);
check("$\\psi'$ → math (Greek with prime)", () => isLikelyInlineMath("\\psi'") === true);
check("$[56]$ → math (irrep label)", () => isLikelyInlineMath("[56]") === true);
check("$[56,0^+]$ → math", () => isLikelyInlineMath("[56,0^+]") === true);
check("$[\\mathbf{56}]$ → math", () => isLikelyInlineMath("[\\mathbf{56}]") === true);
console.log("\nisLikelyInlineMath — minimal LaTeX patterns (regression)");
// LLMs frequently emit minimal LaTeX in math contexts that the older
// classifier rejected as currency / word tokens. These tests pin down the
@@ -170,6 +190,8 @@ check("comma-separated $A, B$ → math (ordered pair)", () => isLikelyInlineMath
check("comma-separated $1, 2, 3$ → math (sequence)", () => isLikelyInlineMath("1, 2, 3") === true);
check("comma-separated $\\alpha, \\beta$ → math (Greek pair)", () => isLikelyInlineMath("\\alpha, \\beta") === true);
check("parens-wrapped $(A, B)$ inner → math", () => isLikelyInlineMath("(A, B)") === true);
check("cycle notation $(12)$ → math", () => isLikelyInlineMath("(12)") === true);
check("cycle notation $(12)(34)$ → math", () => isLikelyInlineMath("(12)(34)") === true);
check("$S$ (set name) → math", () => isLikelyInlineMath("S") === true);
check("$S$ with surrounding prose (regression)", () => {
return normalizeMath("$S$ 非空\n$S$ 有上界") === "$S$ 非空\n$S$ 有上界";
@@ -217,7 +239,7 @@ check("\\|x\\| renders as double bars", () => {
console.log("\nnormalizeMath — LLM delimiter conversion");
eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$");
eq(normalizeMath("\\[E=mc^2\\]"), "$$E=mc^2$$", "\\[…\\] → $$…$$");
eq(normalizeMath("\\[E=mc^2\\]"), "$$\nE=mc^2\n$$", "\\[…\\] → $$…$$");
eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected");
console.log("\nnormalizeMath — \\slashed conversion (regression)");
@@ -235,19 +257,17 @@ console.log("\nnormalizeMath — inline $$ glued to prose (regression)");
// line before any $$ preceded by a letter/closing bracket/etc.
check("inline $$ after prose", () => {
const out = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
return /^decomposes as\n\n\$\$/.test(out) && out.includes("\\mathbf{6}");
return /^decomposes as\n\$\$/.test(out) && out.includes("\\mathbf{6}");
});
check("inline $$ after closing bracket", () => {
const out = normalizeMath("(octet)$$ \\mathbf{56}.$$");
return out.startsWith("(octet)\n\n$$");
return out.startsWith("(octet)\n$$");
});
check("inline $$ after closing brace (\\end{...}$$)", () => {
// A model that writes `\end{array}$$` or `\frac{a}{b}$$` on one line
// has the same micromark-fence problem as the comma case. The
// closing brace is the most common end-of-content marker in LaTeX
// math, so the repair-regex character class includes it.
// A display equation ending with }$$ must be extracted as a unit.
// The closing $$ must not be split off, or the equation body is emptied.
const out = normalizeMath("$$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$$");
return out.includes("\\end{pmatrix},\n\n$$") || out.includes("\\end{pmatrix}\n\n$$");
return out.includes("\\end{pmatrix},\n$$") || out.includes("\\end{pmatrix}\n$$");
});
check("inline $$ after comma on same line as content", () => {
// User-reported (2026-06-12, soft-pion chat): the model wrote the
@@ -261,25 +281,31 @@ check("inline $$ after comma on same line as content", () => {
// as math, which then fails to render with "Can't use function '$'
// in math mode" on the stray $ inside the equation body.
const out = normalizeMath("…D(q^2),$$\nwith $P=…$");
return out.includes("D(q^2),\n\n$$");
return out.includes("D(q^2),\n$$");
});
check("well-formed $$ already on own line is normalised consistently", () => {
// Whether the model writes `decomposes as$$\n\mathbf{6}.$$` or
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce the same
// remark-math-parseable form: opening $$ on its own line, body, blank
// line, closing $$ on its own line.
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce valid
// remark-math-parseable form: opening $$ on its own line, body, closing
// $$ on its own line.
const inline = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
const block = normalizeMath("decomposes as\n\n$$\n\\mathbf{6}.$$");
const expected = "decomposes as\n\n$$\n\\mathbf{6}.\n\n$$";
return inline === expected && block === expected;
const valid = (s: string) => /\n\$\$\n/.test(s) && /\n\$\$/.test(s) && s.includes("\\mathbf{6}");
return valid(inline) && valid(block);
});
check("\\[…\\] → $$…$$ still works (no spurious blank line)", () => {
return normalizeMath("\\[E=mc^2\\]") === "$$E=mc^2$$";
return normalizeMath("\\[E=mc^2\\]") === "$$\nE=mc^2\n$$";
});
check("digit before $$ is NOT a prose boundary (preserves c^2$$)", () => {
const out = normalizeMath("c^2$$ x $$");
return out === "c^2$$ x $$";
});
eq(normalizeMath("intro$$x+1"), "intro\n$$\nx+1", "orphan opening $$ is not duplicated");
eq(
normalizeMath("first$$a$$ middle $$b$$ end"),
"first\n$$\na\n$$\n middle \n$$\nb\n$$\n end",
"multiple display blocks on one line are all normalised",
);
console.log("\nnormalizeMath — non-math dollar filtering");
eq(normalizeMath("costs $1$ today"), "costs &#36;1&#36; today", "$1$ not math");
@@ -409,6 +435,10 @@ const e2e: Array<[string, string]> = [
["$\\frac{1}{\\sqrt{2}}\\|uud\\rangle$", "ket in fraction with \\|"],
["$\\|x\\|$", "norm \\|x\\| → double bar (regression)"],
["$\\langle\\psi\\|$", "bra closer \\| → single bar (regression)"],
["$S'$", "primed letter S'"],
["$f'(x)$", "primed function call"],
["$[56]$", "bracketed irrep label"],
["$[56,0^+]$", "bracketed irrep label with charge"],
];
for (const [src, label] of e2e) {
check(`${label}: ${src}`, () => katexOf(normalizeMath(src), false));
@@ -458,6 +488,19 @@ check("real inline math $x^2$ still renders as KaTeX", () => {
const html = renderHtml("the value $x^2$ here");
return html.includes("katex");
});
check("blockquote display math does not swallow following inline math", () => {
const html = renderHtml("> theorem\n> $$E=mc^2$$\n> after $x$");
return html.includes("katex-display")
&& !html.includes("katex-error")
&& html.includes(">x</mi>");
});
check("multi-line blockquote display math strips quote markers from the formula", () => {
const html = renderHtml("> theorem\n> $$\n> E=mc^2\n> $$\n> after $x$");
return html.includes("katex-display")
&& !html.includes("katex-error")
&& !html.includes("&gt; E")
&& html.includes(">x</mi>");
});
// ── Young diagram / tableau macros ─────────────────────────────────────────────
// `\yng` (ytableau) and `\young` (youngtab) are common in physics —
@@ -486,7 +529,7 @@ check("\\yng inside \\(...\\) does not get double-wrapped", () => {
});
check("\\yng inside \\[...\\] stays display math without triple dollars", () => {
const out = normalizeMath("\\[\\yng(2,1)\\]");
return out.startsWith("$$\\begin{array}{l}")
return out.startsWith("$$\n\\begin{array}{l}")
&& out.endsWith("$$")
&& !out.includes("$$$");
});
@@ -505,7 +548,7 @@ check("digit-starting inline math with \\young does not get nested wrappers", ()
});
check("display math ending in digit closes before following bare \\yng", () => {
const out = normalizeMath("$$x^2$$ \\yng(1)");
return out === "$$x^2$$ $\\begin{array}{l}\\square\\end{array}$";
return out === "$$\nx^2\n$$\n $\\begin{array}{l}\\square\\end{array}$";
});
check("bare \\yng after inline math is separated from adjacent dollars", () => {
const out = normalizeMath("$x$\\yng(1)");
@@ -6,6 +6,9 @@ import {
activeSessionAncestorKeys,
projectTreeTopicOpenRequest,
projectTreeShouldSuppressOpenForRename,
projectTreeReadActivityKey,
projectTreeTopicHasUnreadActivity,
projectTreeShouldRenderTopicActions,
} from "../components/ProjectTree";
import type { ProjectNode } from "../lib/types";
@@ -104,6 +107,58 @@ eq(
"regular project topic still opens by topic",
);
const completedTopic: ProjectNode = {
key: "topic_complete",
kind: "topic",
label: "Completed",
root: "/repo",
topicId: "topic-complete",
lastActivityAt: 2000,
};
const completedTopicKey = projectTreeReadActivityKey(completedTopic) ?? "";
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
true,
"completed inactive topic with newer activity shows unread attention",
);
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 2000 }, "project", "/repo", "other-topic"),
false,
"completed topic stops showing unread attention once opened at its latest activity",
);
eq(
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "topic-complete"),
false,
"active topic does not show unread attention",
);
eq(
projectTreeTopicHasUnreadActivity({ ...completedTopic, status: "streaming", running: true }, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
false,
"running topic keeps runtime status instead of completed-unread attention",
);
eq(
projectTreeShouldRenderTopicActions(false, true, false),
true,
"read workbench topic renders hover actions",
);
eq(
projectTreeShouldRenderTopicActions(false, true, true),
false,
"unread workbench topic omits hover actions from the keyboard tab order",
);
eq(
projectTreeShouldRenderTopicActions(true, true, false),
false,
"runtime session rows do not render topic hover actions",
);
eq(
projectTreeShouldSuppressOpenForRename(
{ rowKey: "topic-a", canRename: true },
@@ -84,5 +84,10 @@ console.log("\nstatus bar workspace");
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar omits non-configurable job indicators");
}
{
const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
ok(!defaultItems.includes("autoresearch"), "autoresearch is not a configurable status bar UI item");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -135,11 +135,13 @@ const historyB = deferred<HistoryMessage[]>();
const historyD = deferred<HistoryMessage[]>();
const contextDGate = deferred<ContextInfo>();
const setActiveBGate = deferred<void>();
const submitTabCGate = deferred<void>();
const historyCalls: string[] = [];
let contextDCalls = 0;
let holdNextContextForD = false;
let setActiveCalls = 0;
let newSessionCalls = 0;
let failSetActiveFor = "";
const runningTabs = new Set<string>();
const tabsById = new Map([tabA, tabB, tabC, tabD].map((tab) => [tab.id, tab]));
const eventHandlers: Array<(e: WireEvent) => void> = [];
@@ -197,10 +199,12 @@ window.go = {
SetActiveTab: async (tabID: string) => {
setActiveCalls += 1;
if (tabID === "tab-b") await setActiveBGate.promise;
if (tabID === failSetActiveFor) throw new Error("persist failed");
backendActiveId = tabID;
},
SubmitToTab: async (tabID: string) => {
runningTabs.add(tabID);
if (tabID === "tab-c") await submitTabCGate.promise;
},
SubmitDisplayToTab: async (tabID: string) => {
runningTabs.add(tabID);
@@ -275,6 +279,32 @@ eq(controller?.activeTabId, "tab-a", "late history for another tab does not chan
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "late history for another tab does not overwrite the active transcript");
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
const historyCallsBeforeFallbackSync = historyCalls.length;
backendActiveId = "tab-b";
await act(async () => {
await controller?.syncActiveTab(false);
await flushPromises();
});
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
await act(async () => {
await controller?.switchTab("tab-a", tabA);
await flushPromises();
});
await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
failSetActiveFor = "tab-b";
const historyCallsBeforeFailedSwitch = historyCalls.length;
await act(async () => {
await controller?.switchTab("tab-b", tabB);
await flushPromises();
});
eq(controller?.activeTabId, "tab-a", "failed backend tab switch reverts to the previous active tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "failed backend tab switch keeps the previous transcript visible");
eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
failSetActiveFor = "";
await act(async () => {
for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
@@ -293,10 +323,15 @@ eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transc
ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
let tabCSendResolved = false;
await act(async () => {
controller?.sendToTab("tab-c", "streaming C");
const sendPromise = controller?.sendToTab("tab-c", "streaming C");
sendPromise?.then(() => {
tabCSendResolved = true;
});
await flushPromises();
});
eq(tabCSendResolved, true, "sendToTab resolves after optimistic dispatch before backend submit completes");
await act(async () => {
await controller?.switchTab("tab-c", tabC);
await flushPromises();
@@ -304,6 +339,11 @@ await act(async () => {
eq(controller?.activeTabId, "tab-c", "switching to a cached running tab still updates the active tab");
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "cached running tab keeps its optimistic transcript");
ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
await act(async () => {
submitTabCGate.resolve();
await submitTabCGate.promise;
await flushPromises();
});
holdNextContextForD = true;
await act(async () => {
@@ -70,6 +70,9 @@ eq(
);
eq(finalDeclaration(".workspace-preview__body--code .workspace-note", "flex"), "0 0 auto", "code truncation note keeps its own row");
eq(finalDeclaration(".workspace-preview__body--code .code-block", "display"), "flex", "code block fills the preview viewport");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "display"), "flex", "code wrapper keeps the preview viewport height");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "flex"), "1 1 auto", "code wrapper participates in the preview flex height");
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "min-height"), "0", "code wrapper can shrink around the scrollable code viewport");
eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling");
eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane");
eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom");
+1 -16
View File
@@ -1,4 +1,4 @@
import { Minus, PanelLeft, PanelRight, Search, Square, X } from "lucide-react";
import { PanelLeft, PanelRight, Search } from "lucide-react";
import { TabBar } from "./TabBar";
import type { TabMeta } from "../lib/types";
import { useT } from "../lib/i18n";
@@ -58,14 +58,12 @@ export function AppChrome({
}: AppChromeProps) {
const t = useT();
const darwinChrome = platform === "darwin";
const showWindowsPreviewControls = browserPreviewChrome && platform === "windows";
const chromeClassName = [
"app-chrome",
"app-chrome--tabs",
darwinChrome ? "app-chrome--darwin-tabs" : "app-chrome--native-tabs",
workbenchChrome ? "app-chrome--workbench" : "",
!darwinChrome ? "app-chrome--identityless" : "",
showWindowsPreviewControls ? "app-chrome--preview-window-controls" : "",
`app-chrome--platform-${platform}`,
].filter(Boolean).join(" ");
const tabBar = (
@@ -194,19 +192,6 @@ export function AppChrome({
<PanelRight size={16} />
</button>
)}
{showWindowsPreviewControls && (
<div className="app-chrome__window-controls app-chrome__window-controls--windows" aria-hidden="true">
<span className="app-chrome__window-control app-chrome__window-control--minimize">
<Minus size={12} strokeWidth={1.9} />
</span>
<span className="app-chrome__window-control app-chrome__window-control--maximize">
<Square size={10} strokeWidth={1.8} />
</span>
<span className="app-chrome__window-control app-chrome__window-control--close">
<X size={12} strokeWidth={1.9} />
</span>
</div>
)}
</header>
);
}
@@ -48,8 +48,14 @@ export function ApprovalModal({
}) {
const t = useT();
const isPlanApproval = approval.tool === "exit_plan_mode";
const subject = approval.subject.trim();
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
const toolMeta = approval.reason?.trim() || subjectSummary || approval.tool;
const hasToolDetails = Boolean(approval.reason || subject);
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
const [revisionOpen, setRevisionOpen] = useState(false);
const [revisionText, setRevisionText] = useState("");
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
const cardRef = useRef<HTMLDivElement | null>(null);
const shelfRef = useRef<HTMLDivElement | null>(null);
@@ -59,7 +65,6 @@ export function ApprovalModal({
// the new one slides in. GSAP fromTo on the shelf wrapper avoids the
// jarring pop when the API cycles through 4+ pending approvals.
const closingRef = useRef(false);
const subject = approval.subject.trim();
const fileMenu = useFileReferenceMenu(revisionText, cwd);
const answerWithExit = (fn: () => void) => {
@@ -98,9 +103,10 @@ export function ApprovalModal({
cardRef.current?.focus();
setRevisionOpen(false);
setRevisionText("");
setDetailsOpen(showToolDetailsByDefault);
setSelectedIndex(isPlanApproval ? 1 : 0);
playAttentionChime();
}, [approval.id, isPlanApproval]);
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
const actionCount = isPlanApproval ? 3 : 4;
const selectedIndexRef = useRef(selectedIndex);
@@ -219,6 +225,7 @@ export function ApprovalModal({
return (
<div ref={shelfRef}>
<PromptShelf
className="prompt-shelf--compact prompt-shelf--plan-approval"
barRef={cardRef}
titleId="plan-approval-title"
title={t("approval.planReady")}
@@ -280,14 +287,23 @@ export function ApprovalModal({
return (
<div ref={shelfRef}>
<PromptShelf
className="prompt-shelf--compact prompt-shelf--tool-approval"
barRef={cardRef}
titleId="tool-approval-title"
title={t("approval.toolPending")}
badges={<PromptBadge>{approval.tool}</PromptBadge>}
meta={toolMeta}
headerActions={
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
Esc
</PromptHeaderAction>
<>
{hasToolDetails && (
<PromptHeaderAction onClick={() => setDetailsOpen((open) => !open)}>
{t(detailsOpen ? "approval.hideDetails" : "approval.details")}
</PromptHeaderAction>
)}
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
Esc
</PromptHeaderAction>
</>
}
actions={
<>
@@ -298,9 +314,13 @@ export function ApprovalModal({
</>
}
>
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
{detailsOpen && (
<div className="approval-details">
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
{subject && (
<pre className="approval-subject">{subject}</pre>
)}
</div>
)}
</PromptShelf>
</div>
+20 -1
View File
@@ -22,8 +22,10 @@ export function AskCard({
// Per-question state: selected option labels, and an optional typed answer.
const [sel, setSel] = useState<Record<string, string[]>>({});
const [custom, setCustom] = useState<Record<string, string>>({});
const [customOpen, setCustomOpen] = useState(false);
const [active, setActive] = useState(0);
const shelfRef = useRef<HTMLDivElement | null>(null);
const customInputRef = useRef<HTMLInputElement | null>(null);
const advanceTimer = useRef<number | null>(null);
const questions = ask.questions;
@@ -36,6 +38,7 @@ export function AskCard({
shelfRef.current?.focus();
setSel({});
setCustom({});
setCustomOpen(false);
setActive(0);
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
playAttentionChime();
@@ -66,7 +69,15 @@ export function AskCard({
(sel[question.id]?.length ?? 0) > 0 || (custom[question.id]?.trim() ?? "") !== "";
const currentAnswered = q ? answered(q) : false;
const showSubmitAction = q ? q.multi || Boolean(custom[q.id]?.trim()) : false;
const showSubmitAction = q ? q.multi || customOpen || Boolean(custom[q.id]?.trim()) : false;
useEffect(() => {
setCustomOpen(false);
}, [active]);
useEffect(() => {
if (customOpen) customInputRef.current?.focus();
}, [customOpen]);
const finishOrAdvance = (nextSel = sel, nextCustom = custom) => {
if (advanceTimer.current != null) {
@@ -89,6 +100,7 @@ export function AskCard({
setCustom(nextCustom);
setSel(nextSel);
setCustomOpen(false);
if (!question.multi) {
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
@@ -148,6 +160,7 @@ export function AskCard({
return (
<PromptShelf
className="prompt-shelf--compact prompt-shelf--ask"
barRef={shelfRef}
titleId="ask-shelf-title"
title={t("ask.title")}
@@ -165,6 +178,9 @@ export function AskCard({
headerActions={
<>
<PromptHeaderAction onClick={onDismiss}>{t("ask.justChat")}</PromptHeaderAction>
{!customOpen && (
<PromptHeaderAction onClick={() => setCustomOpen(true)}>{t("ask.customAnswer")}</PromptHeaderAction>
)}
<PromptHeaderAction onClick={onStop} ariaLabel={t("composer.stopShort")}>Esc</PromptHeaderAction>
</>
}
@@ -213,8 +229,10 @@ export function AskCard({
)
}
>
{customOpen && (
<div className="ask-shelf__custom-row">
<input
ref={customInputRef}
className="ask-shelf__custom"
placeholder={t("ask.customPlaceholder")}
value={custom[q.id] ?? ""}
@@ -225,6 +243,7 @@ export function AskCard({
}}
/>
</div>
)}
</PromptShelf>
);
}
+186 -18
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { ArrowUp, Check, ChevronsUpDown, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
import { ArrowUp, Check, ChevronDown, ChevronUp, ChevronsUpDown, CornerDownRight, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
import { asArray } from "../lib/array";
import { filterAtMatches } from "../lib/atMatches";
import { DedupIndex, sha256 } from "../lib/attachDedup";
@@ -63,6 +63,12 @@ type PastedBlock = {
text: string;
};
type PendingGuidance = {
id: number;
text: string;
submitText: string;
};
type ComposerDraft = {
text: string;
attachments: Attachment[];
@@ -163,6 +169,13 @@ function emptyComposerDraft(): ComposerDraft {
};
}
function guidanceTextMatches(queued: string, consumed: string): boolean {
const left = queued.trim();
const right = consumed.trim();
if (!left || !right) return false;
return left === right || left.includes(right) || right.includes(left);
}
function cloneComposerDraft(draft: ComposerDraft): ComposerDraft {
return {
text: draft.text,
@@ -431,6 +444,9 @@ export function Composer({
retry,
transientDismissSignal,
sessionKey,
guidanceConsumedKey,
guidanceConsumedText,
guidanceQueuePreviewItems,
}: {
running: boolean;
collaborationMode: CollaborationMode;
@@ -470,6 +486,9 @@ export function Composer({
retry?: { attempt: number; max: number };
transientDismissSignal?: number;
sessionKey?: string;
guidanceConsumedKey?: string;
guidanceConsumedText?: string;
guidanceQueuePreviewItems?: readonly string[];
}) {
const { t, locale } = useI18n();
const { showToast } = useToast();
@@ -498,6 +517,10 @@ export function Composer({
const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
const [pastChatQuery, setPastChatQuery] = useState("");
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
const [pendingGuidance, setPendingGuidance] = useState<PendingGuidance[]>([]);
const [guidanceExpanded, setGuidanceExpanded] = useState(false);
const [guidanceSendingId, setGuidanceSendingId] = useState<number | null>(null);
const nextGuidanceId = useRef(1);
const [loadingPastChats, setLoadingPastChats] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [composerPrompt, setComposerPrompt] = useState<string | null>(null);
@@ -524,6 +547,8 @@ export function Composer({
const lastSelectionRef = useRef({ start: 0, end: 0 });
const consumedInsertIdRef = useRef(0);
const lastTransientDismissSignal = useRef(transientDismissSignal);
const lastGuidanceConsumedKey = useRef(guidanceConsumedKey);
const selfDispatchedGuidanceRef = useRef<string[]>([]);
const submittingRef = useRef(false);
const nativeClipboardPasteTimerRef = useRef<number | null>(null);
// Snapshot of the current cwd so async callbacks (openPastChats) can detect
@@ -533,6 +558,7 @@ export function Composer({
const attachmentDedupRef = useRef(new DedupIndex());
const attachmentDedupKeysRef = useRef<Record<string, AttachmentDedupKey>>({});
const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
const guidanceQueuePreviewKey = (guidanceQueuePreviewItems ?? []).map((item) => item.trim()).filter(Boolean).join("\n");
const draftsBySessionRef = useRef<Record<string, ComposerDraft>>({});
const activeDraftKeyRef = useRef(draftKey);
const textRef = useRef(text);
@@ -609,14 +635,37 @@ export function Composer({
useEffect(() => () => clearNativeClipboardPasteTimer(), []);
useEffect(() => {
if (wasRunning.current && !running && text.trim() === "") {
pastedBlocksRef.current = [];
setPastedBlocks([]);
setOpenPastedLabels([]);
if (wasRunning.current && !running) {
setPendingGuidance([]);
setGuidanceExpanded(false);
if (text.trim() === "") {
pastedBlocksRef.current = [];
setPastedBlocks([]);
setOpenPastedLabels([]);
}
}
wasRunning.current = running;
}, [running, text]);
useEffect(() => {
setPendingGuidance([]);
setGuidanceExpanded(false);
}, [draftKey]);
useEffect(() => {
if (!running || !guidanceQueuePreviewKey) return;
setGuidanceExpanded(false);
setPendingGuidance(
guidanceQueuePreviewKey
.split("\n")
.map((text) => ({ id: nextGuidanceId.current++, text, submitText: text })),
);
}, [guidanceQueuePreviewKey, running]);
useEffect(() => {
if (guidanceExpanded && pendingGuidance.length <= 2) setGuidanceExpanded(false);
}, [guidanceExpanded, pendingGuidance.length]);
// --- slash commands (whole-input "/token") ---
const [commands, setCommands] = useState<CommandInfo[]>([]);
useEffect(() => {
@@ -817,6 +866,28 @@ export function Composer({
setDismissed(true);
}, [transientDismissSignal]);
const takeSelfDispatchedGuidance = useCallback((text: string): boolean => {
const idx = selfDispatchedGuidanceRef.current.findIndex((queued) => guidanceTextMatches(queued, text));
if (idx < 0) return false;
selfDispatchedGuidanceRef.current.splice(idx, 1);
return true;
}, []);
useEffect(() => {
if (!guidanceConsumedKey || guidanceConsumedKey === lastGuidanceConsumedKey.current) return;
lastGuidanceConsumedKey.current = guidanceConsumedKey;
const consumed = (guidanceConsumedText ?? "").trim();
if (consumed && takeSelfDispatchedGuidance(consumed)) return;
setPendingGuidance((items) => {
if (items.length === 0) return items;
const idx = consumed
? items.findIndex((item) => guidanceTextMatches(item.submitText, consumed) || guidanceTextMatches(item.text, consumed))
: -1;
const removeAt = idx >= 0 ? idx : 0;
return items.filter((_, index) => index !== removeAt);
});
}, [guidanceConsumedKey, guidanceConsumedText, takeSelfDispatchedGuidance]);
// When the @ trigger disappears (user deleted the @), close the past:chats
// sub-menu and reset related state. Without this, showPastChats can outlive
// the @ token and leave the session list visible with no way to dismiss it.
@@ -1111,7 +1182,7 @@ export function Composer({
}, [showToast, t]);
const submit = async () => {
if (disabled || submitDisabled || readOnly || submittingRef.current) return;
if (disabled || (!running && submitDisabled) || readOnly || submittingRef.current) return;
const submitDraftKey = activeDraftKeyRef.current;
const currentText = textRef.current;
const trimmedText = currentText.trim();
@@ -1150,6 +1221,16 @@ export function Composer({
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs);
const baseSubmitText = [expandPastedBlocks(trimmedText), refs].filter(Boolean).join(trimmedText && refs ? " " : "");
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
if (running) {
const guidanceText = displayText.trim();
const guidanceSubmitText = submitText.trim();
if (guidanceText) {
const id = nextGuidanceId.current++;
setPendingGuidance((items) => [...items, { id, text: guidanceText, submitText: guidanceSubmitText || guidanceText }]);
}
clearSubmittedDraft(submitDraftKey);
return;
}
await onSend(displayText, submitText);
clearSubmittedDraft(submitDraftKey);
} catch (error) {
@@ -1160,6 +1241,27 @@ export function Composer({
}
};
const sendQueuedGuidance = async (item: PendingGuidance) => {
if (disabled || readOnly || guidanceSendingId !== null) return;
const displayText = item.text.trim();
const submitText = item.submitText.trim() || displayText;
if (!displayText || !submitText) return;
selfDispatchedGuidanceRef.current.push(submitText);
setGuidanceSendingId(item.id);
try {
await onSend(displayText, submitText);
setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id));
window.setTimeout(() => {
takeSelfDispatchedGuidance(submitText);
}, 5000);
} catch (error) {
takeSelfDispatchedGuidance(submitText);
showToast(error instanceof Error ? error.message : String(error), "warn");
} finally {
setGuidanceSendingId((current) => (current === item.id ? null : current));
}
};
const readFileAsDataURL = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -1929,6 +2031,21 @@ export function Composer({
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
})()
: null;
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0;
const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
const submitTooltip = running ? t("composer.queueGuidance") : t("composer.send");
const composerPlaceholder = readOnly
? t("composer.readOnlyChannel")
: disabled
? t("common.loading")
: running
? t("composer.steerPlaceholder")
: goalModeOn && !activeGoal
? t("composer.goalInputPlaceholder")
: t("composer.placeholder");
const hiddenGuidanceCount = Math.max(0, pendingGuidance.length - 2);
const visibleGuidance = guidanceExpanded ? pendingGuidance : pendingGuidance.slice(0, 2);
const showGuidanceExpander = pendingGuidance.length > 2;
const composerMetaClass = [
"composer-meta",
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
@@ -2185,6 +2302,58 @@ export function Composer({
</div>
</div>
)}
{pendingGuidance.length > 0 && (
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
<div className="composer-guidance-head">
<span className="composer-guidance-head__label">
<CornerDownRight size={14} />
<span>{t("composer.guidanceCount", { n: pendingGuidance.length })}</span>
</span>
</div>
<div className="composer-guidance-list">
{visibleGuidance.map((item) => (
<div className="composer-guidance-item" key={item.id}>
<CornerDownRight size={14} className="composer-guidance-item__icon" />
<span className="composer-guidance-item__text">{item.text}</span>
<Tooltip label={t("composer.guidanceSend")}>
<button
className="composer-guidance-item__guide"
type="button"
aria-label={t("composer.guidanceSend")}
disabled={!running || disabled || readOnly || guidanceSendingId !== null}
onClick={() => void sendQueuedGuidance(item)}
>
<CornerDownRight size={13} />
<span>{t("composer.guidanceMode")}</span>
</button>
</Tooltip>
<Tooltip label={t("composer.guidanceDismiss")}>
<button
className="composer-guidance-item__action"
type="button"
aria-label={t("composer.guidanceDismiss")}
disabled={guidanceSendingId === item.id}
onClick={() => setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id))}
>
<Trash2 size={14} />
</button>
</Tooltip>
</div>
))}
{showGuidanceExpander && (
<button
className="composer-guidance-more"
type="button"
aria-expanded={guidanceExpanded}
onClick={() => setGuidanceExpanded((value) => !value)}
>
{guidanceExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
<span>{guidanceExpanded ? t("composer.guidanceCollapse") : t("composer.guidanceRemaining", { n: hiddenGuidanceCount })}</span>
</button>
)}
</div>
</div>
)}
{(attachments.length > 0 || workspaceRefs.length > 0 || sessionRefs.length > 0) && (
<div className="composer-context" aria-label={t("composer.contextItems")}>
{sortComposerAttachments(attachments).map((a) => {
@@ -2324,7 +2493,7 @@ export function Composer({
lastCompositionEndAt.current = Date.now();
}}
style={textareaStyle}
placeholder={readOnly ? t("composer.readOnlyChannel") : disabled ? t("common.loading") : goalModeOn && !activeGoal ? t("composer.goalInputPlaceholder") : t("composer.placeholder")}
placeholder={composerPlaceholder}
rows={1}
disabled={disabled || readOnly}
/>
@@ -2333,17 +2502,16 @@ export function Composer({
{composerPrompt}
</span>
)}
{!running && (
<Tooltip label={t("composer.send")}>
<button
className="composer__btn composer__btn--send"
onClick={submit}
disabled={submitting || pendingPaste > 0 || ((!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) && !(goalModeOn && !activeGoal)) || disabled || submitDisabled || readOnly}
>
<ArrowUp size={16} />
</button>
</Tooltip>
)}
<Tooltip label={submitTooltip}>
<button
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
onClick={submit}
disabled={submitBlocked}
aria-label={submitTooltip}
>
<ArrowUp size={16} />
</button>
</Tooltip>
</div>
<div className={composerMetaClass}>
<div className="composer-meta__params">
+225 -204
View File
@@ -1,6 +1,6 @@
// ContextPanel shows the active tab's context gauge and token usage.
// All visible text is routed through the i18n dictionary.
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useI18n, type Locale, type Translator } from "../lib/i18n";
@@ -23,9 +23,9 @@ interface ContextPanelProps {
refreshKey?: number;
}
function fmtTokens(n: number): string {
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
function fmtFullTokens(n: number): string {
if (n <= 0) return "0";
return String(Math.round(n));
}
function fmtDuration(ms: number, t: Translator): string {
@@ -62,11 +62,6 @@ export function formatMetricTokens(tokens: number | undefined, locale: Locale |
return { display: exact, exact };
}
function fmtTurns(turns: number | undefined, t: Translator): string {
if (typeof turns !== "number" || turns < 0) return "-";
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
}
function fmtUsageCacheRate(usage?: WireUsage): string {
if (!usage) return "-";
const denom = usage.cacheHitTokens + usage.cacheMissTokens;
@@ -80,10 +75,28 @@ export function formatCacheHitRate(hitTokens: number, missTokens: number): strin
return `${((hitTokens / denom) * 100).toFixed(2)}%`;
}
interface HealthResult {
type MetricTone = "accent" | "good" | "notice" | "warn";
type UsageAnalysisView = "source" | "type";
export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
const denom = hitTokens + missTokens;
if (denom <= 0) return undefined;
const pct = (hitTokens / denom) * 100;
if (pct >= 80) return "good";
if (pct >= 60) return "notice";
return "warn";
}
function formatSharePercent(value: number, total: number): string {
if (total <= 0 || value <= 0) return "-";
const pct = (value / total) * 100;
if (pct > 0 && pct < 1) return "<1%";
return `${Math.round(pct)}%`;
}
interface ContextWindowStatus {
tone: "good" | "notice" | "warn";
shortKey: DictKey;
vars: Record<string, string | number>;
key: DictKey;
}
export function contextCostDisplay({
@@ -171,33 +184,11 @@ export function contextBreakdown(
};
}
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
if (usagePct >= 85) {
return {
tone: "warn",
shortKey: "context.healthNearLimitShort",
vars: { pct: usagePct },
};
}
if (readCount >= 8) {
return {
tone: "notice",
shortKey: "context.healthManyFilesShort",
vars: { count: readCount },
};
}
if (cachePct > 0 && cachePct < 50) {
return {
tone: "notice",
shortKey: "context.healthLowCacheShort",
vars: { pct: cachePct },
};
}
return {
tone: "good",
shortKey: "context.healthGoodShort",
vars: {},
};
export function contextWindowStatus(usagePct: number, compactPct: number): ContextWindowStatus {
if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" };
if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" };
if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" };
return { tone: "good", key: "context.windowStatusHealthy" };
}
const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
@@ -286,7 +277,6 @@ export function ContextPanel({
sessionTokens,
sessionCost,
sessionCurrency,
sessionTurns,
turnTokens,
turnCost,
balance,
@@ -295,6 +285,7 @@ export function ContextPanel({
}: ContextPanelProps) {
const { locale, t } = useI18n();
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
const refreshSeq = useRef(0);
const refresh = useCallback(async () => {
@@ -302,17 +293,14 @@ export function ContextPanel({
const seq = ++refreshSeq.current;
try {
const next = await app.ContextPanel(tabId);
if (refreshSeq.current === seq) setInfo(next);
if (refreshSeq.current === seq) {
setInfo(next);
}
} catch {
/* bridge unavailable */
}
}, [tabId]);
useEffect(() => {
const id = window.setInterval(() => void refresh(), 2000);
return () => window.clearInterval(id);
}, [refresh]);
useEffect(() => {
refreshSeq.current += 1;
setInfo(null);
@@ -344,32 +332,25 @@ export function ContextPanel({
? usage.totalTokens
: promptTokens + completionTokens;
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
const cacheHitTokens = hasPanelUsage ? info?.cacheHitTokens ?? 0 : usage?.cacheHitTokens ?? 0;
const cacheMissTokens = hasPanelUsage ? info?.cacheMissTokens ?? 0 : usage?.cacheMissTokens ?? 0;
// Session-cumulative values for the metrics cards (方案A: 纯前端改数据源)
// Session-cumulative values for the top summary.
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
const sessionCompletion = info?.sessionCompletionTokens ?? 0;
const sessionCacheHitMetric = formatMetricTokens(sessionCacheHit, locale);
const sessionCacheMissMetric = formatMetricTokens(sessionCacheMiss, locale);
const sessionCompletionMetric = formatMetricTokens(sessionCompletion, locale);
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
const showSourceUsageRows = sourceUsageRows.length > 0;
const sourceTotalTokens = sourceUsageRows.reduce((sum, row) => sum + sourceTokenTotal(row), 0);
const visibleSourceRows = sourceUsageRows.slice(0, 3);
const hiddenSourceRows = sourceUsageRows.slice(3);
const readFiles = asArray(info?.readFiles);
const changedFiles = asArray(info?.changedFiles);
const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0;
const compactPct = context?.compactRatio ? Math.round(context.compactRatio * 100) : 0;
const cacheDenom = cacheHitTokens + cacheMissTokens;
const cachePct = cacheDenom > 0 ? (cacheHitTokens / cacheDenom) * 100 : 0;
const cachePctDisplay = formatCacheHitRate(cacheHitTokens, cacheMissTokens);
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
const compactPct = Math.round(compactRatio * 100);
const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
const donutStyle = {
background: `conic-gradient(#13a7a5 0 ${breakdown.promptPct}%, #2f6df6 ${breakdown.promptPct}% ${breakdown.completionPct}%, #f97316 ${breakdown.completionPct}% ${breakdown.reasoningPct}%, var(--border) ${breakdown.reasoningPct}% ${breakdown.otherPct}%, var(--border-soft) ${breakdown.otherPct}% 100%)`,
};
const eventTimes = [
...readFiles.map((file) => file.time),
...changedFiles.map((file) => file.latestTime ?? 0),
@@ -378,100 +359,165 @@ export function ContextPanel({
const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? info.elapsedMs : derivedElapsed;
const derivedRequestCount = Math.max(readFiles.length + changedFiles.length, 0);
const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
const health = contextHealth(usagePct, Math.round(cachePct), readFiles.length);
const windowStatus = contextWindowStatus(usagePct, compactPct);
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
const turnCostLabel = formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" });
const sessionCostLabel = formatMoneyLocalized(sessionCost, sessionCurrency, { locale, empty: "dash" });
const sessionCostLabel = formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" });
const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
const usedLabel = fmtFullTokens(usedTokens);
const windowLabel = fmtFullTokens(windowTokens);
const compactRemainingLabel = tokensUntilCompact > 0 ? fmtFullTokens(tokensUntilCompact) : "0";
const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
const tokenTypeRows = [
{ key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
{ key: "completion", label: t("context.completion"), value: breakdown.completionTokens },
{ key: "reasoning", label: t("context.reasoning"), value: breakdown.reasoningTokens },
{ key: "other", label: t("context.other"), value: breakdown.otherTokens },
];
const tokenCompositionTotal = tokenTypeRows.reduce((sum, row) => sum + row.value, 0);
const renderSourceRow = (row: ContextSourceRow) => {
const inputMetric = formatMetricTokens(row.promptTokens, locale);
const outputMetric = formatMetricTokens(row.completionTokens, locale);
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
return (
<div className="context-panel__source-row" key={row.source}>
<div className="context-panel__source-head">
<span>
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
{sourceLabel(row.label, t)}
</span>
<em>{t("context.sourceRequests", { count: row.requests })}</em>
</div>
<div className="context-panel__source-summary">
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
</div>
<details className="context-panel__source-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__source-details-body">
<SourceSplitBar
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
segments={[
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
]}
/>
{cacheReported ? (
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
]}
compact
/>
) : (
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
)}
<div className="context-panel__source-metrics">
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
</div>
</div>
</details>
</div>
);
};
return (
<div className="context-panel">
<div className="context-panel__body">
<section className="context-panel__overview">
<section className="context-panel__usage">
<SectionHeading title={t("context.windowTitle")} meta={t("context.windowSubtitle")} />
<div className="context-panel__usage-visual">
<div className="context-panel__donut" style={donutStyle}>
<div className="context-panel__donut-core">
<strong>{fmtTokens(usedTokens)}</strong>
<span>/ {fmtTokens(windowTokens)} tokens</span>
<SectionHeading title={t("context.windowTitle")} />
<div className={`context-panel__capacity-card context-panel__capacity-card--${windowStatus.tone}`}>
<div className="context-panel__capacity-top">
<span className="context-panel__capacity-status">{t(windowStatus.key)}</span>
<strong>{usedLabel}/{windowLabel}</strong>
</div>
<div className="context-panel__usage-progress context-panel__capacity-meter" aria-label={`${t(windowStatus.key)}. ${usageSummary}. ${compactSummary}`}>
<div className="context-panel__capacity-scale" aria-hidden="true">
<span className="context-panel__capacity-pin context-panel__capacity-pin--used" style={{ left: `${usageMarkerPct}%` }}>{usagePct}%</span>
<span className="context-panel__capacity-pin context-panel__capacity-pin--compact" style={{ left: `${compactLabelPct}%` }}>{compactPct}%</span>
</div>
<div className="context-panel__progress-track" aria-hidden="true">
<span className="context-panel__progress-segment context-panel__progress-segment--prompt" style={{ width: `${breakdown.promptPct}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--completion" style={{ width: `${Math.max(0, breakdown.completionPct - breakdown.promptPct)}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--reasoning" style={{ width: `${Math.max(0, breakdown.reasoningPct - breakdown.completionPct)}%` }} />
<span className="context-panel__progress-segment context-panel__progress-segment--other" style={{ width: `${Math.max(0, breakdown.otherPct - breakdown.reasoningPct)}%` }} />
<span className="context-panel__compact-marker" style={{ left: `${compactMarkerPct}%` }} />
</div>
</div>
<div className="context-panel__percent">{usagePct}%</div>
</div>
<div className="context-panel__usage-progress" aria-label={t("context.windowSubtitle")}>
<div className="context-panel__progress-head">
<strong>{fmtTokens(usedTokens)} / {fmtTokens(windowTokens)}</strong>
<span>{usagePct}%</span>
</div>
<div className="context-panel__progress-track" aria-hidden="true">
<span className="context-panel__progress-fill" style={{ width: `${usagePct}%` }} />
<div className="context-panel__capacity-foot">
<span>{t("context.windowUsedLabel")}</span>
<span className="context-panel__capacity-remaining">
<span>{t("context.windowCompactDistance")}</span>
<strong>{compactRemainingLabel}</strong>
</span>
</div>
</div>
<div className="context-panel__summary-rows">
<MiniStat label={t("status.compactLabel")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} />
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
<MiniStat label={t("status.sessionTurnsLabel")} value={fmtTurns(sessionTurns, t)} />
</div>
<div className="context-panel__breakdown">
<TokenLegend label={t("context.prompt")} value={breakdown.promptTokens} color="prompt" />
<TokenLegend label={t("context.completion")} value={breakdown.completionTokens} color="completion" />
<TokenLegend label={t("context.reasoning")} value={breakdown.reasoningTokens} color="reasoning" />
<TokenLegend label={t("context.other")} value={breakdown.otherTokens} color="other" />
<div className="context-panel__total">
<span>{t("context.total")}</span>
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
</section>
<section className="context-panel__section context-panel__session-section">
<SectionHeading title={t("context.sessionMetrics")} />
<div className="context-panel__session-metrics">
<div className="context-panel__summary-rows">
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} tone={cacheHitTone(sessionCacheHit, sessionCacheMiss)} />
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
<MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
<MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MiniStat label={t("context.sessionTokensShort")} value={totalTokensMetric.display} title={totalTokensTitle} wide />
</div>
</div>
</section>
<section className="context-panel__creation-grid" aria-label={t("context.overview")}>
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
<MetricCard label={t("status.turnTokensLabel")} value={fmtOptionalTokens(turnTokens)} />
<MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} />
<MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
</section>
<section className="context-panel__section">
<SectionHeading title={t("context.runtimeMetrics")} />
<div className="context-panel__runtime-card">
<div className="context-panel__runtime-summary">
<RuntimeMetric label={t("context.time")} value={fmtDuration(elapsed, t)} />
<RuntimeMetric label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
<RuntimeMetric label={t("context.outputTokens")} value={sessionCompletionMetric.display} title={sessionCompletionMetric.exact} />
<RuntimeMetric label={t("context.sessionTokens")} value={totalTokensMetric.display} title={totalTokensMetric.exact} />
</div>
<div className="context-panel__runtime-cache">
<div className="context-panel__runtime-cache-head">
<span>{t("context.inputCache")}</span>
<strong>{formatCacheHitRate(sessionCacheHit, sessionCacheMiss)}</strong>
<section className="context-panel__section context-panel__analysis">
<SectionHeading title={t("context.usageAnalysis")}>
{showSourceUsageRows && (
<div className="context-panel__view-switch" role="tablist" aria-label={t("context.usageAnalysisView")}>
<button
type="button"
className={`context-panel__view-tab${activeAnalysisView === "source" ? " context-panel__view-tab--active" : ""}`}
role="tab"
aria-selected={activeAnalysisView === "source"}
onClick={() => setAnalysisView("source")}
>
{t("context.usageAnalysisSource")}
</button>
<button
type="button"
className={`context-panel__view-tab${activeAnalysisView === "type" ? " context-panel__view-tab--active" : ""}`}
role="tab"
aria-selected={activeAnalysisView === "type"}
onClick={() => setAnalysisView("type")}
>
{t("context.usageAnalysisType")}
</button>
</div>
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: sessionCacheHit, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: sessionCacheMiss, tone: "miss" },
]}
/>
<div className="context-panel__runtime-cache-values">
<SourceMetric label={t("context.sourceCacheHit")} value={sessionCacheHitMetric.display} title={sessionCacheHitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={sessionCacheMissMetric.display} title={sessionCacheMissMetric.exact} />
</div>
</div>
</div>
</section>
<section className="context-panel__section">
<SectionHeading title={t("context.costMetrics")} />
<div className="context-panel__stats">
<MetricCard label={t("context.cacheHit")} value={cachePctDisplay} tone="accent" />
<MetricCard label={t("context.sessionCost")} value={formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" })} />
</div>
{showSourceUsageRows && (
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")}>
)}
</SectionHeading>
{activeAnalysisView === "source" ? (
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")} role="tabpanel">
<div className="context-panel__source-overview">
<div className="context-panel__source-overview-head">
<strong>{t("context.sourceBreakdown")}</strong>
<span>{t("context.sourceShareTokens")}</span>
<strong>{t("context.sourceShareTitle")}</strong>
</div>
<div className="context-panel__source-sharebar" aria-hidden="true">
{sourceUsageRows.map((row) => {
@@ -498,70 +544,51 @@ export function ContextPanel({
})}
</div>
</div>
{sourceUsageRows.map((row) => {
const inputMetric = formatMetricTokens(row.promptTokens, locale);
const outputMetric = formatMetricTokens(row.completionTokens, locale);
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
return (
<div className="context-panel__source-row" key={row.source}>
<div className="context-panel__source-head">
<span>
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
{sourceLabel(row.label, t)}
</span>
<em>{t("context.sourceRequests", { count: row.requests })}</em>
</div>
<div className="context-panel__source-summary">
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
</div>
<SourceSplitBar
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
segments={[
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
]}
/>
{cacheReported ? (
<SourceSplitBar
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
segments={[
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
]}
compact
/>
) : (
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
)}
<details className="context-panel__source-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__source-metrics">
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
</div>
</details>
{visibleSourceRows.map(renderSourceRow)}
{hiddenSourceRows.length > 0 && (
<details className="context-panel__source-more">
<summary>{t("context.moreSources", { count: hiddenSourceRows.length })}</summary>
<div className="context-panel__source-more-list">
{hiddenSourceRows.map(renderSourceRow)}
</div>
);
})}
</details>
)}
</div>
) : (
<div className="context-panel__type-panel" aria-label={t("context.tokenBreakdown")} role="tabpanel">
<div className="context-panel__type-overview">
<div className="context-panel__type-overview-head">
<strong>{t("context.tokenBreakdown")}</strong>
</div>
<div className="context-panel__type-sharebar" aria-hidden="true">
{tokenTypeRows.map((row) => row.value > 0 ? (
<span
className={`context-panel__type-share context-panel__type-share--${row.key}`}
key={row.key}
style={{ width: `${(row.value / Math.max(1, tokenCompositionTotal)) * 100}%` }}
/>
) : null)}
</div>
<div className="context-panel__type-legend">
{tokenTypeRows.map((row) => (
<span key={row.key}>
<i className={`context-panel__type-dot context-panel__type-dot--${row.key}`} aria-hidden="true" />
{row.label} {formatSharePercent(row.value, tokenCompositionTotal)}
</span>
))}
</div>
</div>
<details className="context-panel__breakdown-details">
<summary>{t("context.sourceDetails")}</summary>
<div className="context-panel__breakdown">
{tokenTypeRows.map((row) => (
<TokenLegend key={row.key} label={row.label} value={row.value} color={row.key} />
))}
</div>
</details>
</div>
)}
</section>
<section className="context-panel__section context-panel__section--status">
<SectionHeading title={t("context.sessionStatus")} />
<div className="context-panel__stats">
<MetricCard label={t("context.health")} value={t(health.shortKey, health.vars)} tone={health.tone} />
<MetricCard label={t("context.compaction")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
</div>
</section>
</section>
</div>
@@ -569,11 +596,12 @@ export function ContextPanel({
);
}
function SectionHeading({ title, meta }: { title: string; meta?: string }) {
function SectionHeading({ title, meta, children }: { title: string; meta?: string; children?: ReactNode }) {
return (
<header className="context-panel__section-head">
<h3>{title}</h3>
{meta && <span>{meta}</span>}
{children}
</header>
);
}
@@ -588,11 +616,14 @@ function TokenLegend({ label, value, color }: { label: string; value: number; co
);
}
function MiniStat({ label, value }: { label: string; value: string }) {
function MiniStat({ label, value, title, tone, wide }: { label: string; value: string; title?: string; tone?: MetricTone; wide?: boolean }) {
const toneClass = tone ? ` context-panel__mini-stat--${tone}` : "";
const wideClass = wide ? " context-panel__mini-stat--wide" : "";
const exactTitle = title && title !== value ? title : undefined;
return (
<div className="context-panel__mini-stat">
<div className={`context-panel__mini-stat${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
<span>{label}</span>
<strong>{value}</strong>
<strong title={exactTitle}>{value}</strong>
</div>
);
}
@@ -609,16 +640,6 @@ function MetricCard({ label, value, valueTitle, tone, wide }: { label: string; v
);
}
function RuntimeMetric({ label, value, title }: { label: string; value: string; title?: string }) {
const exactTitle = title && title !== value ? title : undefined;
return (
<div className="context-panel__runtime-metric" aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
<span>{label}</span>
<strong title={exactTitle}>{value}</strong>
</div>
);
}
function SourceMetric({ label, value, title }: { label: string; value: string; title?: string }) {
const exactTitle = title && title !== value ? title : undefined;
return (
+9 -5
View File
@@ -483,9 +483,9 @@ export function TurnActions({
}
};
const actionMeta = (scope: MessageActionScope): string => {
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
const total = checkpoint.files.length;
const turnCount = checkpoint.turnFileCount ?? 0;
const total = checkpoint?.fileCount ?? checkpoint?.files?.length ?? 0;
if ((scope === "code" || scope === "both") && total > 0) {
const turnCount = checkpoint?.turnFileCount ?? 0;
if (turnCount > 0 && turnCount < total) {
return `${t("rewind.filesChanged", { count: total })} (${t("rewind.turnFiles", { count: turnCount })})`;
}
@@ -496,12 +496,16 @@ export function TurnActions({
const actionTooltipLabel = (scope: MessageActionScope) => {
const reason = actionDisabledReason(scope);
if (reason) return <span>{reason}</span>;
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
const files = checkpoint?.files ?? [];
const total = checkpoint?.fileCount ?? files.length;
if ((scope === "code" || scope === "both") && total > 0) {
const hidden = Math.max(0, total - files.length);
return (
<div className="rewind__files-tooltip">
{checkpoint.files.map((file) => (
{files.map((file) => (
<div key={file}>{file.split(/[/\\]/).pop() || file}</div>
))}
{hidden > 0 && <div>+{hidden}</div>}
</div>
);
}
+129 -2
View File
@@ -159,6 +159,43 @@ function topicStatusLabel(node: ProjectNode, t: Translator): string {
return status ? t(topicStatusLabels[status]) : "";
}
function topicActivityAt(node: ProjectNode): number {
return node.lastActivityAt || node.createdAt || 0;
}
export function projectTreeReadActivityKey(node: ProjectNode): string | null {
const request = projectTreeTopicOpenRequest(node);
if (!request?.topicId) return null;
return [
request.scope,
request.workspaceRoot,
request.topicId,
request.sessionPath ?? "",
].join("\u001f");
}
type ProjectTreeReadActivity = Record<string, number>;
export function projectTreeTopicHasUnreadActivity(
node: ProjectNode,
readActivity: ProjectTreeReadActivity,
activeScope?: string,
activeWorkspaceRoot?: string,
activeTopicId?: string,
activeSessionPath?: string,
): boolean {
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
if (topicStatus(node) !== "") return false;
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
}
export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, compactTopics: boolean, unread: boolean): boolean {
return !isSessionNode && compactTopics && !unread;
}
function topicActivityLabel(ms: number, t: Translator, compact = false): string {
if (ms <= 0) return "";
const delta = Date.now() - ms;
@@ -226,6 +263,31 @@ type WorkbenchTreeSections = {
const GLOBAL_PROJECT_ORDER_KEY = "__global__";
const WORKBENCH_ORGANIZE_KEY = "projectTree:workbenchOrganize";
const WORKBENCH_SORT_KEY = "projectTree:workbenchSort";
const READ_ACTIVITY_KEY = "projectTree:readActivity";
const READ_ACTIVITY_INIT_KEY = "projectTree:readActivityInitialized";
function loadReadActivity(): ProjectTreeReadActivity {
try {
const raw = localStorage.getItem(READ_ACTIVITY_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, unknown>;
const out: ProjectTreeReadActivity = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
}
return out;
} catch {
return {};
}
}
function saveReadActivity(readActivity: ProjectTreeReadActivity) {
try {
localStorage.setItem(READ_ACTIVITY_KEY, JSON.stringify(readActivity));
} catch {
/* localStorage unavailable */
}
}
function loadWorkbenchOrganizeMode(): WorkbenchOrganizeMode {
try {
@@ -497,6 +559,7 @@ export function ProjectTree({
const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
const [workbenchOrganizeMode, setWorkbenchOrganizeMode] = useState<WorkbenchOrganizeMode>(loadWorkbenchOrganizeMode);
const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
const [readActivity, setReadActivity] = useState<ProjectTreeReadActivity>(loadReadActivity);
const filterRef = useRef<HTMLDivElement>(null);
const filterTriggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -562,6 +625,67 @@ export function ProjectTree({
void refresh();
}, [refresh, refreshSignal]);
const markNodeRead = useCallback((node: ProjectNode) => {
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
if (!key || activityAt <= 0) return;
setReadActivity((prev) => {
if ((prev[key] ?? 0) >= activityAt) return prev;
const next = { ...prev, [key]: activityAt };
saveReadActivity(next);
return next;
});
}, []);
useEffect(() => {
if (tree.length === 0) return;
try {
if (localStorage.getItem(READ_ACTIVITY_INIT_KEY)) return;
} catch {
return;
}
const baseline: ProjectTreeReadActivity = {};
const collectBaseline = (nodes: ProjectNode[]) => {
for (const node of nodes) {
if ((isTopicNode(node) || isRuntimeSessionNode(node)) && topicStatus(node) === "") {
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
if (key && activityAt > 0) baseline[key] = Math.max(baseline[key] ?? 0, activityAt);
}
collectBaseline(asArray(node.children));
}
};
collectBaseline(tree);
try {
localStorage.setItem(READ_ACTIVITY_INIT_KEY, "1");
} catch {
/* localStorage unavailable */
}
if (Object.keys(baseline).length === 0) return;
setReadActivity((prev) => {
const next = { ...prev };
let changed = false;
for (const [key, value] of Object.entries(baseline)) {
if ((next[key] ?? 0) >= value) continue;
next[key] = value;
changed = true;
}
if (!changed) return prev;
saveReadActivity(next);
return next;
});
}, [tree]);
useEffect(() => {
const markActive = (nodes: ProjectNode[]) => {
for (const node of nodes) {
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) markNodeRead(node);
markActive(asArray(node.children));
}
};
markActive(tree);
}, [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, tree]);
useEffect(() => {
try {
localStorage.setItem(WORKBENCH_ORGANIZE_KEY, workbenchOrganizeMode);
@@ -1010,6 +1134,7 @@ export function ProjectTree({
const status = topicStatus(node);
const statusLabel = topicStatusLabel(node, t);
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const topicId = node.topicId ?? "";
const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
const imSourceLabel = imSource?.label || "";
@@ -1092,7 +1217,7 @@ export function ProjectTree({
}
const row = (
<div
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
style={accentStyle}
onContextMenu={isSessionNode ? undefined : openTopicMenu}
>
@@ -1112,6 +1237,7 @@ export function ProjectTree({
}
const timer = setTimeout(() => {
if (clickTimerRef.current?.timer === timer) clickTimerRef.current = null;
markNodeRead(node);
onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
}, 200);
clickTimerRef.current = { ...nextClick, timer };
@@ -1169,7 +1295,8 @@ export function ProjectTree({
</span>
)}
</button>
{!isSessionNode && compactTopics && (
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
<span className="project-tree__topic-actions" aria-label={t("projectTree.topicActions")}>
<Tooltip label={pinLabel} side="top" className="project-tree__topic-action-slot">
<button
@@ -1,6 +1,8 @@
import type { ReactNode, RefObject } from "react";
export function PromptShelf({
className,
cardClassName,
titleId,
title,
badges,
@@ -13,6 +15,8 @@ export function PromptShelf({
barRef,
role = "dialog",
}: {
className?: string;
cardClassName?: string;
titleId: string;
title: ReactNode;
badges?: ReactNode;
@@ -26,10 +30,10 @@ export function PromptShelf({
role?: "dialog" | "region";
}) {
return (
<div className="prompt-shelf" aria-live="polite">
<div className={["prompt-shelf", className ?? ""].filter(Boolean).join(" ")} aria-live="polite">
<div
ref={barRef}
className="prompt-shelf__card"
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
role={role}
aria-modal={role === "dialog" ? "false" : undefined}
aria-labelledby={titleId}
+332 -176
View File
@@ -17,6 +17,7 @@ import {
type ThemeStyle,
} from "../lib/theme";
import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
import { snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
import {
applyFontFamily,
applyMonoFontFamily,
@@ -57,6 +58,7 @@ import { ShortcutComboDisplay } from "./ShortcutComboDisplay";
const SETTINGS_TABS: SettingsTab[] = ["general", "models", "bots", "mcp", "skills", "memory", "hooks", "shortcuts", "permissions", "sandbox", "network", "appearance", "updates"];
export type SettingsInitialFocus = { target: "bot-allowlist"; connectionId?: string };
type DesktopPlatform = "darwin" | "windows" | "linux";
const MCPServersSettingsPage = lazy(() => import("./CapabilitiesPanel").then((module) => ({ default: module.MCPServersSettingsPage })));
const SkillsSettingsPage = lazy(() => import("./CapabilitiesPanel").then((module) => ({ default: module.SkillsSettingsPage })));
@@ -72,12 +74,14 @@ export function SettingsPanel({
initialTab,
initialFocus,
agentRunning = false,
desktopPlatform,
}: {
onClose: () => void;
onChanged: (settings?: SettingsView | null) => void;
initialTab?: SettingsTab;
initialFocus?: SettingsInitialFocus;
agentRunning?: boolean;
desktopPlatform: DesktopPlatform;
}) {
const t = useT();
const [s, setS] = useState<SettingsView | null>(null);
@@ -89,6 +93,7 @@ export function SettingsPanel({
const [theme, setThemeState] = useState<Theme>(getTheme());
const [themeStyle, setThemeStyleState] = useState<ThemeStyle>(() => getThemeStyle(getTheme()));
const [textSize, setTextSizeState] = useState<TextSize>(getTextSize());
const [zoomPct, setZoomPct] = useState<number>(zoomToPercent(getRestartZoom()));
const [fontFamily, setFontFamilyState] = useState<FontFamily>(getFontFamily());
const [monoFontFamily, setMonoFontFamilyState] = useState<MonoFontFamily>(getMonoFontFamily());
const [customFontName, setCustomFontNameState] = useState<string>(getCustomFontName());
@@ -220,6 +225,8 @@ export function SettingsPanel({
theme={theme}
themeStyle={themeStyle}
textSize={textSize}
showDisplayZoom={desktopPlatform === "windows"}
zoomPct={zoomPct}
fontFamily={fontFamily}
monoFontFamily={monoFontFamily}
customFontName={customFontName}
@@ -238,6 +245,18 @@ export function SettingsPanel({
applyTextSize(size);
setTextSizeState(size);
}}
onRestartZoom={async (zoom) => {
const snapped = snapZoom(zoom);
setErr(null);
setWarning(null);
try {
await app.SetDesktopZoomFactor(snapped);
saveRestartZoom(snapped);
setZoomPct(zoomToPercent(snapped));
} catch (e) {
setErr(String((e as Error)?.message ?? e));
}
}}
onFontFamily={(font) => {
applyFontFamily(font);
setFontFamilyState(font);
@@ -612,10 +631,9 @@ function toRef(model: string, s: SettingsView): string {
const PROXY_MODES = ["auto", "custom", "off"] as const;
// EFFORT_PRESETS is the canonical union of /effort levels the kernel
// recognises. The settings UI exposes these as toggleable checkboxes; users
// can additionally add arbitrary custom names via the "Add" input. The order
// here is what the user sees in the dropdown.
// EFFORT_PRESETS is the canonical union of /effort levels the kernel recognises.
// The settings UI uses it for subagent defaults; provider-specific levels are
// inferred by the backend or edited in TOML for rare gateways.
const EFFORT_PRESETS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];
const REASONING_PROTOCOLS: readonly string[] = ["", "deepseek", "openai", "none"];
const PROXY_TYPES = ["http", "https", "socks5", "socks5h"] as const;
@@ -672,6 +690,47 @@ export function providerBaseURLFromChatURL(chatUrl: string): string {
return full;
}
function formatProviderHeaders(headers: Record<string, string> | null | undefined): string {
const entries = Object.entries(headers ?? {})
.map(([key, value]) => [key.trim(), String(value ?? "").trim()] as const)
.filter(([key, value]) => key && value)
.sort(([a], [b]) => a.localeCompare(b));
return entries.map(([key, value]) => `${key}: ${value}`).join("\n");
}
function parseProviderHeaders(raw: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const colon = trimmed.indexOf(":");
const eq = trimmed.indexOf("=");
const cut = colon >= 0 && (eq < 0 || colon < eq) ? colon : eq;
if (cut <= 0) continue;
const key = trimmed.slice(0, cut).trim();
const value = trimmed.slice(cut + 1).trim();
if (key && value) out[key] = value;
}
return out;
}
function providerModelFetchFallbackMessage(error: unknown, t: ReturnType<typeof useT>): string {
const message = String((error as Error)?.message ?? error);
if (/\bstatus\s+(401|403)\b/i.test(message)) {
return t("settings.fetchModelsManualFallbackAuth");
}
if (/\bstatus\s+(404|405)\b/i.test(message)) {
return t("settings.fetchModelsManualFallbackUnsupported");
}
if (/\b(status\s+5\d\d|request failed|network|timeout|timed out|connection|deadline|fetch failed)\b/i.test(message)) {
return t("settings.fetchModelsManualFallbackNetwork");
}
if (/\b(decode response|invalid character|unexpected end|unexpected format)\b/i.test(message)) {
return t("settings.fetchModelsManualFallbackDecode");
}
return t("settings.fetchModelsManualFallbackGeneric", { err: message });
}
function normalizeReasoningLanguage(lang: string | undefined): string {
const v = String(lang ?? "").trim().toLowerCase();
return v === "zh" || v === "en" ? v : "auto";
@@ -797,6 +856,17 @@ function normalizeBotMappingScope(scope: unknown, workspaceRoot: unknown): "glob
return String(workspaceRoot ?? "").trim() ? "project" : "global";
}
function normalizeStringMap(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(value as Record<string, unknown>)) {
const key = rawKey.trim();
const val = String(rawValue ?? "").trim();
if (key && val) out[key] = val;
}
return out;
}
function normalizeProviderView(p: ProviderView): ProviderView {
const visionModels = asArray(p.visionModels);
const requiresKey = providerRequiresKey(p);
@@ -809,8 +879,10 @@ function normalizeProviderView(p: ProviderView): ProviderView {
visionModels,
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? visionModels.length > 0),
modelsUrl: p.modelsUrl ?? "",
headers: normalizeStringMap(p.headers),
reasoningProtocol: normalizeReasoningProtocol(p.reasoningProtocol),
supportedEfforts: asArray(p.supportedEfforts),
modelOverrides: asArray(p.modelOverrides),
requiresKey,
configured: providerIsConfigured({ ...p, requiresKey }),
keySource: p.keySource ?? "",
@@ -4274,19 +4346,93 @@ function parseBotListInput(value: string): string[] {
.filter(Boolean));
}
// Memoized model chips for ProviderEditor — prevents re-render when typing
// in name/key/baseUrl fields.
const ModelChips = memo(function ModelChips({ modelNames }: { modelNames: string[] }) {
const ProviderEditorModelPicker = memo(function ProviderEditorModelPicker({
candidates,
selectedModels,
visionModels,
disabled,
onToggleModel,
onToggleVision,
onSelectAll,
onClear,
}: {
candidates: string[];
selectedModels: string[];
visionModels: string[];
disabled: boolean;
onToggleModel: (model: string) => void;
onToggleVision: (model: string) => void;
onSelectAll: () => void;
onClear: () => void;
}) {
const t = useT();
if (modelNames.length === 0) return null;
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 150);
return () => clearTimeout(timer);
}, [query]);
if (candidates.length === 0) return null;
const selected = new Set(selectedModels);
const vision = new Set(visionModels);
const q = debouncedQuery.trim().toLowerCase();
const visibleCandidates = q
? candidates.filter((model) => model.toLowerCase().includes(q))
: candidates;
return (
<div className="provider-model-chips">
{modelNames.slice(0, 8).map((model) => (
<span className="provider-model-chip" key={model}>{model}</span>
))}
{modelNames.length > 8 && (
<span className="provider-model-chip provider-model-chip--more">{t("settings.moreModels", { n: modelNames.length - 8 })}</span>
<div className="provider-model-draft provider-model-draft--inline">
<div className="provider-model-draft__head">
<div>
<div className="provider-card-block__label">{t("settings.modelCandidates")}</div>
<span>{t("settings.modelCandidatesSelected", { n: selectedModels.length })}</span>
</div>
<div className="provider-model-draft__tools">
<button type="button" className="btn btn--small" disabled={disabled || selectedModels.length === candidates.length} onClick={onSelectAll}>
{t("settings.selectAllModels")}
</button>
<button type="button" className="btn btn--small" disabled={disabled || selectedModels.length === 0} onClick={onClear}>
{t("settings.clearModelSelection")}
</button>
</div>
</div>
{candidates.length > 8 && (
<input
className="mem-input provider-model-draft__search"
placeholder={t("settings.modelCandidateSearch")}
value={query}
disabled={disabled}
onChange={(e) => setQuery(e.target.value)}
/>
)}
<div className="provider-model-draft__list" role="list" aria-label={t("settings.modelCandidates")}>
{visibleCandidates.length > 0 ? visibleCandidates.map((model) => {
const enabled = selected.has(model);
return (
<div className="provider-model-draft__option" key={model}>
<label className="provider-model-draft__model">
<input
type="checkbox"
checked={enabled}
disabled={disabled}
onChange={() => onToggleModel(model)}
/>
<span>{model}</span>
</label>
<label className="provider-model-draft__vision">
<input
type="checkbox"
checked={enabled && vision.has(model)}
disabled={disabled || !enabled}
onChange={() => onToggleVision(model)}
/>
<span>{t("settings.visionModel")}</span>
</label>
</div>
);
}) : (
<div className="provider-model-draft__empty">{t("settings.noMatchingCandidateModels")}</div>
)}
</div>
</div>
);
});
@@ -4310,29 +4456,30 @@ function ProviderEditor({
}) {
const t = useT();
const [name, setName] = useState(initial?.name ?? "");
const [kind, setKind] = useState(initial?.kind ?? "openai");
const [kind] = useState(initial?.kind ?? "openai");
const [baseUrl, setBaseUrl] = useState(initial?.baseUrl ?? "");
const [chatUrl, setChatUrl] = useState(initial?.chatUrl ?? "");
const [fullChatUrl, setFullChatUrl] = useState(Boolean((initial?.chatUrl ?? "").trim()));
const [models, setModels] = useState((initial?.models ?? []).join(", "));
const [modelCandidates, setModelCandidates] = useState<string[]>(initial?.models ?? []);
const [visionModels, setVisionModels] = useState((initial?.visionModels ?? []).join(", "));
const [visionModelsConfigured, setVisionModelsConfigured] = useState(
Boolean(initial?.visionModelsConfigured ?? ((initial?.visionModels ?? []).length > 0)),
);
const [modelsUrl, setModelsUrl] = useState(initial?.modelsUrl ?? "");
const [apiKeyEnv, setApiKeyEnv] = useState(initial?.apiKeyEnv ?? "");
const [headersDraft, setHeadersDraft] = useState(formatProviderHeaders(initial?.headers));
const [keyDraft, setKeyDraft] = useState("");
const [balanceUrl, setBalanceUrl] = useState(initial?.balanceUrl ?? "");
// Empty when unset so the placeholder (and its "0 = default" hint) reads instead
// of a bare "0"; saved back as 0.
const [ctx, setCtx] = useState(initial?.contextWindow ? String(initial.contextWindow) : "");
const [reasoningProtocol, setReasoningProtocol] = useState(normalizeReasoningProtocol(initial?.reasoningProtocol));
const [supportedEfforts, setSupportedEfforts] = useState<string[]>(initial?.supportedEfforts ?? []);
const [customEffortDraft, setCustomEffortDraft] = useState("");
const [defaultEffort, setDefaultEffort] = useState(initial?.defaultEffort ?? "");
const [supportedEfforts] = useState<string[]>(initial?.supportedEfforts ?? []);
const [defaultEffort] = useState(initial?.defaultEffort ?? "");
const [fetchingModels, setFetchingModels] = useState(false);
const [fetchStatus, setFetchStatus] = useState<string | null>(null);
const [fetchErr, setFetchErr] = useState<string | null>(null);
const [fetchFallback, setFetchFallback] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const builtIn = initial?.builtIn ?? false;
const isNewCustomProvider = !initial;
@@ -4340,45 +4487,26 @@ function ProviderEditor({
const effectiveBaseUrl = fullChatUrl ? providerBaseURLFromChatURL(chatUrl) : baseUrl.trim();
const effectiveChatUrl = fullChatUrl ? trimmedURL(chatUrl) : "";
const effectiveModelsUrl = modelsUrl.trim();
const effectiveHeaders = parseProviderHeaders(headersDraft);
const previewChatUrl = providerChatURLPreview(baseUrl, chatUrl, fullChatUrl);
// Offer the kinds the kernel actually registered; if the stored kind is a
// legacy/unknown one, keep it as an option so editing doesn't silently change it.
const kindOptions = kind && !kinds.includes(kind) ? [kind, ...kinds] : kinds;
// Split supportedEfforts into the 5 canonical presets (for checkbox UI) and
// any user-added custom names (rendered as removable chips). The preset order
// is fixed; custom names keep insertion order.
const presetEfforts = supportedEfforts.filter((e) => EFFORT_PRESETS.includes(e));
const customEfforts = supportedEfforts.filter((e) => !EFFORT_PRESETS.includes(e));
const togglePreset = (level: string) => {
const has = presetEfforts.includes(level);
const nextPresets = has ? presetEfforts.filter((e) => e !== level) : [...presetEfforts, level];
setSupportedEfforts([...nextPresets, ...customEfforts]);
// If the removed preset was the default, fall back to "auto" (empty string).
if (has && defaultEffort === level) setDefaultEffort("");
};
const addCustomEffort = () => {
const v = customEffortDraft.trim().toLowerCase();
if (!v || supportedEfforts.includes(v)) {
setCustomEffortDraft("");
return;
}
setSupportedEfforts([...presetEfforts, ...customEfforts, v]);
setCustomEffortDraft("");
};
const removeCustomEffort = (level: string) => {
setSupportedEfforts(supportedEfforts.filter((e) => e !== level));
if (defaultEffort === level) setDefaultEffort("");
};
// Empty supportedEfforts means "use protocol defaults". The simplified
// provider flow no longer edits these levels directly, but it preserves
// existing advanced TOML unless the user explicitly disables reasoning.
const cleanedSupportedEfforts = reasoningProtocol !== "none"
? uniqueStrings(
supportedEfforts
.map((level) => level.toLowerCase().trim())
.filter((level) => level && level !== "auto")
)
: [];
const normalizedDefaultEffort = defaultEffort.toLowerCase().trim();
const cleanDefaultEffort = cleanedSupportedEfforts.includes(normalizedDefaultEffort) ? normalizedDefaultEffort : "";
const fetchModels = async () => {
setFetchingModels(true);
setFetchStatus(null);
setFetchErr(null);
setFetchFallback(null);
try {
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
if (!apiKeyEnv.trim()) setApiKeyEnv(effectiveApiKeyEnv);
@@ -4396,14 +4524,20 @@ function ProviderEditor({
visionModelsConfigured: false,
default: "",
apiKeyEnv: effectiveApiKeyEnv,
headers: effectiveHeaders,
keySet: Boolean(keyDraft.trim()) || (initial?.keySet ?? false),
balanceUrl: balanceUrl.trim(),
contextWindow: Number(ctx) || 0,
reasoningProtocol,
supportedEfforts,
defaultEffort,
supportedEfforts: cleanedSupportedEfforts,
defaultEffort: cleanDefaultEffort,
modelOverrides: initial?.modelOverrides ?? [],
});
if (fetched.length === 0) throw new Error(t("settings.fetchModelsEmpty"));
if (fetched.length === 0) {
setFetchFallback(t("settings.fetchModelsManualFallbackEmpty"));
return;
}
setModelCandidates(fetched);
setModels(fetched.join(", "));
setVisionModels((current) => {
const existing = parseProviderListInput(current).filter((model) => fetched.includes(model));
@@ -4411,10 +4545,9 @@ function ProviderEditor({
});
setVisionModelsConfigured(true);
if (keyDraft.trim()) setKeyDraft("");
setDefaultEffort((v) => v);
setFetchStatus(t("settings.fetchModelsSuccess", { n: fetched.length }));
} catch (e) {
setFetchErr(String((e as Error)?.message ?? e));
setFetchFallback(providerModelFetchFallbackMessage(e, t));
} finally {
setFetchingModels(false);
}
@@ -4437,15 +4570,17 @@ function ProviderEditor({
visionModelsConfigured: visionModelsConfigured || vms.length > 0,
default: ms[0] ?? "",
apiKeyEnv: effectiveApiKeyEnv,
headers: effectiveHeaders,
modelsUrl: effectiveModelsUrl,
keySet: Boolean(keyDraft.trim()) || (initial?.keySet ?? false),
balanceUrl: balanceUrl.trim(),
contextWindow: Number(ctx) || 0,
reasoningProtocol,
supportedEfforts,
supportedEfforts: cleanedSupportedEfforts,
// Clear the stored default if no levels are selected; the backend's
// NormalizeEffort would otherwise silently ignore an unsupported value.
defaultEffort: supportedEfforts.length > 0 ? defaultEffort : "",
defaultEffort: cleanedSupportedEfforts.length > 0 ? cleanDefaultEffort : "",
modelOverrides: initial?.modelOverrides ?? [],
});
};
@@ -4484,29 +4619,75 @@ function ProviderEditor({
}
const modelNames = useMemo(
() => models.split(",").map((m) => m.trim()).filter(Boolean),
() => parseProviderListInput(models),
[models],
);
const modelCandidateNames = useMemo(
() => uniqueStrings([...modelCandidates, ...modelNames]),
[modelCandidates, modelNames],
);
const visionModelNames = useMemo(
() => parseProviderListInput(visionModels).filter((model) => modelNames.includes(model)),
[modelNames, visionModels],
);
const canFetch = Boolean(name.trim() && effectiveBaseUrl);
const protocolField = initial ? (
<select className="mem-select" value={kind} onChange={(e) => setKind(e.target.value)}>
{kindOptions.map((k) => (
<option key={k} value={k}>
{k === "openai" ? t("settings.providerProtocolOpenAI") : k}
</option>
))}
</select>
) : (
<div className="provider-readonly-field provider-readonly-field--stacked" aria-readonly="true">
<strong>{t("settings.providerProtocolOpenAI")}</strong>
<span>{t("settings.providerProtocolOpenAIHint")}</span>
</div>
);
const setModelsFromList = (nextModels: string[]) => {
setModels(uniqueStrings(nextModels).join(", "));
};
const updateManualModels = (value: string) => {
setModels(value);
const typedModels = parseProviderListInput(value);
if (typedModels.length > 0) {
setModelCandidates((current) => uniqueStrings([...current, ...typedModels]));
}
};
const toggleEditorModel = (model: string) => {
const selected = new Set(modelNames);
if (selected.has(model)) {
selected.delete(model);
setVisionModels(visionModelNames.filter((candidate) => candidate !== model).join(", "));
} else {
selected.add(model);
}
setModelsFromList(modelCandidateNames.filter((candidate) => selected.has(candidate)));
setVisionModelsConfigured(true);
};
const toggleEditorVisionModel = (model: string) => {
if (!modelNames.includes(model)) return;
const vision = new Set(visionModelNames);
if (vision.has(model)) vision.delete(model);
else vision.add(model);
setVisionModels(modelCandidateNames.filter((candidate) => vision.has(candidate)).join(", "));
setVisionModelsConfigured(true);
};
const selectAllEditorModels = () => {
setModelsFromList(modelCandidateNames);
setVisionModels(visionModelNames.filter((model) => modelCandidateNames.includes(model)).join(", "));
setVisionModelsConfigured(true);
};
const clearEditorModels = () => {
setModels("");
setVisionModels("");
setVisionModelsConfigured(true);
};
const advancedFields = (
<details className="provider-editor-advanced" open={advancedOpen} onToggle={(e) => setAdvancedOpen(e.currentTarget.open)}>
<summary>{t("settings.providerAdvancedSettings")}</summary>
<summary>
<span className="provider-editor-advanced__title">
<ChevronDown className="provider-editor-advanced__icon" size={16} aria-hidden="true" />
{t("settings.providerAdvancedSettings")}
</span>
<span className="provider-editor-advanced__hint">
{advancedOpen ? t("settings.providerAdvancedCollapseHint") : t("settings.providerAdvancedExpandHint")}
</span>
</summary>
<div className="provider-editor-advanced__body">
<label className="set-label">{t("settings.providerApiKeyEnv")}</label>
<input
@@ -4524,23 +4705,15 @@ function ProviderEditor({
onChange={(e) => setModelsUrl(e.target.value)}
/>
<div className="mem-hint">{t("settings.providerModelsUrlHint")}</div>
<label className="set-label">{t("settings.providerBalanceUrl")}</label>
<input className="mem-input" placeholder={t("settings.balanceUrlPlaceholder")} value={balanceUrl} onChange={(e) => setBalanceUrl(e.target.value)} />
<div className="mem-hint">{t("settings.balanceUrlHint")}</div>
<label className="set-label">{t("settings.providerContextWindow")}</label>
<input className="mem-input" placeholder={t("settings.contextWindowPlaceholder")} value={ctx} onChange={(e) => setCtx(e.target.value)} inputMode="numeric" />
<div className="mem-hint">{t("settings.contextWindowHint")}</div>
<label className="set-label">{t("settings.visionModels")}</label>
<input
className="mem-input"
placeholder={t("settings.providerModels")}
value={visionModels}
onChange={(e) => {
setVisionModelsConfigured(true);
setVisionModels(e.target.value);
}}
<label className="set-label">{t("settings.providerHeaders")}</label>
<textarea
className="mem-textarea provider-headers-textarea"
placeholder={t("settings.providerHeadersPlaceholder")}
value={headersDraft}
onChange={(e) => setHeadersDraft(e.target.value)}
rows={3}
/>
<div className="mem-hint">{t("settings.visionModelsHint")}</div>
<div className="mem-hint">{t("settings.providerHeadersHint")}</div>
<label className="set-label">{t("settings.reasoningProtocol")}</label>
<select className="mem-select" value={reasoningProtocol} onChange={(e) => setReasoningProtocol(e.target.value)}>
{REASONING_PROTOCOLS.map((protocol) => (
@@ -4550,81 +4723,25 @@ function ProviderEditor({
))}
</select>
<div className="mem-hint">{t("settings.reasoningProtocolHint")}</div>
<label className="set-label">{t("settings.supportedEfforts")}</label>
{EFFORT_PRESETS.map((level) => (
<label key={level} className="set-check">
<input
type="checkbox"
checked={presetEfforts.includes(level)}
onChange={() => togglePreset(level)}
/>
{level}
</label>
))}
<div className="set-row">
<input
className="mem-input set-grow"
placeholder={t("settings.supportedEffortsCustomPlaceholder")}
value={customEffortDraft}
onChange={(e) => setCustomEffortDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustomEffort();
}
}}
/>
<button
type="button"
className="btn btn--small"
disabled={
!customEffortDraft.trim() || supportedEfforts.includes(customEffortDraft.trim().toLowerCase())
}
onClick={addCustomEffort}
>
{t("common.add")}
</button>
</div>
{customEfforts.length > 0 && (
<div className="set-rules__chips">
{customEfforts.map((level) => (
<span className="set-rule" key={level}>
{level}
<Tooltip label={t("common.delete")}>
<button
type="button"
className="set-rule__x"
disabled={busy}
onClick={() => removeCustomEffort(level)}
>
×
</button>
</Tooltip>
</span>
))}
</div>
)}
<div className="mem-hint">{t("settings.supportedEffortsHint")}</div>
<label className="set-label">{t("settings.defaultEffort")}</label>
{supportedEfforts.length > 0 ? (
<select
className="mem-select"
value={defaultEffort}
onChange={(e) => setDefaultEffort(e.target.value)}
>
<option value="">{t("settings.defaultEffortAuto")}</option>
{supportedEfforts.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
) : (
<select className="mem-select" value="" disabled>
<option value="">{t("settings.defaultEffortAuto")}</option>
</select>
)}
<div className="mem-hint">{t("settings.defaultEffortHint")}</div>
<label className="set-label">{t("settings.providerBalanceUrl")}</label>
<input
className="mem-input"
placeholder={t("settings.balanceUrlPlaceholder")}
value={balanceUrl}
onChange={(e) => setBalanceUrl(e.target.value)}
/>
<div className="mem-hint">{t("settings.balanceUrlHint")}</div>
<label className="set-label">{t("settings.providerContextWindow")}</label>
<input
className="mem-input"
inputMode="numeric"
min={0}
placeholder={t("settings.contextWindowPlaceholder")}
type="number"
value={ctx}
onChange={(e) => setCtx(e.target.value)}
/>
<div className="mem-hint">{t("settings.contextWindowHint")}</div>
</div>
</details>
);
@@ -4633,8 +4750,6 @@ function ProviderEditor({
<div className={`provider-editor${isNewCustomProvider ? " provider-editor--wizard" : ""}`}>
<label className="set-label">{t("settings.customProviderName")}</label>
<input className="mem-input" placeholder={t("settings.customProviderNamePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} disabled={!!initial} />
<label className="set-label">{t("settings.providerProtocol")}</label>
{protocolField}
<div className="set-row">
<label className="set-label set-grow">
{t(fullChatUrl ? "settings.providerChatUrlLabel" : "settings.providerBaseUrlLabel")}
@@ -4713,16 +4828,20 @@ function ProviderEditor({
<span>{t("settings.testFetchModelsHint")}</span>
</div>
{fetchStatus && <div className="provider-fetch-status provider-fetch-status--ok">{fetchStatus}</div>}
{fetchErr && <div className="provider-fetch-status provider-fetch-status--error">{fetchErr}</div>}
{modelNames.length > 0 && (
<div className="provider-card-block">
<div className="provider-card-block__label">{t("settings.availableModels")}</div>
<ModelChips modelNames={modelNames} />
</div>
)}
{fetchFallback && <div className="provider-fetch-status provider-fetch-status--warn">{fetchFallback}</div>}
<label className="set-label">{t("settings.manualModels")}</label>
<input className="mem-input" placeholder={t("settings.providerModels")} value={models} onChange={(e) => setModels(e.target.value)} />
<input className="mem-input" placeholder={t("settings.providerModels")} value={models} onChange={(e) => updateManualModels(e.target.value)} />
<div className="mem-hint">{t("settings.manualModelsHint")}</div>
<ProviderEditorModelPicker
candidates={modelCandidateNames}
selectedModels={modelNames}
visionModels={visionModelNames}
disabled={busy || fetchingModels}
onToggleModel={toggleEditorModel}
onToggleVision={toggleEditorVisionModel}
onSelectAll={selectAllEditorModels}
onClear={clearEditorModels}
/>
{advancedFields}
<div className="prov-card__actions">
<button className="btn btn--small" onClick={onCancel} disabled={busy}>
@@ -5272,6 +5391,8 @@ function AppearanceSection({
theme,
themeStyle,
textSize,
showDisplayZoom,
zoomPct,
fontFamily,
monoFontFamily,
customFontName,
@@ -5279,6 +5400,7 @@ function AppearanceSection({
onTheme,
onThemeStyle,
onTextSize,
onRestartZoom,
onFontFamily,
onMonoFontFamily,
onCustomFontNameChange,
@@ -5287,6 +5409,8 @@ function AppearanceSection({
theme: Theme;
themeStyle: ThemeStyle;
textSize: TextSize;
showDisplayZoom: boolean;
zoomPct: number;
fontFamily: FontFamily;
monoFontFamily: MonoFontFamily;
customFontName: string;
@@ -5294,6 +5418,7 @@ function AppearanceSection({
onTheme: (t: Theme) => void;
onThemeStyle: (style: ThemeStyle) => void;
onTextSize: (size: TextSize) => void;
onRestartZoom: (zoom: ZoomLevel) => Promise<void>;
onFontFamily: (font: FontFamily) => void;
onMonoFontFamily: (font: MonoFontFamily) => void;
onCustomFontNameChange: (name: string) => void;
@@ -5365,6 +5490,37 @@ function AppearanceSection({
))}
</div>
</SettingsField>
{showDisplayZoom && (
<SettingsField label={t("settings.displayZoom")}>
<div className="zoom-slider-wrap">
<div className="zoom-slider__value">{zoomPct}%</div>
<div className="zoom-slider-row">
<span className="zoom-slider__label">50%</span>
<div className="slider-track">
<div className="slider-track__bg" />
<div
className="slider-track__fill"
style={{ width: `calc(${((zoomPct - 50) / 150) * 100}% + 15px)` }}
/>
<div className="slider-thumb" style={{ left: `${((zoomPct - 50) / 150) * 100}%` }}>
<div className="slider-thumb__left" />
<div className="slider-thumb__mid" />
<div className="slider-thumb__right" />
</div>
<input
type="range"
min={50}
max={200}
step={5}
value={zoomPct}
onChange={(e) => { void onRestartZoom(Number(e.target.value) / 100); }}
/>
</div>
<span className="zoom-slider__label">200%</span>
</div>
</div>
</SettingsField>
)}
<SettingsField label={t("settings.fontFamily")}>
<div className="set-seg">
{availableFontFamilies.map((font) => (
+45 -10
View File
@@ -1,22 +1,39 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../lib/i18n";
import type { Todo } from "../lib/tools";
import { shouldOpenTodoPanelByDefault } from "../lib/todoVisibility";
import { PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
const STORAGE_KEY = "todoPanel:open";
const STORAGE_KEY = "todoPanel:openStates";
const MAX_STORED_OPEN_STATES = 80;
function loadOpenState(): boolean {
function loadOpenStates(): Record<string, boolean> {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved === null ? true : saved === "1";
if (!saved) return {};
const parsed = JSON.parse(saved) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const states: Record<string, boolean> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "boolean") states[key] = value;
}
return states;
} catch {
return true;
return {};
}
}
function saveOpenState(open: boolean): void {
function loadOpenState(stateKey: string, defaultOpen: boolean): boolean {
const states = loadOpenStates();
return Object.prototype.hasOwnProperty.call(states, stateKey) ? states[stateKey] : defaultOpen;
}
function saveOpenState(stateKey: string, open: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, open ? "1" : "0");
const entries = Object.entries(loadOpenStates()).filter(([key]) => key !== stateKey);
entries.push([stateKey, open]);
const trimmed = entries.slice(-MAX_STORED_OPEN_STATES);
localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(trimmed)));
} catch {
/* ignore quota errors */
}
@@ -24,23 +41,35 @@ function saveOpenState(open: boolean): void {
// TodoPanel is the live task list pinned just above the composer — the kernel's
// latest todo_write call drives it, and it updates in place as the agent flips
// items to in_progress / completed. The collapsed/expanded state is persisted
// globally in localStorage so the user's choice sticks across sessions.
// items to in_progress / completed. Open state follows the current todo batch:
// new incomplete work opens by default, finished work collapses to a reviewable
// summary, and manual expand/collapse is restored only for the same batch.
export function TodoPanel({
stateKey,
todos,
onDismiss,
}: {
stateKey: string;
todos: Todo[];
onDismiss: () => void;
}) {
const t = useT();
const [open, setOpen] = useState(loadOpenState);
const currentRef = useRef<HTMLLIElement | null>(null);
const done = todos.filter((t) => t.status === "completed").length;
const current = todos.find((t) => t.status === "in_progress");
const allDone = todos.length > 0 && done === todos.length;
const summary = current?.activeForm || current?.content || todos[todos.length - 1]?.content || "";
const [open, setOpen] = useState(() => loadOpenState(stateKey, shouldOpenTodoPanelByDefault(todos)));
const wasAllDoneRef = useRef(allDone);
useEffect(() => {
if (allDone && !wasAllDoneRef.current) {
saveOpenState(stateKey, false);
setOpen(false);
}
wasAllDoneRef.current = allDone;
}, [allDone, stateKey]);
useEffect(() => {
if (!open) return;
@@ -58,7 +87,13 @@ export function TodoPanel({
role="region"
headerActions={
<>
<PromptHeaderAction onClick={() => setOpen((value) => { const next = !value; saveOpenState(next); return next; })}>
<PromptHeaderAction
onClick={() => setOpen((value) => {
const next = !value;
saveOpenState(stateKey, next);
return next;
})}
>
{open ? t("common.collapse") : t("common.expand")}
</PromptHeaderAction>
{allDone && (
@@ -13,11 +13,25 @@ export function isLikelyInlineMath(math: string): boolean {
// Unary plus/minus: +2, -x, +\alpha, - 3.14
if (/^[+\-]\s*(?:\d+(?:\.\d+)?|[A-Za-z\\])/.test(math)) return true;
if (/\\[A-Za-z]+\b/.test(math)) return true;
// LaTeX command (\alpha, \frac{x}{y}, \tfrac12, ...). Do not require a
// word boundary after the command name: \tfrac12 / \sqrt2 / \log3 have no
// boundary between the command letters and the trailing digit.
if (/\\[A-Za-z]+/.test(math)) return true;
if (/[\^_{}|]/.test(math)) return true;
// Lone math operators are common in physics prose: "the sign of $+$",
// "the $<$ relation".
if (/^[+\-=<>±∓]$/.test(math)) return true;
if (/\b(?:alpha|beta|gamma|sum|int|prod|lim|infty|sqrt|frac|sin|cos|tan|log|ln|max|min|partial|nabla|left|right)\b/.test(math)) return true;
if (/^[A-Za-z]\s*\([^)]{1,80}\)$/.test(math)) return true;
if (/[A-Za-z0-9)\]}]\s*[+\-*/=<>]\s*[A-Za-z0-9([{\\]/.test(math)) return true;
// Short identifiers followed by parenthesised arguments cover both
// function calls (f(x)) and group notation (SO(3,1), SU(2), GL(n)).
if (/^[A-Za-z]{1,6}\s*\([^)]{1,80}\)$/.test(math)) return true;
// Primed function call: f'(x), g''(t).
if (/^[A-Za-z]'{1,}\s*\([^)]{1,80}\)$/.test(math)) return true;
// Permutation cycle notation: (12), (123), (12)(34).
if (/^(?:\(\d+\))+$/.test(math)) return true;
// Binary operator between operands. The RHS may start with a unary sign:
// K = -iJ, p = +\alpha, a = -b.
if (/[A-Za-z0-9)\]}]\s*[+\-*/=<>]\s*[+\-]?\s*[A-Za-z0-9([{\\]/.test(math)) return true;
// One-sided comparison: < B, > 0, B < — comparison against an implicit
// operand is common in prose.
if (/^(?:<=?|>=?|≠|≤|≥)\s*[A-Za-z0-9]|[A-Za-z0-9]\s*(?:<=?|>=?|≠|≤|≥)$/.test(math)) return true;
@@ -25,11 +39,19 @@ export function isLikelyInlineMath(math: string): boolean {
// \alpha, \beta. Currency/env-var usage never looks like this.
if (/^\(?(?:[A-Za-z0-9]|\\[A-Za-z]+)(?:\s*,\s*(?:[A-Za-z0-9]|\\[A-Za-z]+)){1,10}\)?$/.test(math)) return true;
// Bracketed labels/indexes such as [56], [8], [N], [\mathbf{56}], [56,0^+]
// are common in group theory and arrays. Markdown links are rejected above
// by the "](" guard.
if (/^\[[A-Za-z0-9^_+\-,.\\\s{}]+\]$/.test(math)) return true;
if (/[A-Za-z]\s+[A-Za-z]/.test(math)) return false;
if (/^[A-Z][A-Z0-9_]{1,}$/.test(math)) return false;
if (/^v\d+(?:\.\d+)*$/i.test(math)) return false;
if (/^[A-Za-z]{2,}$/.test(math)) return false;
// Letter or Greek command followed by one or more primes: x', S', y'', \psi'.
if (/^(?:[A-Za-z]|\\[A-Za-z]+)'{1,}$/.test(math)) return true;
// Single letter (a-z, A-Z). Uppercase single letters (S, A, G, …) are
// common math names (sets, algebras, groups) and $X$ is essentially
// never written for the English word I/A by hand.
@@ -67,30 +67,13 @@ function normalizeMathText(s: string): string {
const escapedDollarToken = unusedEscapedDollarToken(r);
r = r.split("\\$").join(escapedDollarToken);
// Step 3: repair inline $$. CommonMark requires a blank line before
// block math; without it remark-math parses the opening $$ as an
// empty math node and the formula leaks out as literal text.
// Digits are excluded so `c^2$$` inside a formula is left alone.
// Comma is included so `…D(q^2),$$` (closing $$ on the same line as
// the trailing content) is repaired: micromark-extension-math only
// recognises a closing $$ fence at the start of a new line, so
// without this repair it consumes the rest of the document as math
// and katex fails on the stray $ in the next paragraph. Closing
// braces } and { are included too — a model that writes
// `\end{array}$$` or `\frac{a}{b}$$` on one line has the same
// micromark-fence problem, and these are the most common ends of
// LaTeX math content.
r = r.replace(/([A-Za-z\)\]\>\.。!?,{}])\$\$/g, (_m, prev) => prev + "\n\n$$");
// Orphan opening $$ (model forgot the closing $$) is left alone:
// converting it to a lone $ would interact badly with the $…$
// matcher below and wrap whole prose paragraphs in math spans. The
// right fix is upstream — a post-generation lint or stricter prompt.
// Step 4: $$…$$ → display placeholders. KaTeX-specific normalisation
// runs here so |→\vert (with \| protected) and \text{} escapes both
// apply to display math.
r = r.replace(/\$\$([\s\S]*?)\$\$/g, (_m, m) => `${DM}${latexNormalizeForKatex(m)}${DM}`);
// Step 3+4: normalise display $$ blocks and run KaTeX-specific
// normalisation on each recognised display source. remark-math requires
// opening and closing $$ to sit on their own lines; LLMs often emit
// single-line displays, opening fences glued to prose, and adjacent display
// blocks separated by prose. A line parser avoids the old cross-block regex
// capture that swallowed prose between two display blocks.
r = normaliseDisplayBlocks(r);
// Step 5: $\cmd{...}$ pairs where the body may contain a stray $
// (e.g. $\text{price is $5}$). Recognised first so the inner $ doesn't
@@ -246,3 +229,95 @@ function lineEnd(s: string, start: number): number {
const end = s.indexOf("\n", start);
return end < 0 ? s.length : end;
}
function normaliseDisplayBlocks(s: string): string {
const lines = s.split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const $$idx = line.indexOf("$$");
if ($$idx >= 0 && line.indexOf("$$", $$idx + 2) >= 0
&& !($$idx > 0 && /\d/.test(line[$$idx - 1]))) {
const m = line.match(/^(.*?)\$\$([^\n]*?)\$\$(.*)$/);
if (m) {
const quote = blockquotePrefix(m[1]);
pushDisplayBefore(out, m[1]);
out.push(DM);
out.push(latexNormalizeForKatex(m[2]));
out.push(DM);
if (m[3]) out.push(normaliseDisplayBlocks(quote ? quote + m[3].trimStart() : m[3]));
i += 1;
continue;
}
}
if ($$idx >= 0 && line.indexOf("$$", $$idx + 2) < 0
&& !($$idx > 0 && /\d/.test(line[$$idx - 1]))) {
const before = line.slice(0, $$idx);
const afterOpen = line.slice($$idx + 2);
const quote = blockquotePrefix(before);
const formulaLines: string[] = [];
if (afterOpen) formulaLines.push(afterOpen);
let j = i + 1;
let found = false;
while (j < lines.length) {
const rawLine = lines[j];
const fLine = quote ? stripBlockquotePrefix(rawLine, quote) : rawLine;
const closeIdx = fLine.indexOf("$$");
if (closeIdx >= 0 && fLine.indexOf("$$", closeIdx + 2) < 0) {
const formulaPart = fLine.slice(0, closeIdx);
const afterClose = fLine.slice(closeIdx + 2);
pushDisplayBefore(out, before);
if (formulaPart) formulaLines.push(formulaPart);
const formula = formulaLines.join("\n");
out.push(DM);
out.push(latexNormalizeForKatex(formula));
out.push(DM);
if (afterClose) out.push(quote ? quote + afterClose.trimStart() : afterClose);
i = j + 1;
found = true;
break;
}
formulaLines.push(fLine);
j += 1;
}
if (found) continue;
pushDisplayBefore(out, before);
out.push("$$");
if (afterOpen) out.push(afterOpen);
i += 1;
continue;
}
out.push(line);
i += 1;
}
return out.join("\n");
}
function pushDisplayBefore(out: string[], before: string): void {
if (!before.trim()) return;
out.push(before);
}
function blockquotePrefix(before: string): string | null {
const m = before.match(/^(\s*>\s*)/);
return m ? m[1] : null;
}
function stripBlockquotePrefix(line: string, prefix: string): string {
if (line.startsWith(prefix)) return line.slice(prefix.length);
const marker = prefix.trimEnd();
if (marker && line.startsWith(marker)) {
const rest = line.slice(marker.length);
return rest.startsWith(" ") ? rest.slice(1) : rest;
}
return line;
}
@@ -190,6 +190,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
enabled: true,
approvalMode: "yolo",
newConversationEachRun: false,
notifyChannels: false,
createdAt: Date.now(),
});
});
@@ -218,6 +219,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
enabled: true,
approvalMode: "yolo",
newConversationEachRun: false,
notifyChannels: false,
createdAt: Date.now(),
});
}, []);
@@ -952,37 +954,63 @@ function TaskEditor({
/>
</div>
{/* Approval Mode */}
<div className="heartbeat-editor__field">
<label>{t("heartbeat.fieldApprovalMode")}</label>
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
title={t("heartbeat.approvalModeAskTooltip")}
>
{t("heartbeat.approvalModeAsk")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
title={t("heartbeat.approvalModeAutoTooltip")}
>
{t("heartbeat.approvalModeAuto")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
title={t("heartbeat.approvalModeYoloTooltip")}
>
{t("heartbeat.approvalModeYolo")}
</button>
{/* Approval Mode + Push to bot (side by side) */}
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px" }}>
<label>{t("heartbeat.fieldApprovalMode")}</label>
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
title={t("heartbeat.approvalModeAskTooltip")}
>
{t("heartbeat.approvalModeAsk")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
title={t("heartbeat.approvalModeAutoTooltip")}
>
{t("heartbeat.approvalModeAuto")}
</button>
<button
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
title={t("heartbeat.approvalModeYoloTooltip")}
>
{t("heartbeat.approvalModeYolo")}
</button>
</div>
<span className="heartbeat-editor__mode-hint">
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
t("heartbeat.approvalModeAskHint")}
</span>
</div>
{/* Push to bot channels */}
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px", textAlign: "left" }}>
<label>{t("heartbeat.notifyChannels")} <span className="heartbeat-editor__optional">{t("heartbeat.optional")}</span></label>
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
<button
className={`set-seg__btn${draft.notifyChannels === true ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: true }))}
>
{t("heartbeat.notifyChannelsOn")}
</button>
<button
className={`set-seg__btn${draft.notifyChannels !== true ? " set-seg__btn--on" : ""}`}
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: false }))}
>
{t("heartbeat.notifyChannelsOff")}
</button>
</div>
<span className="heartbeat-editor__mode-hint">
{draft.notifyChannels === true
? t("heartbeat.notifyChannelsOnHint")
: t("heartbeat.notifyChannelsOffHint")}
</span>
</div>
<span className="heartbeat-editor__mode-hint">
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
t("heartbeat.approvalModeAskHint")}
</span>
</div>
{/* New conversation per run */}
@@ -15,4 +15,5 @@ export interface HeartbeatTask {
approvalMode?: "ask" | "auto" | "yolo"; // empty defaults to "yolo"
timeWindowStart?: string; // "HH:MM" — interval tasks only run after this time
timeWindowEnd?: string; // "HH:MM" — interval tasks only run before this time
notifyChannels?: boolean; // true = push to bot channels; false/nil = skip
}
+127 -7
View File
@@ -16,6 +16,9 @@ import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarIt
import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";
import type {
AutoResearchFindingView,
AutoResearchEvidenceView,
AutoResearchStatusView,
BalanceInfo,
BotConnectionDiagnostic,
BotInstallPollResult,
@@ -115,6 +118,10 @@ interface DesktopWindowState {
// to AppBindings, then run `pnpm typecheck` to verify.
export interface AppBindings {
Platform(): Promise<string>;
MinimiseMainWindow(): Promise<void>;
ToggleMaximiseMainWindow(): Promise<void>;
IsMainWindowMaximised(): Promise<boolean>;
CloseMainWindow(): Promise<void>;
// ── Heartbeat ──
HeartbeatListTasks(): Promise<unknown>;
HeartbeatReloadTasks(): Promise<unknown>;
@@ -190,6 +197,12 @@ export interface AppBindings {
ToolResultForTab(tabID: string, toolID: string): Promise<{ args: string; output: string } | null>;
Meta(): Promise<Meta>;
MetaForTab(tabID: string): Promise<Meta>;
AutoResearchCurrent(): Promise<AutoResearchStatusView>;
AutoResearchStatus(tabID: string): Promise<AutoResearchStatusView>;
AutoResearchList(tabID: string): Promise<AutoResearchStatusView[]>;
AutoResearchFindings(tabID: string, limit: number): Promise<AutoResearchFindingView[]>;
AutoResearchOpenTask(tabID: string): Promise<void>;
AutoResearchRecordEvidence(tabID: string, criterionID: string, input: AutoResearchEvidenceView): Promise<void>;
Commands(): Promise<CommandInfo[]>;
Capabilities(): Promise<CapabilitiesView>;
MCPServers(): Promise<ServerView[]>;
@@ -294,6 +307,9 @@ export interface AppBindings {
SetDesktopLanguage(lang: string): Promise<void>;
SetDesktopAppearance(theme: string, style: string): Promise<void>;
SetDesktopLayoutStyle(style: string): Promise<void>;
SetDesktopZoomFactor(factor: number): Promise<void>;
GetDesktopZoomFactor(): Promise<number>;
RestartApplication(): Promise<void>;
SetDesktopCheckUpdates(enabled: boolean): Promise<void>;
SetDesktopTelemetry(enabled: boolean): Promise<void>;
SetDesktopMetrics(enabled: boolean): Promise<void>;
@@ -394,6 +410,8 @@ const EVENT_CHANNEL = "agent:event";
const RECENT_NATIVE_FILE_DRAG_MS = 2000;
const WAILS_NON_FILE_DRAG_MESSAGE = "additional File object is not a file on the disk";
const UNCAUGHT_ERROR_PREFIX_RE = /^Uncaught(?:\s+\(in promise\))?(?:\s+\w*Error)?:\s*/i;
const WAILS_IPC_CONNECTING_RE = /Failed to execute 'send' on 'WebSocket': Still in CONNECTING state/i;
const WAILS_IPC_NULL_SEND_RE = /Cannot read properties of null \(reading 'send'\)/i;
// Resolve the Wails binding at CALL time, not module-load time: in dev the Wails
// runtime can inject window.go AFTER this module first evaluates, so snapshotting
@@ -452,6 +470,11 @@ export function isWailsNonFileDragErrorEvent(
return event.error != null && isWailsNonFileDragError(event.message, recentNativeFileDrag);
}
export function isTransientWailsIPCError(err: unknown): boolean {
const msg = errorMessage(err).trim().replace(UNCAUGHT_ERROR_PREFIX_RE, "");
return WAILS_IPC_CONNECTING_RE.test(msg) || WAILS_IPC_NULL_SEND_RE.test(msg);
}
function dataTransferLooksLikeFileDrag(dt: DataTransfer | null): boolean {
if (!dt) return false;
if (dt.files?.length > 0) return true;
@@ -472,12 +495,12 @@ export function installWailsNonFileDragErrorSuppression(): () => void {
};
const hasRecentNativeFileDrag = () => Date.now() - lastNativeFileDragAt <= RECENT_NATIVE_FILE_DRAG_MS;
const suppressNonFileDragError = (e: ErrorEvent) => {
if (isWailsNonFileDragErrorEvent(e, hasRecentNativeFileDrag())) {
if (isWailsNonFileDragErrorEvent(e, hasRecentNativeFileDrag()) || isTransientWailsIPCError(e.error ?? e.message)) {
e.preventDefault();
}
};
const suppressNonFileDragRejection = (e: PromiseRejectionEvent) => {
if (isWailsNonFileDragError(e.reason, hasRecentNativeFileDrag())) {
if (isWailsNonFileDragError(e.reason, hasRecentNativeFileDrag()) || isTransientWailsIPCError(e.reason)) {
e.preventDefault();
}
};
@@ -565,6 +588,7 @@ function bridgeBreadcrumb(method: string): string {
return `mcp ${method}`;
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion)/.test(method))
return `skill ${method}`;
if (/^(MinimiseMainWindow|ToggleMaximiseMainWindow|IsMainWindowMaximised|CloseMainWindow)$/.test(method)) return `window ${method}`;
if (/^(OpenProjectTab|OpenGlobalTab|OpenTopicSession|EnsureBlankTab|ActivateTopic|EnsureBlankSurface|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace)/.test(method))
return `nav ${method}`;
return "";
@@ -662,10 +686,11 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
}
function mockScenario(): "demo" | "fresh" | "running" {
function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
if (typeof window === "undefined") return "demo";
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
if (value === "running" || value === "busy" || value === "streaming") return "running";
return "demo";
}
@@ -673,7 +698,8 @@ function mockScenario(): "demo" | "fresh" | "running" {
function makeMockApp(): AppBindings {
const scenario = mockScenario();
const freshMock = scenario === "fresh";
const runningMock = scenario === "running";
const guidanceMock = scenario === "guidance";
const runningMock = scenario === "running" || guidanceMock;
let cancelled = false;
let pendingAskPreview = false;
let pendingApprovalPreview = false;
@@ -1362,7 +1388,7 @@ function makeMockApp(): AppBindings {
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: true,
active: !guidanceMock,
cwd: "~/projects/joyquant-db",
},
{
@@ -1382,7 +1408,7 @@ function makeMockApp(): AppBindings {
collaborationMode: "normal",
toolApprovalMode: "ask",
tokenMode: "full",
active: false,
active: guidanceMock,
cwd: "~/projects/joyquant-sys",
},
{
@@ -1434,6 +1460,18 @@ function makeMockApp(): AppBindings {
}
};
return {
async MinimiseMainWindow() {
console.info("mock MinimiseMainWindow");
},
async ToggleMaximiseMainWindow() {
console.info("mock ToggleMaximiseMainWindow");
},
async IsMainWindowMaximised() {
return false;
},
async CloseMainWindow() {
console.info("mock CloseMainWindow");
},
async Platform() {
const override = browserPlatformOverride();
if (override) return override;
@@ -1805,7 +1843,7 @@ function makeMockApp(): AppBindings {
async ClearSession() {},
async Checkpoints() {
return [
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], fileCount: 1, turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
];
},
async CheckpointsForTab() {
@@ -2009,6 +2047,7 @@ function makeMockApp(): AppBindings {
tokenMode: normalizeTokenMode(active?.tokenMode),
goal: active?.goal ?? "",
goalStatus: active?.goalStatus ?? (active?.goal ? "running" : "stopped"),
autoResearch: active?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined,
};
},
async MetaForTab(tabID) {
@@ -2034,8 +2073,80 @@ function makeMockApp(): AppBindings {
tokenMode: normalizeTokenMode(tab?.tokenMode),
goal: tab?.goal ?? "",
goalStatus: tab?.goalStatus ?? (tab?.goal ? "running" : "stopped"),
autoResearch: tab?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined,
};
},
async AutoResearchCurrent() {
return {
taskId: "mock-autoresearch",
goal: "Mock long-running research",
status: "running",
iteration: 4,
currentDirection: "Inspect status chip",
staleCount: 0,
pivotCount: 0,
pivotRequired: false,
lastHeartbeatAt: "2026-06-29T00:00:00Z",
findingCount: 1,
openCriteria: [],
blocker: "",
taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch",
nextRequiredAction: "continue with the next evidence-producing step",
};
},
async AutoResearchStatus(_tabID) {
return {
taskId: "mock-autoresearch",
goal: "Mock long-running research",
status: "running",
iteration: 4,
currentDirection: "Inspect status chip",
staleCount: 0,
pivotCount: 0,
pivotRequired: false,
lastHeartbeatAt: "2026-06-29T00:00:00Z",
findingCount: 1,
openCriteria: [],
blocker: "",
taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch",
nextRequiredAction: "continue with the next evidence-producing step",
};
},
async AutoResearchList(_tabID) {
return [{
taskId: "mock-autoresearch",
goal: "Mock long-running research",
status: "running",
iteration: 4,
currentDirection: "Inspect status chip",
staleCount: 0,
pivotCount: 0,
pivotRequired: false,
lastHeartbeatAt: "2026-06-29T00:00:00Z",
findingCount: 1,
openCriteria: [],
blocker: "",
taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch",
nextRequiredAction: "continue with the next evidence-producing step",
}];
},
async AutoResearchFindings(_tabID, limit) {
return [{
id: "f1",
kind: "test",
summary: "Mock accepted finding",
source: "command",
command: "go test ./...",
accepted: true,
createdAt: "2026-06-29T00:00:00Z",
}].slice(0, Math.max(0, limit || 1));
},
async AutoResearchOpenTask(_tabID) {
console.info("mock AutoResearchOpenTask");
},
async AutoResearchRecordEvidence(_tabID, _criterionID, _input) {
console.info("mock AutoResearchRecordEvidence");
},
async Commands() {
return [
{ name: "new", description: "start new session; save transcript", kind: "builtin" as const },
@@ -2764,6 +2875,15 @@ function makeMockApp(): AppBindings {
async SetDesktopLayoutStyle(style: string) {
settings.desktopLayoutStyle = style === "workbench" || style === "creation" ? style : "classic";
},
async SetDesktopZoomFactor(_factor: number) {
// no-op in mock; in production this writes desktop-zoom.json via Go
},
async GetDesktopZoomFactor() {
return 1.0; // default in mock
},
async RestartApplication() {
// no-op in mock
},
async SetDesktopCheckUpdates(enabled: boolean) {
settings.checkUpdates = enabled;
},
+79
View File
@@ -0,0 +1,79 @@
/**
* DPI zoom scale service.
*
* Uses the WebView2 ZoomFactor (Go side) for reliable, layout-safe zooming.
* The frontend only saves the user's preference and lets the Go binding persist
* it to desktop-zoom.json; on next startup main.go reads the file and applies
* ZoomFactor to the WebView2 window options.
*
* Range: 0.50 – 2.00 (50% – 200%), step 0.05.
*/
export const MIN_ZOOM = 0.5;
export const MAX_ZOOM = 2.0;
export const ZOOM_STEP = 0.05;
export type ZoomLevel = number; // 0.5 – 2.0
export const DEFAULT_ZOOM: ZoomLevel = 1.0;
const ZOOM_KEY = "reasonix-zoom-restart";
// ─── helpers ────────────────────────────────────────────────────────
/** Snap a number to the nearest valid step within [MIN, MAX]. */
export function snapZoom(value: number): ZoomLevel {
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
const steps = Math.round((clamped - MIN_ZOOM) / ZOOM_STEP);
return parseFloat((MIN_ZOOM + steps * ZOOM_STEP).toFixed(2));
}
/** Convert a zoom value (0.5-2.0) to a percentage integer (50-200). */
export function zoomToPercent(zoom: ZoomLevel): number {
return Math.round(zoom * 100);
}
/** Convert a percentage integer (50-200) back to a zoom value (0.5-2.0). */
export function percentToZoom(pct: number): ZoomLevel {
return snapZoom(pct / 100);
}
function readZoom(fallback: ZoomLevel): ZoomLevel {
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(ZOOM_KEY) : null;
if (stored === null) return fallback;
const parsed = parseFloat(stored);
if (isNaN(parsed)) return fallback;
return snapZoom(parsed);
}
function writeZoom(value: ZoomLevel): void {
try {
localStorage.setItem(ZOOM_KEY, String(value));
} catch {
/* private mode / no storage */
}
}
// ─── public API ─────────────────────────────────────────────────────
/** Read the saved zoom level that will be applied on next restart. */
export function getRestartZoom(): ZoomLevel {
return readZoom(DEFAULT_ZOOM);
}
/**
* Save a zoom factor that will be picked up by Go on next startup and
* applied as the WebView2 `ZoomFactor`. Takes effect after the app is
* restarted (no layout issues — ZoomFactor is engine-level).
*/
export function saveRestartZoom(userZoom: ZoomLevel): void {
writeZoom(snapZoom(userZoom));
}
/**
* Init: no-op for zoom (the Go-side ZoomFactor is applied at WebView2
* creation time).
*/
export function initDpiScale(): void {
/* zoom is handled entirely by the Go side (ZoomFactor) */
}
@@ -1,5 +1,17 @@
import type { Todo } from "./tools";
export interface TodoPanelScopeInput {
activeTabId?: string | null;
activeTab?: {
id?: string | null;
scope?: string | null;
workspaceRoot?: string | null;
topicId?: string | null;
sessionPath?: string | null;
} | null;
eventChannel?: string | null;
}
export function todoDismissalKey(todos: Todo[]): string {
if (todos.length === 0) return "";
return JSON.stringify(todos.map((todo) => ({
@@ -10,6 +22,50 @@ export function todoDismissalKey(todos: Todo[]): string {
})));
}
export function todoPanelScope({ activeTab, activeTabId, eventChannel }: TodoPanelScopeInput): string {
const tabId = String(activeTabId ?? "").trim();
const tab = !tabId || activeTab?.id === tabId ? activeTab : null;
const sessionPath = tab?.sessionPath?.trim();
if (sessionPath) return `session:${sessionPath}`;
if (tabId) return `tab:${tabId}`;
const topicId = tab?.topicId?.trim();
if (tab && topicId) return `topic:${tab.scope ?? ""}:${tab.workspaceRoot ?? ""}:${topicId}`;
const channel = String(eventChannel ?? "").trim();
return channel ? `event:${channel}` : "";
}
export function scopedTodoDismissalKey(scope: string | null | undefined, todoKey: string | null | undefined): string {
const key = String(todoKey ?? "").trim();
if (!key) return "";
const prefix = String(scope ?? "").trim();
return prefix ? `${prefix}\0${key}` : key;
}
export function dismissedTodoKeyForScope(
scope: string | null | undefined,
dismissedKeys: ReadonlySet<string> | null | undefined,
todoKey: string | null | undefined,
): string | null {
const scopedKey = scopedTodoDismissalKey(scope, todoKey);
if (!scopedKey || !dismissedKeys?.has(scopedKey)) return null;
return todoKey ?? null;
}
export function todoBatchKey(todos: Todo[]): string {
if (todos.length === 0) return "";
return JSON.stringify(todos.map((todo) => ({
content: String(todo.content ?? ""),
level: typeof todo.level === "number" ? todo.level : 0,
})));
}
export function scopedTodoBatchKey(scope: string | null | undefined, batchKey: string | null | undefined): string {
const key = String(batchKey ?? "").trim();
if (!key) return "";
const prefix = String(scope ?? "").trim();
return prefix ? `${prefix}\0${key}` : key;
}
export function shouldShowTodoPanel(
todoKey: string | null | undefined,
dismissedTodoKey: string | null,
@@ -20,6 +76,10 @@ export function shouldShowTodoPanel(
return todoKey !== dismissedTodoKey;
}
export function shouldOpenTodoPanelByDefault(todos: Todo[]): boolean {
return hasIncompleteTodos(todos);
}
function todoStatus(status: unknown): string {
const normalized = String(status ?? "").trim();
return normalized || "pending";
+63
View File
@@ -207,6 +207,7 @@ export interface TabMeta {
tokenMode?: TokenMode;
goal?: string;
goalStatus?: GoalStatus;
autoResearch?: AutoResearchCompactView;
startupErr?: string;
active: boolean;
cwd: string;
@@ -355,6 +356,8 @@ export interface CheckpointMeta {
turn: number;
prompt: string;
files: string[];
fileCount?: number;
filesTruncated?: boolean;
turnFileCount?: number;
time: number; // unix ms
canCode?: boolean;
@@ -434,6 +437,7 @@ export interface Meta {
tokenMode?: TokenMode;
goal?: string;
goalStatus?: GoalStatus;
autoResearch?: AutoResearchCompactView;
}
export type CollaborationMode = "normal" | "plan" | "goal";
@@ -441,6 +445,55 @@ export type ToolApprovalMode = "ask" | "auto" | "yolo";
export type TokenMode = "full" | "economy";
export type GoalStatus = "running" | "complete" | "blocked" | "stopped";
export interface AutoResearchCompactView {
taskId: string;
status: "running" | "blocked" | "complete" | "stopped" | "invalid";
iteration: number;
pivotRequired: boolean;
staleCount: number;
}
export interface AutoResearchCriterionView {
id: string;
description: string;
required: boolean;
evidenceCount: number;
status: string;
}
export interface AutoResearchStatusView extends AutoResearchCompactView {
goal: string;
currentDirection: string;
pivotCount: number;
lastHeartbeatAt: string;
findingCount: number;
openCriteria: AutoResearchCriterionView[];
blocker: string;
taskPath: string;
nextRequiredAction: string;
}
export interface AutoResearchFindingView {
id: string;
kind: string;
summary: string;
source: string;
command?: string;
paths?: string[];
accepted: boolean;
createdAt: string;
}
export interface AutoResearchEvidenceView {
id: string;
kind: string;
summary: string;
source: string;
command?: string;
paths?: string[];
accepted: boolean;
}
export function normalizeCollaborationMode(mode?: string, goal?: string, legacyMode?: Mode): CollaborationMode {
if (mode === "plan" || mode === "goal" || mode === "normal") return mode;
if (legacyMode && modeHasPlan(legacyMode)) return "plan";
@@ -754,6 +807,7 @@ export interface ProviderView {
modelsUrl: string; // optional override for model discovery; empty derives from baseUrl
default: string;
apiKeyEnv: string;
headers?: Record<string, string> | null; // optional extra request headers for compatible gateways
keySet: boolean; // the env var currently resolves to a value
requiresKey?: boolean; // false for explicit no-auth providers
configured?: boolean; // selectable: key is set or no key is required
@@ -764,6 +818,15 @@ export interface ProviderView {
reasoningProtocol: string; // auto|deepseek|openai|none; empty = auto/model registry
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
modelOverrides?: ProviderModelOverrideView[] | null;
}
export interface ProviderModelOverrideView {
model: string;
reasoningProtocol: string;
supportedEfforts: string[];
defaultEffort: string;
vision?: boolean | null;
}
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
+46 -12
View File
@@ -43,6 +43,9 @@ export type LiveStream = { id: string; text: string; reasoning: string; reasonin
export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversation" | "code" | "both";
export type MessageActionState = { turn: number; scope: MessageActionScope };
export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup";
type SyncActiveTabOptions = {
preserveCachedHistory?: boolean;
};
const HISTORY_PAGE_TURNS = 60;
@@ -1109,17 +1112,35 @@ export function useController() {
return;
}
const ancillaryStartedAt = Date.now();
const [effort, jobs, checkpoints, context] = await Promise.all([
app.EffortForTab(tabId).catch((err) => { noteFailure("effort", err); return undefined; }),
app.JobsForTab(tabId).catch((err) => { noteFailure("jobs", err); return undefined; }),
app.CheckpointsForTab(tabId).catch((err) => { noteFailure("checkpoints", err); return undefined; }),
app.ContextUsageForTab(tabId).catch((err) => { noteFailure("context", err); return undefined; }),
const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
const startedAt = Date.now();
try {
const value = await load();
addBreadcrumb("tab.hydrate", `ancillary ${label} ${reason} ${tabId} ms=${Date.now() - startedAt}`);
return value;
} catch (err) {
noteFailure(label, err);
return undefined;
}
};
const [effort, jobs, context] = await Promise.all([
loadAncillary("effort", () => app.EffortForTab(tabId)),
loadAncillary("jobs", () => app.JobsForTab(tabId)),
loadAncillary("context", () => app.ContextUsageForTab(tabId)),
]);
if (!stillCurrent()) return;
if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
if (context !== undefined) dispatchTo(tabId, { type: "context", context });
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
if (!stillCurrent()) return;
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
addBreadcrumb("tab.hydrate", `checkpoints skipped inactive ${reason} ${tabId}`);
return;
}
const checkpoints = await loadAncillary("checkpoints", () => app.CheckpointsForTab(tabId));
if (!stillCurrent()) return;
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
addBreadcrumb("tab.hydrate", `ancillary ${reason} ${tabId} ms=${Date.now() - ancillaryStartedAt}`);
app.BalanceForTab(tabId)
.then((balance) => {
@@ -1180,7 +1201,7 @@ export function useController() {
}
}, []);
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false): Promise<string | undefined> => {
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false, options: SyncActiveTabOptions = {}): Promise<string | undefined> => {
const active = await activeTabFromBackend();
if (!active) return undefined;
// When guard is true, skip if the frontend already settled on a
@@ -1194,7 +1215,11 @@ export function useController() {
activeTabIdRef.current = active.id;
confirmBackendActiveTab(active.id);
dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, statesRef.current.get(active.id)?.meta) });
await loadSessionDataForTab(active.id, reset, "startup");
const preserveCachedHistory = options.preserveCachedHistory ?? !reset;
await loadSessionDataForTab(active.id, reset, "startup", {
preserveCachedHistory,
sessionPath: active.sessionPath,
});
return active.id;
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
@@ -1290,7 +1315,6 @@ export function useController() {
.then((turns) => dispatchTo(targetTabId, { type: "history_checkpoint_turns", turns: asArray(turns) }))
.catch(() => {});
}
void refreshMetaForTab(targetTabId, dispatchTo);
app
.ContextUsageForTab(targetTabId)
.then((context) => dispatchTo(targetTabId, { type: "context", context }))
@@ -1298,6 +1322,7 @@ export function useController() {
app.BalanceForTab(targetTabId).then((balance) => dispatchTo(targetTabId, { type: "balance", balance })).catch(() => {});
app.EffortForTab(targetTabId).then((effort) => dispatchTo(targetTabId, { type: "effort", effort })).catch(() => {});
void refreshCheckpoints(targetTabId);
void refreshMetaForTab(targetTabId, dispatchTo);
}
if (e.kind === "turn_done" || e.kind === "notice") {
app.JobsForTab(targetTabId).then((jobs) => dispatchTo(targetTabId, { type: "jobs", jobs: asArray(jobs) })).catch(() => {});
@@ -1375,9 +1400,12 @@ export function useController() {
dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq });
invalidateCache();
try {
await (original
const submitPromise = original
? app.SubmitEditedDisplayToTab(tabId, display, submit, original)
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit));
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit);
void submitPromise.catch((error) => {
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
});
} catch (error) {
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
throw error;
@@ -1735,6 +1763,7 @@ export function useController() {
// Tab management: switch preserves per-tab state; open creates it.
const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta): Promise<TabMeta[] | undefined> => {
const startedAt = Date.now();
const previousTabId = activeTabIdRef.current;
const targetSessionPath = optimisticTab?.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath;
const preserveCachedHistory = hasReusableCachedTranscript(statesRef.current.get(tabId), targetSessionPath);
addBreadcrumb("tab.switch", `click ${tabId}`);
@@ -1755,6 +1784,11 @@ export function useController() {
.catch((err) => {
dispatchTo(tabId, { type: "backend_activation_done" });
dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: errorMessage(err) });
if (previousTabId && activeTabIdRef.current === tabId) {
setActiveTabId(previousTabId);
activeTabIdRef.current = previousTabId;
addBreadcrumb("tab.switch", `set-active-failed-reverted ${tabId} -> ${previousTabId} ms=${Date.now() - startedAt}`);
}
return false;
});
trackBackendActivation(tabId, backendActivation);
@@ -1876,7 +1910,7 @@ export function useController() {
await app.CloseTab(tabId);
statesRef.current.delete(tabId);
bump();
if (tabId === activeTabId) await syncActiveTabFromBackend(true);
if (tabId === activeTabId) await syncActiveTabFromBackend(false);
} catch { /* ignore */ }
}, [activeTabId, bump, syncActiveTabFromBackend]);
+75 -32
View File
@@ -411,6 +411,17 @@ export const en = {
"composer.removeCurrentProjectDisabled": "Current project cannot be removed",
"composer.resize": "Drag to resize composer, double-click to reset",
"composer.send": "Send (Enter)",
"composer.queueGuidance": "Add to guidance queue (Enter)",
"composer.steer": "Add guidance to current turn (Enter)",
"composer.steerPlaceholder": "Add guidance to the queue...",
"composer.guidanceQueue": "Queued guidance",
"composer.guidanceCount": "Queued guidance {n}",
"composer.guidanceRemaining": "{n} more queued",
"composer.guidanceCollapse": "Collapse",
"composer.guidanceMode": "Guide",
"composer.guidanceSend": "Send this guidance to the transcript",
"composer.guidanceDismiss": "Dismiss queued guidance",
"composer.guidanceMore": "More guidance options",
"composer.shellMode": "Shell mode: prefix input with !",
"composer.shellModeOn": "Shell mode on: click to remove !",
"composer.stop": "Stop (Esc)",
@@ -545,6 +556,11 @@ export const en = {
"heartbeat.approvalModeAskHint": "Each tool call needs manual confirmation",
"heartbeat.approvalModeAutoHint": "Auto-approve safe tools, keep deny rules",
"heartbeat.approvalModeYoloHint": "Skip all permission prompts, tasks run automatically",
"heartbeat.notifyChannels": "Push to bot channels",
"heartbeat.notifyChannelsOn": "On",
"heartbeat.notifyChannelsOff": "Off",
"heartbeat.notifyChannelsOnHint": "AI output is streamed to connected Feishu/QQ bots",
"heartbeat.notifyChannelsOffHint": "Only create a desktop conversation; do not push to bots",
"heartbeat.fieldNewConversation": "Conversation mode",
"heartbeat.newConversationEachRunOn": "New conversation each run",
"heartbeat.newConversationEachRunOff": "Reuse same conversation",
@@ -589,6 +605,7 @@ export const en = {
"ask.hideDetails": "Hide",
"ask.back": "Back",
"ask.next": "Next",
"ask.customAnswer": "Other answer",
"ask.customPlaceholder": "Type your own answer…",
"ask.justChat": "Just chat",
@@ -1006,7 +1023,7 @@ export const en = {
"settings.providerModelsUrlPlaceholder": "e.g. https://api.openai.com/v1/models",
"settings.providerModelsUrlHint": "Optional. Use when model discovery needs a different URL than the chat endpoint.",
"settings.providerModels": "models (comma-separated)",
"settings.providerApiKeyEnv": "api_key_env (e.g. DEEPSEEK_API_KEY)",
"settings.providerApiKeyEnv": "api_key_env (optional)",
"settings.providerBalanceUrl": "Balance URL (optional)",
"settings.balanceUrlPlaceholder": "balance endpoint (e.g. https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "Optional. If set, the wallet balance is queried and shown in the status bar.",
@@ -1016,12 +1033,22 @@ export const en = {
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto (model and endpoint detection)",
"settings.providerReasoningProtocolHint": "For OpenAI-compatible reasoning models. deepseek uses DeepSeek reasoning fields, openai uses ordinary reasoning_effort, and none disables it.",
"settings.effortBehavior": "Effort behavior",
"settings.effortUseProtocolDefaults": "Protocol defaults",
"settings.effortUseCustom": "Customize levels",
"settings.effortProtocolDefault.auto": "Protocol defaults stay stored as empty supported_efforts. Reasonix will infer known DeepSeek models/endpoints as high|max, OpenAI reasoning as low|medium|high, and unsupported providers as no /effort.",
"settings.effortProtocolDefault.deepseek": "DeepSeek defaults expose auto|high|max. Reasonix keeps thinking enabled and uses reasoning_effort for depth.",
"settings.effortProtocolDefault.openai": "OpenAI reasoning defaults expose auto|low|medium|high and send ordinary reasoning_effort.",
"settings.effortProtocolDefault.minimax": "MiniMax-style endpoints use provider-specific thinking levels such as adaptive|disabled. Add those as custom levels only when the endpoint supports them.",
"settings.effortProtocolDefault.none": "Effort is disabled for this provider; Reasonix will not send reasoning_effort or thinking controls.",
"settings.supportedEfforts": "Supported efforts",
"settings.supportedEffortsCustomPlaceholder": "Custom level (press Enter)",
"settings.supportedEffortsHint": "Toggle the /effort levels this provider supports, or add a custom name. Empty uses the built-in default (DeepSeek: high|max, Anthropic: low|medium|high|xhigh|max).",
"settings.supportedEffortsNoPresets": "No standard presets",
"settings.supportedEffortsNoPresetsHint": "Use custom levels only for endpoints that document their own effort names.",
"settings.supportedEffortsHint": "Custom levels override protocol defaults. Pick only levels the endpoint documents; unsupported combinations can make the model request fail.",
"settings.defaultEffort": "Default effort",
"settings.defaultEffortAuto": "auto (use built-in default)",
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Must be one of the levels above. Empty falls back to the first level (or the built-in default when none are selected).",
"settings.defaultEffortAuto": "unset (use protocol/custom default)",
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Leave unset to use the protocol default, or the first custom level when levels are customized.",
"settings.proxyMode": "Proxy mode",
"settings.proxyMode.auto": "auto",
"settings.proxyMode.env": "env",
@@ -1290,6 +1317,7 @@ export const en = {
"settings.textSizeLarge": "Large",
"settings.textSizeXLarge": "Extra large",
"settings.textSizeXXLarge": "Huge",
"settings.displayZoom": "Display zoom (restart required)",
"settings.fontFamily": "Interface font",
"settings.fontFamilySystem": "System",
"settings.fontFamilyYaHei": "YaHei",
@@ -1369,12 +1397,23 @@ export const en = {
"settings.testFetchModels": "Test and fetch models",
"settings.testFetchModelsHint": "Uses the API address and key above to verify the connection and fill the model list.",
"settings.manualModels": "Model list",
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually, separated by commas.",
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually; mark image-capable models below.",
"settings.visionModels": "Image-capable models",
"settings.visionModelsHint": "Subset of enabled models that accept image input, separated by commas.",
"settings.providerAdvancedSettings": "Advanced settings",
"settings.providerAdvancedSettings": "Compatibility settings (usually leave unchanged)",
"settings.providerAdvancedExpandHint": "Click to open details",
"settings.providerAdvancedCollapseHint": "Click to collapse",
"settings.providerHeaders": "Extra request headers (optional)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "For gateways such as OpenRouter that require site headers. Use one Header: value per line; the API key still belongs in the key field above.",
"settings.fetchingModels": "Refreshing…",
"settings.fetchModelsSuccess": "Fetched {n} models",
"settings.fetchModelsManualFallbackAuth": "The model discovery endpoint rejected the key or permission. Check the API key; if this service does not expose model discovery, enter model IDs manually below.",
"settings.fetchModelsManualFallbackUnsupported": "This service may not support automatic model discovery. Enter model IDs manually below.",
"settings.fetchModelsManualFallbackNetwork": "Could not reach the model discovery endpoint. Check the API address or proxy; if you know the model IDs, enter them manually below.",
"settings.fetchModelsManualFallbackDecode": "The model discovery endpoint returned an unknown format. Enter model IDs manually below.",
"settings.fetchModelsManualFallbackEmpty": "No models were discovered automatically. Enter model IDs manually below.",
"settings.fetchModelsManualFallbackGeneric": "Could not fetch models automatically: {err}. Enter model IDs manually below.",
"settings.fetchModelsUpdatedForProvider": "Updated available models for {provider}, {n} total",
"settings.fetchModelsReadyForProvider": "Found {n} models for {provider}. Review and save the enabled list.",
"settings.enabledModelsSavedForProvider": "Saved {n} enabled models for {provider}",
@@ -1384,12 +1423,12 @@ export const en = {
"settings.fetchModelsAfterKeyFailed": "Key saved, but available models could not be refreshed automatically: {err}",
"settings.fetchModelsAfterKeyFailedForProvider": "Key saved, but available models for {provider} could not be refreshed: {err}",
"settings.newProviderDraftName": "new provider",
"settings.reasoningProtocol": "Reasoning protocol",
"settings.reasoningProtocol.auto": "Auto (detect from model)",
"settings.reasoningProtocol.deepseek": "DeepSeek",
"settings.reasoningProtocol.openai": "OpenAI",
"settings.reasoningProtocol.none": "None (off)",
"settings.reasoningProtocolHint": "Controls the request shape behind effort. Auto uses the model capability registry and official endpoints; switch to OpenAI or None if a proxy is incompatible.",
"settings.reasoningProtocol": "Model capability mode",
"settings.reasoningProtocol.auto": "Auto-detect (recommended)",
"settings.reasoningProtocol.deepseek": "DeepSeek thinking",
"settings.reasoningProtocol.openai": "OpenAI reasoning",
"settings.reasoningProtocol.none": "Plain chat (no reasoning fields)",
"settings.reasoningProtocolHint": "Auto-detect is best for most gateways. Switch only when a proxy is incompatible or the model documentation requires a specific reasoning shape.",
"settings.updateKey": "update {env} (saved globally)",
"settings.updateKeyAction": "Update key",
"settings.keyStatus": "Key",
@@ -1628,10 +1667,20 @@ export const en = {
"context.scopeGlobal": "Scope: Global",
"context.scopeProject": "Project scope · {name}",
"context.windowTitle": "Context window",
"context.windowSubtitle": "Current context window usage",
"context.windowStatusHealthy": "Context healthy",
"context.windowStatusWatch": "Compression soon",
"context.windowStatusPastCompact": "Compression threshold reached",
"context.windowStatusNearLimit": "Near context limit",
"context.windowUsageSummary": "{pct}% used",
"context.windowCompactRemaining": "{used} / {window} · {tokens} until compression",
"context.windowCompactDistance": "To compact",
"context.windowUsedLabel": "Used",
"context.windowUsed": "Used context",
"context.windowCompactThreshold": "Compression threshold",
"context.windowCompactHeadroom": "Before compression",
"context.sessionMetrics": "Session metrics",
"context.runtimeMetrics": "Runtime",
"context.costMetrics": "Cost",
"context.sessionStatus": "Session status",
"context.sessionChanges": "Session changes",
"context.referencedFiles": "Dependency files",
"context.noChanges": "No changed files in this session yet",
@@ -1639,34 +1688,28 @@ export const en = {
"context.changedMeta": "{count} change records",
"context.readMeta": "{count} read records",
"context.viewAll": "View all",
"context.health": "Context health",
"context.healthGood": "Context healthy",
"context.healthGoodShort": "Healthy",
"context.healthGoodBody": "Usage, source files, and session changes are traceable.",
"context.healthNearLimit": "Context near limit",
"context.healthNearLimitShort": "Near limit",
"context.healthNearLimitBody": "{pct}% used, keep critical sources and consider compaction.",
"context.healthManyFiles": "Many source files",
"context.healthManyFilesShort": "Many files",
"context.healthManyFilesBody": "{count} file records read, verify sources are still relevant.",
"context.healthLowCache": "Low cache hit",
"context.healthLowCacheShort": "Low cache",
"context.healthLowCacheBody": "Cache hit at {pct}%, watch context stability on long tasks.",
"context.prompt": "Prompt",
"context.completion": "Completion",
"context.reasoning": "Reasoning",
"context.other": "Other",
"context.total": "Total",
"context.usageAnalysis": "Usage analysis",
"context.usageAnalysisView": "Usage analysis view",
"context.usageAnalysisSource": "By source",
"context.usageAnalysisType": "By type",
"context.tokenBreakdown": "Token composition",
"context.cacheHit": "Cache hit",
"context.inputCache": "Input cache",
"context.inputCacheHit": "Input (cache hit)",
"context.inputCacheMiss": "Input (cache miss)",
"context.outputTokens": "Output tokens",
"context.sessionTokens": "Session tokens",
"context.sessionTokens": "Session total tokens",
"context.sessionTokensShort": "Total tokens",
"context.tokensValue": "{value} tokens",
"context.sessionCost": "Session cost",
"context.costBreakdown": "Cost breakdown",
"context.sourceBreakdown": "Model usage breakdown",
"context.sourceShareTokens": "By tokens",
"context.sourceBreakdown": "Usage source",
"context.sourceShareTitle": "Source share",
"context.sourceInput": "Input",
"context.sourceOutput": "Output",
"context.sourceCacheHit": "Hit",
@@ -1682,9 +1725,9 @@ export const en = {
"context.sourceClassifier": "Classifier",
"context.sourceTitle": "Title",
"context.sourceRequests": "{count} req",
"context.moreSources": "{count} more sources",
"context.requests": "Requests",
"context.time": "Time",
"context.compaction": "Compaction",
"context.time": "Runtime",
"context.durationSeconds": "{seconds}s",
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
+75 -32
View File
@@ -290,6 +290,17 @@ export const zhTW: Record<DictKey, string> = {
"composer.removeCurrentProjectDisabled": "不能移除當前專案",
"composer.resize": "拖動調整輸入區高度,雙擊重置",
"composer.send": "傳送(Enter)",
"composer.queueGuidance": "加入引導佇列(Enter)",
"composer.steer": "追加到目前任務(Enter)",
"composer.steerPlaceholder": "輸入要加入佇列的引導…",
"composer.guidanceQueue": "待處理引導",
"composer.guidanceCount": "待處理引導 {n}",
"composer.guidanceRemaining": "還有 {n} 條",
"composer.guidanceCollapse": "收起",
"composer.guidanceMode": "引導",
"composer.guidanceSend": "將這條引導加入資訊流",
"composer.guidanceDismiss": "移除這條引導提示",
"composer.guidanceMore": "更多引導選項",
"composer.stop": "停止(Esc)",
"composer.stopShort": "停止",
"composer.pastedLabel": "[已貼上文字 #{id} · {lines} 行]",
@@ -398,6 +409,11 @@ export const zhTW: Record<DictKey, string> = {
"heartbeat.approvalModeAskHint": "每次工具調用都需要手動確認",
"heartbeat.approvalModeAutoHint": "自動批准安全工具,保留拒絕規則",
"heartbeat.approvalModeYoloHint": "跳過所有工具權限提示,任務自動運行",
"heartbeat.notifyChannels": "推送到機器人",
"heartbeat.notifyChannelsOn": "開啟",
"heartbeat.notifyChannelsOff": "關閉",
"heartbeat.notifyChannelsOnHint": "AI 輸出會即時推送到已連線的飛書/QQ 機器人",
"heartbeat.notifyChannelsOffHint": "只建立桌面對話,不推送到機器人",
"heartbeat.fieldNewConversation": "對話模式",
"heartbeat.newConversationEachRunOn": "每次執行新建對話",
"heartbeat.newConversationEachRunOff": "複用同一對話",
@@ -441,6 +457,7 @@ export const zhTW: Record<DictKey, string> = {
"ask.hideDetails": "收起",
"ask.back": "返回",
"ask.next": "下一步",
"ask.customAnswer": "其他答案",
"ask.customPlaceholder": "輸入你自己的答案…",
"ask.justChat": "只是聊聊",
@@ -738,7 +755,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.providerModelsUrlPlaceholder": "例如 https://api.openai.com/v1/models",
"settings.providerModelsUrlHint": "可選。模型發現需要使用不同於聊天請求的位址時填寫。",
"settings.providerModels": "模型(逗號分隔)",
"settings.providerApiKeyEnv": "api_key_env(如 DEEPSEEK_API_KEY)",
"settings.providerApiKeyEnv": "api_key_env(可以不設定)",
"settings.providerBalanceUrl": "餘額查詢 URL(可選)",
"settings.balanceUrlPlaceholder": "餘額查詢介面(如 https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "可選。填了就會查詢並在狀態欄顯示錢包餘額。",
@@ -748,12 +765,22 @@ export const zhTW: Record<DictKey, string> = {
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto(按模型和端點自動判斷)",
"settings.providerReasoningProtocolHint": "用於 OpenAI-compatible 推理模型。deepseek 使用 DeepSeek reasoning 參數,openai 使用普通 reasoning_effort,none 表示停用。",
"settings.effortBehavior": "effort 行為",
"settings.effortUseProtocolDefaults": "協定預設",
"settings.effortUseCustom": "自訂覆蓋",
"settings.effortProtocolDefault.auto": "協定預設會把 supported_efforts 儲存為空。Reasonix 會把已知 DeepSeek 模型/端點識別為 high|max,把 OpenAI reasoning 識別為 low|medium|high,不支援的服務不暴露 /effort。",
"settings.effortProtocolDefault.deepseek": "DeepSeek 預設暴露 auto|high|max。Reasonix 會保持 thinking 啟用,並用 reasoning_effort 控制深度。",
"settings.effortProtocolDefault.openai": "OpenAI reasoning 預設暴露 auto|low|medium|high,並傳送普通 reasoning_effort。",
"settings.effortProtocolDefault.minimax": "MiniMax 風格端點使用 adaptive|disabled 這類服務商自訂 thinking 等級。只有端點文件明確支援時才新增自訂等級。",
"settings.effortProtocolDefault.none": "該供應商已停用 effort;Reasonix 不會傳送 reasoning_effort 或 thinking 控制參數。",
"settings.supportedEfforts": "支援的 effort 等級",
"settings.supportedEffortsCustomPlaceholder": "自訂等級(按 Enter)",
"settings.supportedEffortsHint": "勾選該模型服務在 /effort 命令中暴露的等級,或新增自訂名稱。留空則使用內建預設(DeepSeek:high|max,Anthropic:low|medium|high|xhigh|max)。",
"settings.supportedEffortsNoPresets": "沒有標準預設",
"settings.supportedEffortsNoPresetsHint": "僅在端點文件明確給出 effort 名稱時新增自訂等級。",
"settings.supportedEffortsHint": "自訂等級會覆蓋協定預設。只選擇端點文件支援的等級;不相容組合可能導致模型請求失敗。",
"settings.defaultEffort": "預設 effort",
"settings.defaultEffortAuto": "auto(使用內建預設)",
"settings.defaultEffortHint": "在 /effort 為 auto 或未設定時使用。必須是上方等級之一;為空時回退到第一個等級(無選中時回退到內建預設)。",
"settings.defaultEffortAuto": "未設定(使用協定/自訂預設)",
"settings.defaultEffortHint": "在 /effort 為 auto 或未設定時使用。留空則使用協定預設;自訂等級時回退到第一個自訂等級。",
"settings.proxyMode": "代理模式",
"settings.proxyMode.auto": "自動",
"settings.proxyMode.env": "環境變數",
@@ -801,6 +828,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.textSizeLarge": "大",
"settings.textSizeXLarge": "特大",
"settings.textSizeXXLarge": "超大",
"settings.displayZoom": "顯示縮放(需重啟生效)",
"settings.fontFamily": "介面字型",
"settings.fontFamilySystem": "系統預設",
"settings.fontFamilyYaHei": "微軟正黑體",
@@ -874,12 +902,23 @@ export const zhTW: Record<DictKey, string> = {
"settings.testFetchModels": "測試並取得模型",
"settings.testFetchModelsHint": "會使用上方 API 位址與金鑰確認連線,並用返回結果填充模型列表。",
"settings.manualModels": "模型列表",
"settings.manualModelsHint": "介面不支援模型發現時,可手動填寫多個模型,用英文逗號分隔。",
"settings.manualModelsHint": "介面不支援模型發現時,可手動填寫多個模型;下方可勾選哪些模型支援圖片輸入。",
"settings.visionModels": "支援圖片輸入的模型",
"settings.visionModelsHint": "啟用模型中可接收圖片輸入的子集,用英文逗號分隔。",
"settings.providerAdvancedSettings": "進階設定",
"settings.providerAdvancedSettings": "相容設定(通常不用改)",
"settings.providerAdvancedExpandHint": "點擊展開詳細設定",
"settings.providerAdvancedCollapseHint": "點擊收起",
"settings.providerHeaders": "額外請求標頭(可選)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "用於 OpenRouter 等要求站點來源的中轉站。一行一個 Header: value;API Key 仍透過上方金鑰保存。",
"settings.fetchingModels": "重新整理中…",
"settings.fetchModelsSuccess": "已取得 {n} 個模型",
"settings.fetchModelsManualFallbackAuth": "模型發現介面拒絕了金鑰或權限。請檢查 API Key;如果該服務不開放模型發現,也可以在下方手動填寫模型 ID。",
"settings.fetchModelsManualFallbackUnsupported": "這個服務可能不支援自動取得模型列表。可以直接在下方手動填寫模型 ID。",
"settings.fetchModelsManualFallbackNetwork": "暫時無法連接模型發現介面。請檢查 API 位址或代理;如果你知道模型 ID,也可以先手動填寫。",
"settings.fetchModelsManualFallbackDecode": "模型發現介面返回了無法識別的格式。可以直接在下方手動填寫模型 ID。",
"settings.fetchModelsManualFallbackEmpty": "沒有自動取得模型。可以直接在下方手動填寫模型 ID。",
"settings.fetchModelsManualFallbackGeneric": "無法自動取得模型:{err}。可以直接在下方手動填寫模型 ID。",
"settings.fetchModelsUpdatedForProvider": "已更新可用模型:{provider},共 {n} 個",
"settings.fetchModelsEmpty": "沒有取得可用模型",
"settings.fetchModelsEmptyForProvider": "{provider} 沒有返回可用模型",
@@ -887,12 +926,12 @@ export const zhTW: Record<DictKey, string> = {
"settings.fetchModelsAfterKeyFailed": "金鑰已儲存,但暫時無法自動重新整理可用模型:{err}",
"settings.fetchModelsAfterKeyFailedForProvider": "金鑰已儲存,但暫時無法重新整理 {provider} 的可用模型:{err}",
"settings.newProviderDraftName": "新供應商",
"settings.reasoningProtocol": "推理協定",
"settings.reasoningProtocol.auto": "Auto(按模型自動判斷)",
"settings.reasoningProtocol.deepseek": "DeepSeek",
"settings.reasoningProtocol.openai": "OpenAI",
"settings.reasoningProtocol.none": "None(關閉)",
"settings.reasoningProtocolHint": "控制 effort 的請求格式。Auto 會按模型能力表和官方端點識別;代理不相容時可切到 OpenAI 或 None。",
"settings.reasoningProtocol": "模型能力模式",
"settings.reasoningProtocol.auto": "自動識別(推薦)",
"settings.reasoningProtocol.deepseek": "DeepSeek 思考",
"settings.reasoningProtocol.openai": "OpenAI reasoning",
"settings.reasoningProtocol.none": "普通聊天(不傳送思考參數)",
"settings.reasoningProtocolHint": "預設自動識別。只有代理不相容,或你確定模型需要特定 reasoning 格式時再切換。",
"settings.updateKey": "更新 {env}(全域儲存)",
"settings.updateKeyAction": "更新金鑰",
"settings.keyStatus": "金鑰",
@@ -1067,27 +1106,23 @@ export const zhTW: Record<DictKey, string> = {
"context.changedMeta": "{count} 條變更記錄",
"context.readMeta": "{count} 條讀取記錄",
"context.viewAll": "檢視全部",
"context.health": "上下文狀態",
"context.healthGood": "上下文狀態正常",
"context.healthGoodBody": "用量、依據檔案和本會話變更保持可追蹤。",
"context.healthNearLimit": "上下文接近上限",
"context.healthNearLimitBody": "已使用 {pct}%,優先保留關鍵依據並考慮壓縮。",
"context.healthManyFiles": "依據檔案較多",
"context.healthManyFilesBody": "已讀取 {count} 條檔案記錄,可檢視依據檔案確認是否仍相關。",
"context.healthLowCache": "快取命中偏低",
"context.healthLowCacheBody": "當前快取命中 {pct}%,後續長任務可關注上下文穩定性。",
"context.prompt": "提示詞",
"context.completion": "回覆",
"context.reasoning": "推理",
"context.other": "其他",
"context.total": "總計",
"context.usageAnalysis": "用量分析",
"context.usageAnalysisView": "用量分析視圖",
"context.usageAnalysisSource": "按來源",
"context.usageAnalysisType": "按類型",
"context.tokenBreakdown": "Token 構成",
"context.cacheHit": "快取命中",
"context.inputCacheHit": "輸入 tokens(命中快取)",
"context.inputCacheMiss": "輸入 tokens(未命中快取)",
"context.outputTokens": "輸出 tokens",
"context.sessionCost": "本次費用",
"context.requests": "請求數",
"context.time": "耗時",
"context.time": "執行時間",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
@@ -1712,19 +1747,27 @@ export const zhTW: Record<DictKey, string> = {
"settings.metricsLabel": "共享聚合品質指標",
"settings.metricsHint": "預設開啟。傳送匿名的輪次結束統計、設定偏好快照,以及 Memory v5 聚合桶,例如是否注入、大小桶、IR 數量桶和記憶圖規模桶。包含用於 DAU 去重的隨機安裝 ID。Bucket 可能包含正規化後的自訂 Provider 名和模型名——絕不包含對話、提示詞、金鑰、路徑、base URL、記憶正文、工具輸出或檔案內容。",
"context.windowTitle": "上下文視窗",
"context.windowSubtitle": "當前上下文視窗佔用",
"context.windowStatusHealthy": "上下文充足",
"context.windowStatusWatch": "即將壓縮",
"context.windowStatusPastCompact": "已到壓縮閾值",
"context.windowStatusNearLimit": "接近視窗上限",
"context.windowUsageSummary": "{pct}% 已用",
"context.windowCompactRemaining": "{used} / {window} · 距壓縮還剩 {tokens}",
"context.windowCompactDistance": "距壓縮",
"context.windowUsedLabel": "已用",
"context.windowUsed": "已用上下文",
"context.windowCompactThreshold": "壓縮閾值",
"context.windowCompactHeadroom": "壓縮前餘量",
"context.sessionMetrics": "會話指標",
"context.runtimeMetrics": "執行指標",
"context.costMetrics": "成本",
"context.sessionStatus": "會話狀態",
"context.healthGoodShort": "正常",
"context.healthNearLimitShort": "接近上限",
"context.healthManyFilesShort": "檔案較多",
"context.healthLowCacheShort": "命中偏低",
"context.sessionTokens": "會話 tokens",
"context.sessionTokens": "會話累計 tokens",
"context.sessionTokensShort": "累計 tokens",
"context.tokensValue": "{value} tokens",
"context.inputCache": "輸入快取",
"context.costBreakdown": "費用來源",
"context.sourceBreakdown": "模型用量明細",
"context.sourceShareTokens": "按 tokens 佔比",
"context.sourceBreakdown": "用量來源",
"context.sourceShareTitle": "來源佔比",
"context.sourceInput": "輸入",
"context.sourceOutput": "輸出",
"context.sourceCacheHit": "命中",
@@ -1740,7 +1783,7 @@ export const zhTW: Record<DictKey, string> = {
"context.sourceClassifier": "計畫分類",
"context.sourceTitle": "標題產生",
"context.sourceRequests": "{count} 次",
"context.compaction": "壓縮狀態",
"context.moreSources": "還有 {count} 個來源",
"crash.send": "傳送報告",
"crash.sending": "傳送中…",
"crash.sent": "已傳送,謝謝!",
+75 -32
View File
@@ -412,6 +412,17 @@ export const zh: Record<DictKey, string> = {
"composer.removeCurrentProjectDisabled": "不能移除当前项目",
"composer.resize": "拖动调整输入区高度,双击重置",
"composer.send": "发送(Enter)",
"composer.queueGuidance": "加入引导队列(Enter)",
"composer.steer": "追加到当前任务(Enter)",
"composer.steerPlaceholder": "输入要加入队列的引导…",
"composer.guidanceQueue": "待处理引导",
"composer.guidanceCount": "待处理引导 {n}",
"composer.guidanceRemaining": "还有 {n} 条",
"composer.guidanceCollapse": "收起",
"composer.guidanceMode": "引导",
"composer.guidanceSend": "将这条引导加入信息流",
"composer.guidanceDismiss": "移除这条引导提示",
"composer.guidanceMore": "更多引导选项",
"composer.shellMode": "Shell 模式:给输入前加 !",
"composer.shellModeOn": "Shell 模式已开:点击移除 !",
"composer.stop": "停止(Esc)",
@@ -546,6 +557,11 @@ export const zh: Record<DictKey, string> = {
"heartbeat.approvalModeAskHint": "每次工具调用都需要手动确认",
"heartbeat.approvalModeAutoHint": "自动批准安全工具,保留拒绝规则",
"heartbeat.approvalModeYoloHint": "跳过所有工具权限提示,任务自动运行",
"heartbeat.notifyChannels": "推送到机器人",
"heartbeat.notifyChannelsOn": "开启",
"heartbeat.notifyChannelsOff": "关闭",
"heartbeat.notifyChannelsOnHint": "AI 输出会实时推送到已连接的飞书/QQ 机器人",
"heartbeat.notifyChannelsOffHint": "只创建桌面对话,不推送到机器人",
"heartbeat.fieldNewConversation": "对话模式",
"heartbeat.newConversationEachRunOn": "每次执行新建对话",
"heartbeat.newConversationEachRunOff": "复用同一对话",
@@ -590,6 +606,7 @@ export const zh: Record<DictKey, string> = {
"ask.hideDetails": "收起",
"ask.back": "返回",
"ask.next": "下一步",
"ask.customAnswer": "其它答案",
"ask.customPlaceholder": "输入你自己的答案…",
"ask.justChat": "只是聊聊",
@@ -1008,7 +1025,7 @@ export const zh: Record<DictKey, string> = {
"settings.providerModelsUrlPlaceholder": "例如 https://api.openai.com/v1/models",
"settings.providerModelsUrlHint": "可选。模型发现需要使用不同于聊天请求的地址时填写。",
"settings.providerModels": "模型(逗号分隔)",
"settings.providerApiKeyEnv": "api_key_env(如 DEEPSEEK_API_KEY)",
"settings.providerApiKeyEnv": "api_key_env(可以不设置)",
"settings.providerBalanceUrl": "余额查询 URL(可选)",
"settings.balanceUrlPlaceholder": "余额查询接口(如 https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "可选。填了就会查询并在状态栏显示钱包余额。",
@@ -1018,12 +1035,22 @@ export const zh: Record<DictKey, string> = {
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto(按模型和端点自动判断)",
"settings.providerReasoningProtocolHint": "用于 OpenAI-compatible 推理模型。deepseek 使用 DeepSeek reasoning 参数,openai 使用普通 reasoning_effort,none 表示禁用。",
"settings.effortBehavior": "effort 行为",
"settings.effortUseProtocolDefaults": "协议默认",
"settings.effortUseCustom": "自定义覆盖",
"settings.effortProtocolDefault.auto": "协议默认会把 supported_efforts 保存为空。Reasonix 会把已知 DeepSeek 模型/端点识别为 high|max,把 OpenAI reasoning 识别为 low|medium|high,不支持的服务不暴露 /effort。",
"settings.effortProtocolDefault.deepseek": "DeepSeek 默认暴露 auto|high|max。Reasonix 会保持 thinking 开启,并用 reasoning_effort 控制深度。",
"settings.effortProtocolDefault.openai": "OpenAI reasoning 默认暴露 auto|low|medium|high,并发送普通 reasoning_effort。",
"settings.effortProtocolDefault.minimax": "MiniMax 风格端点使用 adaptive|disabled 这类服务商自定义 thinking 等级。只有端点文档明确支持时才添加自定义等级。",
"settings.effortProtocolDefault.none": "该供应商已禁用 effort;Reasonix 不会发送 reasoning_effort 或 thinking 控制参数。",
"settings.supportedEfforts": "支持的 effort 等级",
"settings.supportedEffortsCustomPlaceholder": "自定义等级(按回车)",
"settings.supportedEffortsHint": "勾选该模型服务在 /effort 命令中暴露的等级,或添加自定义名称。留空则使用内置默认(DeepSeek:high|max,Anthropic:low|medium|high|xhigh|max)。",
"settings.supportedEffortsNoPresets": "没有标准预设",
"settings.supportedEffortsNoPresetsHint": "仅在端点文档明确给出 effort 名称时添加自定义等级。",
"settings.supportedEffortsHint": "自定义等级会覆盖协议默认。只选择端点文档支持的等级;不兼容组合可能导致模型请求失败。",
"settings.defaultEffort": "默认 effort",
"settings.defaultEffortAuto": "auto(使用内置默认)",
"settings.defaultEffortHint": "在 /effort 为 auto 或未设置时使用。必须是上方等级之一;为空时回退到第一个等级(无选中时回退到内置默认)。",
"settings.defaultEffortAuto": "未设置(使用协议/自定义默认)",
"settings.defaultEffortHint": "在 /effort 为 auto 或未设置时使用。留空则使用协议默认;自定义等级时回退到第一个自定义等级。",
"settings.proxyMode": "代理模式",
"settings.proxyMode.auto": "自动",
"settings.proxyMode.env": "环境变量",
@@ -1292,6 +1319,7 @@ export const zh: Record<DictKey, string> = {
"settings.textSizeLarge": "大",
"settings.textSizeXLarge": "特大",
"settings.textSizeXXLarge": "超大",
"settings.displayZoom": "显示缩放(需重启生效)",
"settings.fontFamily": "界面字体",
"settings.fontFamilySystem": "系统默认",
"settings.fontFamilyYaHei": "微软雅黑",
@@ -1371,12 +1399,23 @@ export const zh: Record<DictKey, string> = {
"settings.testFetchModels": "测试并获取模型",
"settings.testFetchModelsHint": "会使用上方 API 地址与密钥确认连接,并用返回结果填充模型列表。",
"settings.manualModels": "模型列表",
"settings.manualModelsHint": "接口不支持模型发现时,可手动填写多个模型,用英文逗号分隔。",
"settings.manualModelsHint": "接口不支持模型发现时,可手动填写多个模型;下方可勾选哪些模型支持图片输入。",
"settings.visionModels": "支持图片输入的模型",
"settings.visionModelsHint": "启用模型中可接收图片输入的子集,用英文逗号分隔。",
"settings.providerAdvancedSettings": "高级设置",
"settings.providerAdvancedSettings": "兼容设置(通常不用改)",
"settings.providerAdvancedExpandHint": "点击展开详细配置",
"settings.providerAdvancedCollapseHint": "点击收起",
"settings.providerHeaders": "额外请求头(可选)",
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
"settings.providerHeadersHint": "用于 OpenRouter 等要求站点来源的中转站。一行一个 Header: value;API Key 仍通过上方密钥保存。",
"settings.fetchingModels": "刷新中…",
"settings.fetchModelsSuccess": "已获取 {n} 个模型",
"settings.fetchModelsManualFallbackAuth": "模型发现接口拒绝了密钥或权限。请检查 API Key;如果该服务不开放模型发现,也可以在下方手动填写模型 ID。",
"settings.fetchModelsManualFallbackUnsupported": "这个服务可能不支持自动获取模型列表。可以直接在下方手动填写模型 ID。",
"settings.fetchModelsManualFallbackNetwork": "暂时无法连接模型发现接口。请检查 API 地址或代理;如果你知道模型 ID,也可以先手动填写。",
"settings.fetchModelsManualFallbackDecode": "模型发现接口返回了无法识别的格式。可以直接在下方手动填写模型 ID。",
"settings.fetchModelsManualFallbackEmpty": "没有自动获取到模型。可以直接在下方手动填写模型 ID。",
"settings.fetchModelsManualFallbackGeneric": "无法自动获取模型:{err}。可以直接在下方手动填写模型 ID。",
"settings.fetchModelsUpdatedForProvider": "已更新可用模型:{provider},共 {n} 个",
"settings.fetchModelsReadyForProvider": "已发现 {provider} 的 {n} 个模型,请确认后保存启用列表。",
"settings.enabledModelsSavedForProvider": "已保存 {provider} 的 {n} 个启用模型",
@@ -1386,12 +1425,12 @@ export const zh: Record<DictKey, string> = {
"settings.fetchModelsAfterKeyFailed": "密钥已保存,但暂时无法自动刷新可用模型:{err}",
"settings.fetchModelsAfterKeyFailedForProvider": "密钥已保存,但暂时无法刷新 {provider} 的可用模型:{err}",
"settings.newProviderDraftName": "新供应商",
"settings.reasoningProtocol": "推理协议",
"settings.reasoningProtocol.auto": "Auto(按模型自动判断)",
"settings.reasoningProtocol.deepseek": "DeepSeek",
"settings.reasoningProtocol.openai": "OpenAI",
"settings.reasoningProtocol.none": "None(关闭)",
"settings.reasoningProtocolHint": "控制 effort 的请求格式。Auto 会按模型能力表和官方端点识别;代理不兼容时可切到 OpenAI 或 None。",
"settings.reasoningProtocol": "模型能力模式",
"settings.reasoningProtocol.auto": "自动识别(推荐)",
"settings.reasoningProtocol.deepseek": "DeepSeek 思考",
"settings.reasoningProtocol.openai": "OpenAI reasoning",
"settings.reasoningProtocol.none": "普通聊天(不发送思考参数)",
"settings.reasoningProtocolHint": "默认自动识别。只有代理不兼容,或你确定模型需要特定 reasoning 格式时再切换。",
"settings.updateKey": "更新 {env}(全局保存)",
"settings.updateKeyAction": "更新密钥",
"settings.keyStatus": "密钥",
@@ -1630,10 +1669,20 @@ export const zh: Record<DictKey, string> = {
"context.scopeGlobal": "范围:全局",
"context.scopeProject": "所属项目 · {name}",
"context.windowTitle": "上下文窗口",
"context.windowSubtitle": "当前上下文窗口占用",
"context.windowStatusHealthy": "上下文充足",
"context.windowStatusWatch": "即将压缩",
"context.windowStatusPastCompact": "已到压缩阈值",
"context.windowStatusNearLimit": "接近窗口上限",
"context.windowUsageSummary": "{pct}% 已用",
"context.windowCompactRemaining": "{used} / {window} · 距压缩还剩 {tokens}",
"context.windowCompactDistance": "距压缩",
"context.windowUsedLabel": "已用",
"context.windowUsed": "已用上下文",
"context.windowCompactThreshold": "压缩阈值",
"context.windowCompactHeadroom": "压缩前余量",
"context.sessionMetrics": "会话指标",
"context.runtimeMetrics": "运行指标",
"context.costMetrics": "成本",
"context.sessionStatus": "会话状态",
"context.sessionChanges": "本会话变更",
"context.referencedFiles": "依赖文件",
"context.noChanges": "当前会话还没有变更文件",
@@ -1641,34 +1690,28 @@ export const zh: Record<DictKey, string> = {
"context.changedMeta": "{count} 条变更记录",
"context.readMeta": "{count} 条读取记录",
"context.viewAll": "查看全部",
"context.health": "上下文状态",
"context.healthGood": "上下文状态正常",
"context.healthGoodShort": "正常",
"context.healthGoodBody": "用量、依据文件和本会话变更保持可追踪。",
"context.healthNearLimit": "上下文接近上限",
"context.healthNearLimitShort": "接近上限",
"context.healthNearLimitBody": "已使用 {pct}%,优先保留关键依据并考虑压缩。",
"context.healthManyFiles": "依据文件较多",
"context.healthManyFilesShort": "文件较多",
"context.healthManyFilesBody": "已读取 {count} 条文件记录,可查看依据文件确认是否仍相关。",
"context.healthLowCache": "缓存命中偏低",
"context.healthLowCacheShort": "命中偏低",
"context.healthLowCacheBody": "当前缓存命中 {pct}%,后续长任务可关注上下文稳定性。",
"context.prompt": "提示词",
"context.completion": "回复",
"context.reasoning": "推理",
"context.other": "其他",
"context.total": "总计",
"context.usageAnalysis": "用量分析",
"context.usageAnalysisView": "用量分析视图",
"context.usageAnalysisSource": "按来源",
"context.usageAnalysisType": "按类型",
"context.tokenBreakdown": "Token 构成",
"context.cacheHit": "缓存命中",
"context.inputCache": "输入缓存",
"context.inputCacheHit": "输入 tokens(命中缓存)",
"context.inputCacheMiss": "输入 tokens(未命中缓存)",
"context.outputTokens": "输出 tokens",
"context.sessionTokens": "会话 tokens",
"context.sessionTokens": "会话累计 tokens",
"context.sessionTokensShort": "累计 tokens",
"context.tokensValue": "{value} tokens",
"context.sessionCost": "会话费用",
"context.costBreakdown": "费用来源",
"context.sourceBreakdown": "模型用量明细",
"context.sourceShareTokens": "按 tokens 占比",
"context.sourceBreakdown": "用量来源",
"context.sourceShareTitle": "来源占比",
"context.sourceInput": "输入",
"context.sourceOutput": "输出",
"context.sourceCacheHit": "命中",
@@ -1684,9 +1727,9 @@ export const zh: Record<DictKey, string> = {
"context.sourceClassifier": "计划分类",
"context.sourceTitle": "标题生成",
"context.sourceRequests": "{count} 次",
"context.moreSources": "还有 {count} 个来源",
"context.requests": "请求数",
"context.time": "耗时",
"context.compaction": "压缩状态",
"context.time": "运行时间",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
File diff suppressed because it is too large. Load diff
+50
View File
@@ -42,6 +42,7 @@ type HeartbeatTask struct {
ApprovalMode string `json:"approvalMode"` // "ask" | "auto" | "yolo"; empty defaults to "yolo"
TimeWindowStart string `json:"timeWindowStart,omitempty"` // "HH:MM" — interval tasks only run after this time (inclusive)
TimeWindowEnd string `json:"timeWindowEnd,omitempty"` // "HH:MM" — interval tasks only run before this time (exclusive)
NotifyChannels *bool `json:"notifyChannels,omitempty"` // true = push to bot channels; nil/false = skip
}
// heartbeatConfig is the on-disk format.
@@ -322,9 +323,21 @@ func (e *HeartbeatEngine) executeTask(t HeartbeatTask) HeartbeatTask {
t.ApprovalMode = mode
e.app.SetToolApprovalModeForTab(tabMeta.ID, mode)
// Attach bot event forwarding if the bot runtime is active and has
// session-mapped targets. The forwarder is set on the tab's event sink
// so AI output events are streamed to connected bot channels in
// real-time alongside the desktop UI.
botForwardAttached := false
if t.NotifyChannels != nil && *t.NotifyChannels {
botForwardAttached = e.attachBotForwarder(tabMeta.ID)
}
// Submit as a plain user turn so scheduled prompts cannot invoke desktop
// shell or slash-command handlers such as "!cmd", "/clear", or "/compact".
if !e.app.submitUserTurnToTab(tabMeta.ID, t.Prompt) {
if botForwardAttached {
e.clearBotForwarder(tabMeta.ID)
}
log.Printf("[heartbeat] submit skipped for %q", t.Title)
return t
}
@@ -806,3 +819,40 @@ func (a *App) HeartbeatGenerateID() string {
}
return string(b)
}
// attachBotForwarder sets up event forwarding to connected bot channels for
// the tab identified by tabID. It is called before submitting a heartbeat
// prompt. If the bot runtime is not active or has no session-mapped targets
// the call is a no-op.
func (e *HeartbeatEngine) attachBotForwarder(tabID string) bool {
runtime := e.app.botRuntime
if runtime == nil || !runtime.Running() {
return false
}
cfg, err := e.app.loadDesktopBotConfig()
if err != nil {
log.Printf("[heartbeat] load config for bot forward: %v", err)
return false
}
targets := runtime.ForwardTargets(cfg)
if len(targets) == 0 {
return false // no session-mapped channels to forward to
}
tab := e.app.tabByID(tabID)
if tab == nil || tab.sink == nil {
return false
}
log.Printf("[heartbeat] bot forwarding attached: %d target(s) for tab %s", len(targets), tabID)
forwarder := newBotEventForwarder(runtime, targets)
tab.sink.SetBotSink(forwarder)
return true
}
func (e *HeartbeatEngine) clearBotForwarder(tabID string) {
tab := e.app.tabByID(tabID)
if tab == nil || tab.sink == nil {
return
}
tab.sink.SetBotSink(nil)
}
+228
View File
@@ -9,9 +9,11 @@ import (
"unicode/utf8"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/store"
"reasonix/internal/tool"
)
@@ -828,6 +830,232 @@ func TestRebindTabToLoadedSessionReusesPreloadedTranscript(t *testing.T) {
}
}
func TestRebindTabToLoadedSessionPersistsAndRestoresSessionProfile(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
currentPath := filepath.Join(dir, "current.jsonl")
targetPath := filepath.Join(dir, "target.jsonl")
writeHistoryTestSession(t, currentPath, "current prompt")
writeHistoryTestSession(t, targetPath, "target prompt")
if err := agent.SaveBranchMetaPreserveUpdated(targetPath, agent.BranchMeta{
TokenMode: boot.TokenModeFull,
Mode: "yolo",
ToolApprovalMode: control.ToolApprovalYolo,
}); err != nil {
t.Fatalf("SaveBranchMetaPreserveUpdated target: %v", err)
}
loaded, err := agent.LoadSession(targetPath)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "current", Sink: event.Discard})
ctrl.SetMode(true, false)
ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
defer ctrl.Close()
app := NewApp()
tab := &WorkspaceTab{
ID: "tab",
Scope: "global",
WorkspaceRoot: root,
SessionPath: currentPath,
Ctrl: ctrl,
Ready: true,
tokenMode: boot.TokenModeEconomy,
mode: "plan",
toolApprovalMode: control.ToolApprovalAuto,
sink: &tabEventSink{tabID: "tab", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
if err := app.rebindTabToLoadedSessionPath(tab, targetPath, loaded); err != nil {
t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
}
currentMeta, ok, err := agent.LoadBranchMeta(currentPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
}
if currentMeta.TokenMode != boot.TokenModeEconomy || currentMeta.Mode != "plan" || currentMeta.ToolApprovalMode != control.ToolApprovalAuto {
t.Fatalf("current session profile = token:%q mode:%q approval:%q, want economy/plan/auto",
currentMeta.TokenMode, currentMeta.Mode, currentMeta.ToolApprovalMode)
}
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
t.Fatalf("rebound token mode = %q, want full", got)
}
if got := currentTabMode(tab); got != "yolo" {
t.Fatalf("rebound mode = %q, want yolo", got)
}
if got := currentTabToolApprovalMode(tab); got != control.ToolApprovalYolo {
t.Fatalf("rebound tool approval = %q, want yolo", got)
}
}
func TestCloseTabPersistsSessionProfileBeforeRemovingVisibleTab(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
currentPath := filepath.Join(dir, "profile.jsonl")
otherPath := filepath.Join(dir, "other.jsonl")
writeHistoryTestSession(t, currentPath, "profile prompt")
writeHistoryTestSession(t, otherPath, "other prompt")
if err := agent.SaveBranchMetaPreserveUpdated(currentPath, agent.BranchMeta{}); err != nil {
t.Fatalf("SaveBranchMetaPreserveUpdated current: %v", err)
}
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: currentPath, Label: "profile", Sink: event.Discard})
ctrl.SetMode(true, false)
ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
ctrl.SetGoal("finish the review")
app := NewApp()
tab := &WorkspaceTab{
ID: "profile",
Scope: "global",
WorkspaceRoot: root,
SessionPath: currentPath,
Ctrl: ctrl,
Ready: true,
tokenMode: boot.TokenModeEconomy,
mode: "plan",
toolApprovalMode: control.ToolApprovalAuto,
sink: &tabEventSink{tabID: "profile", app: app},
disabledMCP: map[string]ServerView{},
}
other := &WorkspaceTab{
ID: "other",
Scope: "global",
WorkspaceRoot: root,
SessionPath: otherPath,
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabs[tab.ID] = tab
app.tabs[other.ID] = other
app.tabOrder = []string{tab.ID, other.ID}
app.activeTabID = tab.ID
if err := app.CloseTab(tab.ID); err != nil {
t.Fatalf("CloseTab: %v", err)
}
meta, ok, err := agent.LoadBranchMeta(currentPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
}
if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "finish the review" {
t.Fatalf("closed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal",
meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal)
}
}
func TestKeepOnlyVisibleTabPersistsRemovedSessionProfile(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
keepPath := filepath.Join(dir, "keep.jsonl")
removedPath := filepath.Join(dir, "removed.jsonl")
writeHistoryTestSession(t, keepPath, "keep prompt")
writeHistoryTestSession(t, removedPath, "removed prompt")
if err := agent.SaveBranchMetaPreserveUpdated(removedPath, agent.BranchMeta{}); err != nil {
t.Fatalf("SaveBranchMetaPreserveUpdated removed: %v", err)
}
removedCtrl := control.New(control.Options{SessionDir: dir, SessionPath: removedPath, Label: "removed", Sink: event.Discard})
removedCtrl.SetMode(true, false)
removedCtrl.SetToolApprovalMode(control.ToolApprovalAuto)
removedCtrl.SetGoal("keep this profile")
app := NewApp()
keep := &WorkspaceTab{
ID: "keep",
Scope: "global",
WorkspaceRoot: root,
SessionPath: keepPath,
Ready: true,
disabledMCP: map[string]ServerView{},
}
removed := &WorkspaceTab{
ID: "removed",
Scope: "global",
WorkspaceRoot: root,
SessionPath: removedPath,
Ctrl: removedCtrl,
Ready: true,
tokenMode: boot.TokenModeEconomy,
mode: "plan",
toolApprovalMode: control.ToolApprovalAuto,
sink: &tabEventSink{tabID: "removed", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs[keep.ID] = keep
app.tabs[removed.ID] = removed
app.tabOrder = []string{keep.ID, removed.ID}
app.activeTabID = removed.ID
if _, err := app.keepOnlyVisibleTab(keep.ID); err != nil {
t.Fatalf("keepOnlyVisibleTab: %v", err)
}
meta, ok, err := agent.LoadBranchMeta(removedPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta removed ok=%v err=%v", ok, err)
}
if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "keep this profile" {
t.Fatalf("removed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal",
meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal)
}
}
func TestLoadTabSessionProfileIgnoresTerminalGoalState(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sessionPath := filepath.Join(dir, "terminal-goal.jsonl")
writeHistoryTestSession(t, sessionPath, "terminal prompt")
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
TokenMode: boot.TokenModeEconomy,
Mode: "plan",
ToolApprovalMode: control.ToolApprovalAuto,
Goal: "stale terminal goal",
}); err != nil {
t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
}
if err := os.WriteFile(store.SessionGoalState(sessionPath), []byte(`{"goal":"stale terminal goal","status":"complete"}`), 0o644); err != nil {
t.Fatalf("write goal state: %v", err)
}
profile := loadTabSessionProfile(sessionPath)
if profile.goal != "" {
t.Fatalf("loaded profile goal = %q, want terminal goal ignored", profile.goal)
}
if profile.tokenMode != boot.TokenModeEconomy || profile.mode != "plan" || profile.toolApprovalMode != control.ToolApprovalAuto {
t.Fatalf("loaded profile = token:%q mode:%q approval:%q, want economy/plan/auto",
profile.tokenMode, profile.mode, profile.toolApprovalMode)
}
}
func writeHistoryTestSession(t *testing.T, path, prompt string) {
t.Helper()
session := agent.NewSession("")
+9
View File
@@ -10,6 +10,7 @@ import (
"embed"
"os"
"path/filepath"
goruntime "runtime"
"strings"
"github.com/wailsapp/wails/v2"
@@ -102,10 +103,17 @@ func main() {
}
}
// Restore saved desktop zoom factor (WebView2 ZoomFactor), or default to 1.0.
zoomFactor := 1.0
if zf, ok := loadZoomFactor(); ok && zf > 0 {
zoomFactor = zf
}
err := wails.Run(&options.App{
Title: "Reasonix",
Width: width,
Height: height,
Frameless: goruntime.GOOS == "windows",
MinWidth: 760,
MinHeight: 480,
// Match the dark UI shell so the initial webview background doesn't flash
@@ -144,6 +152,7 @@ func main() {
// Follow the OS theme so the title bar matches light/dark system
// preference instead of being locked to dark.
Theme: windows.SystemDefault,
ZoomFactor: zoomFactor,
WebviewGpuIsDisabled: windowsWebview2GPUDisabled(),
},
Linux: &linux.Options{
+128 -26
View File
@@ -3,9 +3,11 @@ package main
import (
"context"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -27,28 +29,38 @@ import (
// --- read ---
type ProviderView struct {
Name string `json:"name"`
BuiltIn bool `json:"builtIn"`
Added bool `json:"added"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
ChatURL string `json:"chatUrl"`
Models []string `json:"models"`
VisionModels []string `json:"visionModels"`
VisionModelsSet bool `json:"visionModelsConfigured"`
ModelsURL string `json:"modelsUrl"`
Default string `json:"default"`
APIKeyEnv string `json:"apiKeyEnv"`
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
RequiresKey bool `json:"requiresKey"`
Configured bool `json:"configured"` // selectable: either key is present or no key is required
KeySource string `json:"keySource,omitempty"`
KeySourcePath string `json:"keySourcePath,omitempty"`
BalanceURL string `json:"balanceUrl"`
ContextWindow int `json:"contextWindow"`
Name string `json:"name"`
BuiltIn bool `json:"builtIn"`
Added bool `json:"added"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
ChatURL string `json:"chatUrl"`
Models []string `json:"models"`
VisionModels []string `json:"visionModels"`
VisionModelsSet bool `json:"visionModelsConfigured"`
ModelsURL string `json:"modelsUrl"`
Default string `json:"default"`
APIKeyEnv string `json:"apiKeyEnv"`
Headers map[string]string `json:"headers"`
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
RequiresKey bool `json:"requiresKey"`
Configured bool `json:"configured"` // selectable: either key is present or no key is required
KeySource string `json:"keySource,omitempty"`
KeySourcePath string `json:"keySourcePath,omitempty"`
BalanceURL string `json:"balanceUrl"`
ContextWindow int `json:"contextWindow"`
ReasoningProtocol string `json:"reasoningProtocol"`
SupportedEfforts []string `json:"supportedEfforts"`
DefaultEffort string `json:"defaultEffort"`
ModelOverrides []ProviderModelOverrideView `json:"modelOverrides"`
}
type ProviderModelOverrideView struct {
Model string `json:"model"`
ReasoningProtocol string `json:"reasoningProtocol"`
SupportedEfforts []string `json:"supportedEfforts"`
DefaultEffort string `json:"defaultEffort"`
Vision *bool `json:"vision"`
}
type PermissionsView struct {
@@ -205,6 +217,78 @@ func nonNil(s []string) []string {
return s
}
func nonNilStringMap(m map[string]string) map[string]string {
if m == nil {
return map[string]string{}
}
return m
}
func providerModelOverridesForView(overrides map[string]config.ProviderModelOverride, models []string) []ProviderModelOverrideView {
if len(overrides) == 0 {
return []ProviderModelOverrideView{}
}
modelSet := map[string]bool{}
for _, model := range models {
modelSet[model] = true
}
keys := make([]string, 0, len(overrides))
for model := range overrides {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if len(modelSet) > 0 && !modelSet[model] {
continue
}
keys = append(keys, model)
}
sort.Strings(keys)
out := make([]ProviderModelOverrideView, 0, len(keys))
for _, model := range keys {
ov := overrides[model]
out = append(out, ProviderModelOverrideView{
Model: model,
ReasoningProtocol: ov.ReasoningProtocol,
SupportedEfforts: nonNil(ov.SupportedEfforts),
DefaultEffort: ov.DefaultEffort,
Vision: ov.Vision,
})
}
return out
}
func providerModelOverridesForSave(overrides []ProviderModelOverrideView, models []string) map[string]config.ProviderModelOverride {
if len(overrides) == 0 {
return nil
}
modelSet := map[string]bool{}
for _, model := range models {
modelSet[model] = true
}
out := map[string]config.ProviderModelOverride{}
for _, item := range overrides {
model := strings.TrimSpace(item.Model)
if model == "" || (len(modelSet) > 0 && !modelSet[model]) {
continue
}
ov := config.ProviderModelOverride{
ReasoningProtocol: strings.TrimSpace(item.ReasoningProtocol),
SupportedEfforts: nonNil(item.SupportedEfforts),
DefaultEffort: strings.TrimSpace(item.DefaultEffort),
Vision: item.Vision,
}
if strings.TrimSpace(ov.ReasoningProtocol) == "" && len(ov.SupportedEfforts) == 0 && strings.TrimSpace(ov.DefaultEffort) == "" && ov.Vision == nil {
continue
}
out[model] = ov
}
if len(out) == 0 {
return nil
}
return out
}
func providerRemovalFallbackRef(c *config.Config, name string) string {
for i := range c.Providers {
p := &c.Providers[i]
@@ -310,6 +394,7 @@ func providerViewFromEntryForRootWithResolver(p config.ProviderEntry, builtIn, a
Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL,
Models: nonNil(models), VisionModels: nonNil(providerVisionModels(models, visionModels)), VisionModelsSet: visionModelsSet, ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
APIKeyEnv: p.APIKeyEnv,
Headers: nonNilStringMap(p.Headers),
KeySet: key.Set,
RequiresKey: requiresKey,
Configured: !requiresKey || key.Set,
@@ -320,6 +405,7 @@ func providerViewFromEntryForRootWithResolver(p config.ProviderEntry, builtIn, a
ReasoningProtocol: p.ReasoningProtocol,
SupportedEfforts: nonNil(p.SupportedEfforts),
DefaultEffort: p.DefaultEffort,
ModelOverrides: providerModelOverridesForView(p.ModelOverrides, models),
}
}
@@ -873,7 +959,9 @@ func (a *App) rebuild() error {
if prevPath == "" {
prevPath = tab.Ctrl.SessionPath()
}
_ = a.snapshotTab(tab)
if err := a.snapshotTabForAction(tab, "rebuilding settings"); err != nil {
return err
}
carried = tab.Ctrl.History()
tab.Ctrl.Close()
}
@@ -1214,6 +1302,7 @@ func (a *App) SaveProvider(p ProviderView) error {
e.ChatURL = strings.TrimSpace(p.ChatURL)
e.ModelsURL = strings.TrimSpace(p.ModelsURL)
e.APIKeyEnv = p.APIKeyEnv
e.Headers = p.Headers
e.BalanceURL = strings.TrimSpace(p.BalanceURL)
e.ContextWindow = p.ContextWindow
e.ReasoningProtocol = p.ReasoningProtocol
@@ -1227,6 +1316,7 @@ func (a *App) SaveProvider(p ProviderView) error {
if len(models) > 0 {
e.Model = models[0] // also satisfies validateProvider's model requirement
e.Models = models
e.ModelOverrides = providerModelOverridesForSave(p.ModelOverrides, models)
if p.VisionModelsSet || len(p.VisionModels) > 0 {
e.Vision = false
e.VisionModels = providerVisionModels(models, p.VisionModels)
@@ -1237,6 +1327,7 @@ func (a *App) SaveProvider(p ProviderView) error {
} else {
e.Vision = false
e.VisionModels = nil
e.ModelOverrides = nil
}
if err := c.UpsertProvider(e); err != nil {
return err
@@ -1294,6 +1385,7 @@ func (a *App) FetchProviderModels(p ProviderView) ([]string, error) {
BaseURL: p.BaseURL,
ModelsURL: strings.TrimSpace(p.ModelsURL),
APIKeyEnv: p.APIKeyEnv,
Headers: p.Headers,
}
e.ResolveAPIKeyForRoot(a.activeWorkspaceRoot())
ctx, cancel := context.WithTimeout(a.reqCtx(), 15*time.Second)
@@ -1408,6 +1500,14 @@ func (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {
return err
}
}
for _, item := range affected {
if item.ctrl != nil && !item.readOnly {
if err := item.ctrl.Snapshot(); err != nil {
slog.Warn("desktop: snapshot before removing provider access failed", "tab", item.id, "provider", name, "err", err)
return fmt.Errorf("save current session before removing provider access: %w", err)
}
}
}
retargetProviderReferences(cfg, name, fallbackRef)
removeProviderAccess(cfg, name)
if err := cfg.SaveTo(path); err != nil {
@@ -1418,9 +1518,6 @@ func (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {
}
for _, item := range affected {
if item.ctrl != nil {
if !item.readOnly {
_ = item.ctrl.Snapshot()
}
item.ctrl.Close()
}
}
@@ -1491,6 +1588,14 @@ func (a *App) deleteProviderAndRetargetTabs(name string) error {
return err
}
}
for _, item := range affected {
if item.ctrl != nil && !item.readOnly {
if err := item.ctrl.Snapshot(); err != nil {
slog.Warn("desktop: snapshot before deleting provider failed", "tab", item.id, "provider", name, "err", err)
return fmt.Errorf("save current session before deleting provider: %w", err)
}
}
}
if err := cfg.RemoveProvider(name); err != nil {
return err
}
@@ -1504,9 +1609,6 @@ func (a *App) deleteProviderAndRetargetTabs(name string) error {
}
for _, item := range affected {
if item.ctrl != nil {
if !item.readOnly {
_ = item.ctrl.Snapshot()
}
item.ctrl.Close()
}
}
+63
View File
@@ -427,6 +427,69 @@ func TestSaveProviderPersistsCustomEndpointURLs(t *testing.T) {
t.Fatalf("Settings providers missing sub2api: %+v", view.Providers)
}
func TestSaveProviderPreservesHiddenProviderFields(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Providers = []config.ProviderEntry{{
Name: "custom",
Kind: "openai",
BaseURL: "https://proxy.example.com/v1",
Models: []string{"model-a", "model-b"},
Default: "model-a",
APIKeyEnv: "CUSTOM_API_KEY",
Price: &provider.Pricing{Input: 1, Output: 2, Currency: "$"},
Prices: map[string]*provider.Pricing{"model-b": {Input: 3, Output: 4, Currency: "$"}},
Thinking: "adaptive",
Effort: "high",
VisionDetail: "low",
NoProxy: true,
}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("SaveTo: %v", err)
}
app := NewApp()
settings := app.Settings()
var view ProviderView
found := false
for _, p := range settings.Providers {
if p.Name == "custom" {
view = p
found = true
break
}
}
if !found {
t.Fatalf("Settings providers missing custom: %+v", settings.Providers)
}
if err := app.SaveProvider(view); err != nil {
t.Fatalf("SaveProvider: %v", err)
}
gotCfg := config.LoadForEdit(config.UserConfigPath())
got, ok := gotCfg.Provider("custom")
if !ok {
t.Fatal("saved provider not found")
}
if got.Price == nil || got.Price.Input != 1 || got.Price.Output != 2 || got.Price.Currency != "$" {
t.Fatalf("provider-wide price = %+v, want preserved", got.Price)
}
if got.Prices["model-b"] == nil || got.Prices["model-b"].Input != 3 || got.Prices["model-b"].Output != 4 || got.Prices["model-b"].Currency != "$" {
t.Fatalf("per-model prices = %+v, want model-b price preserved", got.Prices)
}
if got.Thinking != "adaptive" || got.Effort != "high" {
t.Fatalf("thinking/effort = %q/%q, want adaptive/high", got.Thinking, got.Effort)
}
if got.VisionDetail != "low" {
t.Fatalf("vision_detail = %q, want low", got.VisionDetail)
}
if !got.NoProxy {
t.Fatal("no_proxy = false, want preserved true")
}
}
func TestSaveProviderClearsProviderWideVisionForPerModelSelection(t *testing.T) {
isolateDesktopUserDirs(t)
+34
View File
@@ -10,6 +10,40 @@ import (
"reasonix/internal/event"
)
type closeTrackingSink struct {
closed atomic.Bool
}
func (s *closeTrackingSink) Emit(event.Event) {}
func (s *closeTrackingSink) Close() {
s.closed.Store(true)
}
func TestTabEventSinkSetBotSinkClosesPreviousSink(t *testing.T) {
sink := &tabEventSink{}
first := &closeTrackingSink{}
second := &closeTrackingSink{}
sink.SetBotSink(first)
if first.closed.Load() {
t.Fatal("newly attached sink was closed")
}
sink.SetBotSink(second)
if !first.closed.Load() {
t.Fatal("previous sink was not closed when replaced")
}
if second.closed.Load() {
t.Fatal("replacement sink was closed too early")
}
sink.SetBotSink(nil)
if !second.closed.Load() {
t.Fatal("second sink was not closed when cleared")
}
}
func TestTabEventSinkDoesNotBlockOnRuntimeEventsEmit(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
+171
View File
@@ -432,6 +432,177 @@ func TestMetaReportsGoalStatus(t *testing.T) {
}
}
func TestAutoResearchStatusSurfaceForActiveTab(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
app := NewApp()
tab := testTab("a", root)
tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root})
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
defer tab.Ctrl.Close()
tab.Ctrl.SetGoalWithResearchMode("identify the root cause", control.GoalResearchOn)
meta := app.MetaForTab(tab.ID)
if meta.AutoResearch == nil || meta.AutoResearch.TaskID == "" {
t.Fatalf("MetaForTab AutoResearch = %+v, want compact active task summary", meta.AutoResearch)
}
if meta.AutoResearch.Status != control.GoalStatusRunning || meta.AutoResearch.Iteration != 0 {
t.Fatalf("compact AutoResearch summary = %+v", meta.AutoResearch)
}
current := app.AutoResearchCurrent()
if current.TaskID != meta.AutoResearch.TaskID || current.Goal != "identify the root cause" {
t.Fatalf("AutoResearchCurrent = %+v, want task %q", current, meta.AutoResearch.TaskID)
}
if current.TaskPath == "" || current.Status != control.GoalStatusRunning {
t.Fatalf("AutoResearchCurrent missing status/path: %+v", current)
}
heartbeatPath := filepath.Join(root, ".reasonix", "autoresearch", current.TaskID, "logs", "heartbeat.jsonl")
if err := os.WriteFile(heartbeatPath, []byte(`{"status":"turn_done","iteration":1,"created_at":"2026-06-30T00:00:00Z"}`+"\n"), 0o644); err != nil {
t.Fatalf("write heartbeat: %v", err)
}
current = app.AutoResearchCurrent()
if current.LastHeartbeatAt != "2026-06-30T00:00:00Z" || current.NextRequiredAction == "" {
t.Fatalf("AutoResearchCurrent missing runtime fields: %+v", current)
}
tabs := app.ListTabs()
if len(tabs) != 1 || tabs[0].AutoResearch == nil || tabs[0].AutoResearch.TaskID != current.TaskID {
t.Fatalf("ListTabs AutoResearch = %+v, want compact summary for task %q", tabs, current.TaskID)
}
}
func TestAutoResearchFindingsAreLoadedOnDemand(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
app := NewApp()
tab := testTab("a", root)
tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root})
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
defer tab.Ctrl.Close()
tab.Ctrl.SetGoalWithResearchMode("collect accepted findings", control.GoalResearchOn)
current := app.AutoResearchCurrent()
if current.TaskID == "" {
t.Fatal("expected active AutoResearch task")
}
findingsPath := filepath.Join(root, ".reasonix", "autoresearch", current.TaskID, "state", "findings.jsonl")
if err := os.WriteFile(findingsPath, []byte(
`{"id":"f1","kind":"test","summary":"old","source":"command","command":"go test ./...","accepted":true,"created_at":"2026-06-29T10:00:00Z"}`+"\n"+
`{"id":"f2","kind":"review","summary":"new","source":"manual","accepted":true,"created_at":"2026-06-29T11:00:00Z"}`+"\n",
), 0o644); err != nil {
t.Fatalf("write findings: %v", err)
}
findings := app.AutoResearchFindings(tab.ID, 1)
if len(findings) != 1 || findings[0].ID != "f2" || findings[0].Summary != "new" {
t.Fatalf("AutoResearchFindings = %+v, want newest capped finding", findings)
}
if meta := app.MetaForTab(tab.ID); meta.AutoResearch == nil || meta.AutoResearch.TaskID != current.TaskID {
t.Fatalf("MetaForTab compact summary lost task id: %+v", meta.AutoResearch)
}
}
func TestAutoResearchListReturnsWorkspaceTasks(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
app := NewApp()
tab := testTab("a", root)
tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root})
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
defer tab.Ctrl.Close()
tab.Ctrl.SetGoalWithResearchMode("list active research task", control.GoalResearchOn)
list := app.AutoResearchList(tab.ID)
if len(list) != 1 || list[0].TaskID == "" || list[0].Goal != "list active research task" {
t.Fatalf("AutoResearchList = %+v, want active workspace task", list)
}
}
func TestAutoResearchOpenTaskRevealsTaskDirectory(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
app := NewApp()
tab := testTab("a", root)
tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root})
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
defer tab.Ctrl.Close()
tab.Ctrl.SetGoalWithResearchMode("open task folder", control.GoalResearchOn)
current := app.AutoResearchCurrent()
var revealed string
oldReveal := revealPath
revealPath = func(path string) error {
revealed = path
return nil
}
defer func() { revealPath = oldReveal }()
if err := app.AutoResearchOpenTask(tab.ID); err != nil {
t.Fatalf("AutoResearchOpenTask: %v", err)
}
if revealed != current.TaskPath {
t.Fatalf("revealed path = %q, want task path %q", revealed, current.TaskPath)
}
}
func TestAutoResearchRecordEvidenceThroughDesktopAPI(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
app := NewApp()
tab := testTab("a", root)
tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root})
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
defer tab.Ctrl.Close()
tab.Ctrl.SetGoalWithResearchMode("record evidence through desktop", control.GoalResearchOn)
err := app.AutoResearchRecordEvidence(tab.ID, "objective_evidence", AutoResearchEvidenceView{
ID: "f1",
Kind: "test",
Summary: "desktop evidence recorded",
Source: "manual",
Accepted: true,
})
if err != nil {
t.Fatalf("AutoResearchRecordEvidence: %v", err)
}
findings := app.AutoResearchFindings(tab.ID, 10)
if len(findings) != 1 || findings[0].ID != "f1" {
t.Fatalf("findings = %+v, want f1", findings)
}
}
func TestMetaReportsStoredCollaborationModeWhileControllerRebuilds(t *testing.T) {
isolateDesktopUserDirs(t)
+608 -246
View File
File diff suppressed because it is too large. Load diff
+248 -5
View File
@@ -1956,7 +1956,7 @@ func TestTrashTopicCancelsRunningSessionRuntime(t *testing.T) {
}
}
func TestTrashTopicFallbackCreatesIndexedTopic(t *testing.T) {
func TestTrashTopicFallbackCreatesUnindexedBlank(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
@@ -1998,14 +1998,257 @@ func TestTrashTopicFallbackCreatesIndexedTopic(t *testing.T) {
t.Fatalf("fallback should create exactly one visible tab, got %d", len(app.tabs))
}
for id, tab := range app.tabs {
if strings.TrimSpace(tab.TopicID) == "" {
t.Fatalf("fallback tab %q has empty topic ID", id)
if strings.TrimSpace(tab.TopicID) != "" {
t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
}
if strings.TrimSpace(tab.SessionPath) == "" {
t.Fatalf("fallback tab %q has no precreated session path", id)
}
f := loadProjectsFile()
if len(f.Projects) != 1 || !containsDesktopString(f.Projects[0].Topics, tab.TopicID) {
t.Fatalf("fallback topic %q was not indexed in project topics %#v", tab.TopicID, f.Projects)
if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, topicID) {
t.Fatalf("deleted topic %q should be removed without indexing a replacement: %#v", topicID, f.Projects)
}
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || len(nodes[0].Children) != 0 {
t.Fatalf("transient fallback blank should stay out of project tree: %+v", nodes)
}
}
func TestTransientFallbackIndexesOnFirstUserTurn(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_only"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Only topic"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSession(t, dir, "only-topic.jsonl", topicID, "Only topic", projectRoot)
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
defer ctrl.Close()
app := &App{
tabs: map[string]*WorkspaceTab{
"only": {
ID: "only",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Only topic",
Ctrl: ctrl,
Ready: true,
disabledMCP: map[string]ServerView{},
},
},
tabOrder: []string{"only"},
activeTabID: "only",
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("TrashTopic: %v", err)
}
var fallback *WorkspaceTab
for _, tab := range app.tabs {
fallback = tab
}
if fallback == nil {
t.Fatal("fallback tab missing")
}
if fallback.TopicID != "" {
t.Fatalf("fallback topic before first turn = %q, want empty", fallback.TopicID)
}
app.ensureTabTopicIndexedForUserTurn(fallback)
if strings.TrimSpace(fallback.TopicID) == "" {
t.Fatal("first user turn should assign a topic ID")
}
f := loadProjectsFile()
if len(f.Projects) != 1 || !containsDesktopString(f.Projects[0].Topics, fallback.TopicID) {
t.Fatalf("first user turn should index fallback topic %q: %#v", fallback.TopicID, f.Projects)
}
meta, ok, err := agent.LoadBranchMeta(fallback.SessionPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta(%q): ok=%v err=%v", fallback.SessionPath, ok, err)
}
if meta.TopicID != fallback.TopicID || meta.TopicTitle != defaultTopicTitle {
t.Fatalf("fallback session meta = %+v, want topic %q title %q", meta, fallback.TopicID, defaultTopicTitle)
}
}
func TestTransientFallbackDiscardedWhenSingleSurfaceNavigatesAway(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
dir := desktopSessionDir(projectRoot)
transientPath, err := createEmptySessionFile(dir, "test")
if err != nil {
t.Fatalf("create transient session: %v", err)
}
if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
Scope: "project",
WorkspaceRoot: projectRoot,
}); err != nil {
t.Fatalf("save transient meta: %v", err)
}
targetTopicID := "topic_target"
if err := setTopicTitle(projectRoot, targetTopicID, "Target"); err != nil {
t.Fatalf("set target topic title: %v", err)
}
targetPath := writeTopicSession(t, dir, "target.jsonl", targetTopicID, "Target", projectRoot)
app := &App{
tabs: map[string]*WorkspaceTab{
"transient": {
ID: "transient",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicTitle: defaultTopicTitle,
SessionPath: transientPath,
Ready: true,
disabledMCP: map[string]ServerView{},
},
"target": {
ID: "target",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: targetTopicID,
TopicTitle: "Target",
SessionPath: targetPath,
Ready: true,
disabledMCP: map[string]ServerView{},
},
},
tabOrder: []string{"transient", "target"},
activeTabID: "transient",
}
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
t.Fatalf("keepOnlyVisibleTab: %v", err)
}
if _, ok := app.tabs["transient"]; ok {
t.Fatal("transient tab should be removed after single-surface navigation")
}
if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
t.Fatalf("transient session artifact should be removed, stat err = %v", err)
}
if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
t.Fatalf("transient session meta should be removed, stat err = %v", err)
}
f := loadProjectsFile()
if len(f.Projects) != 1 || containsDesktopString(f.Projects[0].Topics, "") {
t.Fatalf("transient blank should not be indexed in project topics: %#v", f.Projects)
}
}
func TestCloseTabDiscardsUnusedTransientBlankSession(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
dir := desktopSessionDir(projectRoot)
transientPath, err := createEmptySessionFile(dir, "test")
if err != nil {
t.Fatalf("create transient session: %v", err)
}
if err := agent.SaveBranchMetaPreserveUpdated(transientPath, agent.BranchMeta{
Scope: "project",
WorkspaceRoot: projectRoot,
}); err != nil {
t.Fatalf("save transient meta: %v", err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"transient": {
ID: "transient",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicTitle: defaultTopicTitle,
SessionPath: transientPath,
Ready: true,
disabledMCP: map[string]ServerView{},
},
"other": {
ID: "other",
Scope: "global",
TopicID: "topic_other",
TopicTitle: "Other",
Ready: true,
disabledMCP: map[string]ServerView{},
},
},
tabOrder: []string{"transient", "other"},
activeTabID: "transient",
}
if err := app.CloseTab("transient"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
if _, ok := app.tabs["transient"]; ok {
t.Fatal("transient tab should be closed")
}
if _, err := os.Stat(transientPath); !os.IsNotExist(err) {
t.Fatalf("transient session artifact should be removed, stat err = %v", err)
}
if _, err := os.Stat(agent.BranchMetaPath(transientPath)); !os.IsNotExist(err) {
t.Fatalf("transient session meta should be removed, stat err = %v", err)
}
}
func TestCloseTabKeepsIndexedBlankSession(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_indexed_blank"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, defaultTopicTitle); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := desktopSessionDir(projectRoot)
indexedPath, err := createEmptySessionFile(dir, "test")
if err != nil {
t.Fatalf("create indexed blank session: %v", err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"indexed": {
ID: "indexed",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: defaultTopicTitle,
SessionPath: indexedPath,
Ready: true,
disabledMCP: map[string]ServerView{},
},
"other": {
ID: "other",
Scope: "global",
TopicID: "topic_other",
TopicTitle: "Other",
Ready: true,
disabledMCP: map[string]ServerView{},
},
},
tabOrder: []string{"indexed", "other"},
activeTabID: "indexed",
}
if err := app.CloseTab("indexed"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
if _, err := os.Stat(indexedPath); err != nil {
t.Fatalf("indexed blank session should be preserved, stat err = %v", err)
}
}
func TestTrashTopicTrashConflictKeepsRunningRuntime(t *testing.T) {
+41
View File
@@ -0,0 +1,41 @@
package main
import "github.com/wailsapp/wails/v2/pkg/runtime"
// MinimiseMainWindow backs the Windows frameless titlebar controls.
func (a *App) MinimiseMainWindow() {
if a.ctx == nil {
return
}
runtime.WindowMinimise(a.ctx)
}
// ToggleMaximiseMainWindow backs the Windows frameless titlebar controls.
func (a *App) ToggleMaximiseMainWindow() {
if a.ctx == nil {
return
}
runtime.WindowToggleMaximise(a.ctx)
}
// IsMainWindowMaximised reports the native maximise state for the Windows
// frameless titlebar controls.
func (a *App) IsMainWindowMaximised() bool {
if a.ctx == nil {
return false
}
return runtime.WindowIsMaximised(a.ctx)
}
// CloseMainWindow preserves Reasonix's configured close behavior for the
// Windows frameless titlebar close button.
func (a *App) CloseMainWindow() {
if a.ctx == nil {
return
}
if a.beforeClose(a.ctx) {
return
}
a.forceQuit.Store(true)
runtime.Quit(a.ctx)
}
+141
View File
@@ -110,6 +110,147 @@ func TestDesktopMCPMigrationRootsIncludesLegacyWorkspaces(t *testing.T) {
}
}
func TestRecoverLegacyProjectSidebarRootsPreservesUpgradeProjects(t *testing.T) {
isolateDesktopUserDirs(t)
existing := t.TempDir()
active := t.TempDir()
legacy := t.TempDir()
tabRoot := t.TempDir()
missing := filepath.Join(t.TempDir(), "missing")
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: existing, Title: "Existing"}}}); err != nil {
t.Fatal(err)
}
saveWorkspace(active)
if err := os.MkdirAll(filepath.Dir(workspaceListPath()), 0o755); err != nil {
t.Fatal(err)
}
b, err := json.Marshal([]string{legacy, active, missing, legacy})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(workspaceListPath(), b, 0o644); err != nil {
t.Fatal(err)
}
tabs := desktopTabsFile{
Tabs: []desktopTabEntry{
{Scope: "project", WorkspaceRoot: tabRoot},
{Scope: "project", WorkspaceRoot: missing},
{Scope: "global"},
},
}
changed, err := recoverLegacyProjectSidebarRoots(tabs)
if err != nil {
t.Fatal(err)
}
if !changed {
t.Fatal("recoverLegacyProjectSidebarRoots should add missing legacy projects")
}
projects := loadProjectsFile().Projects
want := []string{
normalizeProjectRoot(existing),
normalizeProjectRoot(active),
normalizeProjectRoot(legacy),
normalizeProjectRoot(tabRoot),
}
if len(projects) != len(want) {
t.Fatalf("project count = %d, want %d: %+v", len(projects), len(want), projects)
}
for i, root := range want {
if projects[i].Root != root {
t.Fatalf("projects[%d].Root = %q, want %q; projects=%+v", i, projects[i].Root, root, projects)
}
}
if _, err := os.Stat(filepath.Join(desktopConfigDir(), legacyProjectSidebarRecoveryMarker)); err != nil {
t.Fatalf("recovery marker was not written: %v", err)
}
if err := removeProject(legacy); err != nil {
t.Fatal(err)
}
changed, err = recoverLegacyProjectSidebarRoots(tabs)
if err != nil {
t.Fatal(err)
}
if changed {
t.Fatal("recovery should be one-shot after the marker is written")
}
for _, project := range loadProjectsFile().Projects {
if project.Root == normalizeProjectRoot(legacy) {
t.Fatalf("removed legacy project was restored after marker: %+v", loadProjectsFile().Projects)
}
if project.Root == normalizeProjectRoot(missing) {
t.Fatalf("missing legacy project should not be restored: %+v", loadProjectsFile().Projects)
}
}
}
func TestProjectFileUpdatesSerializeReadModifyWrite(t *testing.T) {
isolateDesktopUserDirs(t)
active := t.TempDir()
added := t.TempDir()
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: active, Title: "Active"}}}); err != nil {
t.Fatal(err)
}
entered := make(chan struct{})
release := make(chan struct{})
updateErr := make(chan error, 1)
go func() {
updateErr <- updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
close(entered)
<-release
for i, project := range f.Projects {
if project.Root == normalizeProjectRoot(active) {
f.Projects[i].Title = "Active edited"
return true, nil
}
}
return false, nil
})
}()
<-entered
addErr := make(chan error, 1)
go func() {
addErr <- addProject(added, "Added")
}()
select {
case err := <-addErr:
t.Fatalf("addProject completed while another project update was in progress: %v", err)
case <-time.After(10 * time.Millisecond):
}
close(release)
if err := <-updateErr; err != nil {
t.Fatal(err)
}
if err := <-addErr; err != nil {
t.Fatal(err)
}
projects := loadProjectsFile().Projects
if len(projects) != 2 {
t.Fatalf("project count = %d, want 2: %+v", len(projects), projects)
}
if projects[0].Root != normalizeProjectRoot(active) || projects[0].Title != "Active edited" {
t.Fatalf("active project was not preserved with edited title: %+v", projects)
}
if projects[1].Root != normalizeProjectRoot(added) || projects[1].Title != "Added" {
t.Fatalf("concurrent project add was lost: %+v", projects)
}
if err := addProject(active, ""); err != nil {
t.Fatal(err)
}
projects = loadProjectsFile().Projects
if len(projects) != 2 || projects[1].Root != normalizeProjectRoot(added) {
t.Fatalf("no-op addProject overwrote the added project: %+v", projects)
}
}
func TestDialogDefaultDirectoryFallsBackFromMissingWorkspace(t *testing.T) {
parent := t.TempDir()
missing := filepath.Join(parent, "deleted", "project")
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reasonix/internal/config"
)
// DesktopZoomFactor persists the user's WebView2 zoom factor preference across
// restarts. The frontend writes it; main.go reads it before wails.Run() to set
// the Windows ZoomFactor option.
type DesktopZoomFactor struct {
ZoomFactor float64 `json:"zoomFactor"`
}
func zoomFactorPath() string {
return filepath.Join(config.MemoryUserDir(), "desktop-zoom.json")
}
// loadZoomFactor reads the saved zoom factor. The bool is false when no saved
// value exists (first launch, missing file, corrupt JSON). Callers should fall
// back to 1.0 (no zoom) in that case.
func loadZoomFactor() (float64, bool) {
path := zoomFactorPath()
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
var zf DesktopZoomFactor
if err := json.Unmarshal(data, &zf); err != nil {
return 0, false
}
if zf.ZoomFactor < 0.5 || zf.ZoomFactor > 2.0 {
return 0, false
}
return zf.ZoomFactor, true
}
// GetDesktopZoomFactor returns the currently persisted restart zoom factor,
// or 1.0 if none is saved.
func (a *App) GetDesktopZoomFactor() float64 {
zf, ok := loadZoomFactor()
if !ok {
return 1.0
}
return zf
}
// SetDesktopZoomFactor persists a zoom factor for the next launch. The value
// is clamped to [0.5, 2.0] (50% – 200%) for safety.
func (a *App) SetDesktopZoomFactor(factor float64) error {
if factor < 0.5 {
factor = 0.5
}
if factor > 2.0 {
factor = 2.0
}
path := zoomFactorPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.Marshal(DesktopZoomFactor{ZoomFactor: factor})
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
// RestartApplication saves the zoom and restarts the whole process so the new
// ZoomFactor takes effect in the WebView2 window options.
func (a *App) RestartApplication() error {
exe, err := os.Executable()
if err != nil {
return err
}
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return err
}
os.Exit(0)
return nil
}
@@ -0,0 +1,90 @@
# AutoResearch Runtime Verification Matrix
Date: 2026-06-30
Scope: host-managed AutoResearch runtime, controller integration, desktop API,
frontend visibility, and focused end-to-end verification.
## Summary
AutoResearch has moved from a prompt convention to a host-managed runtime for
the implemented scope. The host now creates and resumes task state under
`.reasonix/autoresearch/<task-id>/`, injects a runtime summary into active goal
turns, records heartbeats and stale progress, records accepted evidence from
structured assistant evidence blocks, gates completion through default required
criteria, and exposes desktop/frontend status APIs with compact tab metadata.
The implementation is verified against the design requirements listed below.
The only residual limitation is that an external browser smoke test cannot call
the Wails `window.go` bridge directly; bridge behavior is covered by Go and
TypeScript tests, and the running Wails app was smoke-tested through `./dev`.
## Requirement Matrix
| Requirement | Evidence | Status |
| --- | --- | --- |
| Host creates task id and directory layout | `internal/autoresearch.Store.CreateTask`; `TestCreateTaskCreatesHostOwnedLayoutAndInitialState` | Verified |
| State files are host-owned JSON/JSONL under `.reasonix/autoresearch/<task-id>/` | `task_spec.json`, `progress.json`, `directions_tried.json`, `findings.jsonl`, `iteration_log.jsonl`, `heartbeat.jsonl`; store tests and `./dev` smoke task directory | Verified |
| Schema validation rejects missing required fields | `ValidateTask`; `TestValidateTaskReportsSchemaErrors` | Verified |
| JSONL append rejects invalid entries | `AppendFinding`; `TestAppendFindingRejectsInvalidEntry` | Verified |
| Accepted findings satisfy readiness | `Readiness`; `TestAppendFindingRecordsAcceptedEvidenceForReadiness`, `TestRecordEvidenceLinksFindingToCriterionAndSatisfiesReadiness` | Verified |
| Direction repetition increments stale count and pivot threshold | `RecordDirection`; `TestRecordDirectionIncrementsStaleAndRequiresPivot` | Verified |
| Controller computes stale/pivot instead of relying on prompt | `recordAutoResearchTurnProgress`; `TestResearchGoalTurnUpdatesAutoResearchStaleProgress` | Verified |
| Per-task concurrent writes are serialized | `Store.lockTask`; `TestConcurrentDirectionWritesAreSerializedPerTask` | Verified |
| `/goal --research` creates task and persists `AutoResearchTaskID` | `SetGoalWithResearchMode`; `TestResearchGoalCreatesHostManagedAutoResearchTask` | Verified |
| Explicit `.reasonix/autoresearch/<task-id>/` resumes existing task | `ResumeFromGoalText`; `TestResumeFromGoalTextLoadsExplicitTaskPath` | Verified |
| Missing explicit task path blocks instead of silently creating a new task | `ensureAutoResearchTask`; `TestResearchGoalMissingExplicitTaskBlocksInsteadOfCreatingNewTask` | Verified |
| Cold resume restores running AutoResearch task id | `goalMachine.restoreRunningFromState`; `TestResumeRestoresRunningAutoResearchGoalFromSidecar` | Verified |
| Compose injects host-generated `<autoresearch-runtime>` | `Controller.Compose`; `TestResearchGoalCreatesHostManagedAutoResearchTask`, resume test | Verified |
| Heartbeats are written around turns | `appendAutoResearchHeartbeat`; `TestResearchGoalTurnAppendsAutoResearchHeartbeats`; `./dev` smoke heartbeat log | Verified |
| Summary includes last heartbeat | `Summary`; `TestAppendHeartbeatRecordsDurableTurnStatus` | Verified |
| Completion is intercepted when readiness fails | `autoResearchReadinessFailure`; `TestResearchGoalCompletionIsInterceptedWhenReadinessFails` | Verified |
| Completion requires host-readable evidence for default criteria | `defaultAutoResearchSuccessCriteria`; `TestResearchGoalCreatesHostManagedAutoResearchTask`, `TestResearchGoalCompletionMarksAutoResearchTaskComplete` | Verified |
| Completion/blocked goal updates AutoResearch status | `finalizeAutoResearchTask`; `TestResearchGoalCompletionMarksAutoResearchTaskComplete`, `TestResearchGoalBlockedMarksAutoResearchTaskBlocked` | Verified |
| Host controller records structured evidence | `RecordAutoResearchEvidence`; `TestControllerRecordsAutoResearchEvidence` | Verified |
| Assistant replies can record structured evidence without changing tool schemas | `<autoresearch-evidence>` parser; `TestResearchGoalCompletionMarksAutoResearchTaskComplete` | Verified |
| AutoResearch evidence recording does not alter the default provider-visible tool surface | `TestAutoResearchEvidenceDoesNotChangeDefaultToolSurface`; boot tool contract tests | Verified |
| Desktop API exposes current/status/list/findings/open/record evidence | `desktop/app.go`; `TestAutoResearchStatusSurfaceForActiveTab`, `TestAutoResearchFindingsAreLoadedOnDemand`, `TestAutoResearchListReturnsWorkspaceTasks`, `TestAutoResearchOpenTaskRevealsTaskDirectory`, `TestAutoResearchRecordEvidenceThroughDesktopAPI` | Verified |
| Tab metadata includes compact summary only | `compactAutoResearch`; `TestAutoResearchStatusSurfaceForActiveTab` | Verified |
| Findings load on demand and cap by limit | `AutoResearchFindings`; `TestAutoResearchFindingsAreLoadedOnDemand` | Verified |
| AutoResearch does not appear as a configurable default status bar item | `statusbar-workspace.test.tsx` | Verified |
| Frontend bridge tolerates transient Wails IPC timing errors | `bridge-drag-rejection.test.ts` | Verified |
| Transcript lifecycle cards exist | Controller emits `event.Notice`; frontend renders notices via `NoticeCard`; `./dev` smoke shows `autoresearch task created` notice | Verified |
| `./dev` starts and AutoResearch creates durable state | Browser smoke against `http://127.0.0.1:34193`; task `20260630-065721-e2e-verify-autoresearch-ui-smoke-test` created | Verified |
## Commands Run
```bash
GOCACHE=/private/tmp/reasonix-go-build-cache go test ./internal/autoresearch ./internal/control -count=1
GOCACHE=/private/tmp/reasonix-go-build-cache go test . -run 'TestAutoResearch|TestMetaReportsGoalStatus' -count=1
GOCACHE=/private/tmp/reasonix-go-build-cache go test . -count=1
./node_modules/.bin/tsc --noEmit -p tsconfig.test.json
./node_modules/.bin/tsx src/__tests__/statusbar-workspace.test.tsx
pnpm test
./dev
```
`tsx` tests required running outside the sandbox because `tsx` creates IPC pipes
under `/var/folders`.
## End-to-End Smoke Notes
- `./dev` started successfully.
- Wails devserver: `http://127.0.0.1:34193`.
- External browser loaded the page with title `Reasonix`.
- Browser console had no error/warn entries.
- Submitted `/goal --research e2e verify autoresearch ui smoke test`.
- The transcript rendered `autoresearch task created:
20260630-065721-e2e-verify-autoresearch-ui-smoke-test`.
- Host state files were created under `.reasonix/autoresearch/...`.
- Cancelling the smoke goal wrote heartbeat warning `context canceled`.
- Known Wails dev terminal noise remains: `runtime:ready -> Unknown message from
front end: runtime:ready`.
## Remaining Risk
- The external browser smoke test cannot directly call Wails `window.go`; bridge
behavior is covered by Go/TS tests and the running Wails desktop app, but not
by direct browser-side method calls.
- The implementation intentionally avoids background daemon behavior; AutoResearch
advances only during normal Goal turns.
@@ -0,0 +1,94 @@
# AutoResearch Runtime Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement the host-managed AutoResearch runtime from the design spec, including durable state, controller integration, desktop API, and frontend visibility.
**Architecture:** Add `internal/autoresearch` as the source of truth for task state under `.reasonix/autoresearch/<task-id>/`. Goal controller code creates, resumes, summarizes, heartbeats, and gates completion through that package. Desktop and frontend read compact status metadata for the active tab and fetch heavier details on demand.
**Tech Stack:** Go standard library, existing `internal/control` goal FSM, desktop Wails bindings, React/TypeScript frontend, existing Go and frontend test runners.
---
## Implementation Tasks
### Task 1: AutoResearch Store And Schema
**Files:**
- Create: `internal/autoresearch/task.go`
- Create: `internal/autoresearch/store.go`
- Create: `internal/autoresearch/schema.go`
- Create: `internal/autoresearch/store_test.go`
- [ ] Write failing tests that prove `CreateTask` creates `.reasonix/autoresearch/<task-id>/state` and `logs`, writes `task_spec.json`, `progress.json`, empty JSONL files, and validates required fields.
- [ ] Run: `go test ./internal/autoresearch -run 'TestCreateTask|TestValidateTask' -count=1`
- [ ] Implement types, task id generation, atomic JSON writes, JSONL append validation, `CreateTask`, `LoadTask`, `ValidateTask`.
- [ ] Run: `go test ./internal/autoresearch -count=1`
### Task 2: Findings, Directions, Summary, Readiness
**Files:**
- Create: `internal/autoresearch/summary.go`
- Create: `internal/autoresearch/readiness.go`
- Modify: `internal/autoresearch/store.go`
- Modify: `internal/autoresearch/store_test.go`
- [ ] Write failing tests for `AppendFinding`, `RecordDirection`, stale/pivot thresholds, `Summary`, and `Readiness`.
- [ ] Run: `go test ./internal/autoresearch -run 'TestAppendFinding|TestRecordDirection|TestReadiness|TestSummary' -count=1`
- [ ] Implement accepted evidence lookup, direction fingerprint tracking, stale count updates, pivot requirement, readiness blocking, and compact summary generation.
- [ ] Run: `go test ./internal/autoresearch -count=1`
### Task 3: Goal Controller Runtime Integration
**Files:**
- Modify: `internal/control/goal.go`
- Modify: `internal/control/input.go`
- Modify: `internal/control/controller.go`
- Modify: `internal/control/turn_orchestrator.go`
- Modify: `internal/control/goal_test.go`
- Modify: `internal/control/input_test.go`
- [ ] Write failing tests that `/goal --research` creates or resumes an AutoResearch task and stores `AutoResearchTaskID` in persisted goal state.
- [ ] Write failing tests that `Compose` injects a host-generated `<autoresearch-runtime>` summary only in the current user turn.
- [ ] Write failing tests that goal completion is intercepted when AutoResearch readiness fails.
- [ ] Run targeted control tests for the new cases.
- [ ] Implement store wiring, create/resume, heartbeat hooks, runtime summary injection, and readiness intercept.
- [ ] Run: `go test ./internal/control -count=1`
### Task 4: Desktop API Surface
**Files:**
- Modify: `desktop/app.go`
- Modify: `desktop/tabs.go`
- Modify: `desktop/app_test.go`
- Modify: `desktop/tab_profile_test.go`
- [ ] Write failing tests for `AutoResearchStatus`, `AutoResearchCurrent`, `AutoResearchFindings`, and compact tab metadata.
- [ ] Implement view structs and methods that call `internal/autoresearch` without parsing prompt text.
- [ ] Ensure findings are capped and loaded on demand.
- [ ] Run: `go test ./desktop -run 'AutoResearch|MetaReports' -count=1`
### Task 5: Frontend Status And Detail UI
**Files:**
- Modify: `desktop/frontend/src/lib/statusBarItems.ts`
- Modify: `desktop/frontend/src/components/StatusBar.tsx`
- Modify: `desktop/frontend/src/components/ContextPanel.tsx`
- Modify: `desktop/frontend/src/bridge.ts`
- Modify: `desktop/frontend/src/__tests__/statusbar-workspace.test.tsx`
- Add frontend tests for AutoResearch detail rendering.
- [ ] Write failing frontend tests for hidden chip, running/pivot/blocked labels, opening the detail panel, and rendering loaded findings.
- [ ] Implement compact status chip, tooltip, detail section, refresh hooks, and on-demand findings loading.
- [ ] Run: `pnpm --dir desktop/frontend test`
### Task 6: End-To-End Verification
**Files:**
- Update generated Wails bindings if required.
- No broad refactors.
- [ ] Run: `go test ./internal/autoresearch ./internal/control ./desktop -count=1`
- [ ] Run: `pnpm --dir desktop/frontend test`
- [ ] Run the desktop app through the project dev command when feasible and manually verify a research goal shows the chip and panel state.
- [ ] Audit each design-spec goal against implementation and record remaining gaps before marking the goal complete.
@@ -0,0 +1,477 @@
# AutoResearch Runtime Design
## Context
Reasonix already has Goal mode and AutoResearch instructions. When a goal looks
long-running, `activeGoalBlock` injects an AutoResearch protocol that asks the
model to create `.reasonix/autoresearch/<task-id>/` and maintain files such as
`task_spec.json`, `progress.json`, `findings.jsonl`, `directions_tried.json`, and
`heartbeat.jsonl`.
The current behavior is useful, but the durable state is mostly prompt-driven.
The host does not create the task directory, validate schemas, compute
`stale_count`, require pivots, expose a structured status API, or provide a real
resume mechanism. This design upgrades AutoResearch from a prompt convention to
a host-managed runtime.
## Goals
- Host creates and owns the AutoResearch task id and directory layout.
- Host validates task state with typed schemas.
- Host records heartbeat, progress, findings, directions tried, and iteration log.
- Host computes stale and pivot pressure from accepted evidence and direction
repetition.
- Goal completion is blocked until required success criteria have evidence.
- Existing AutoResearch task ids can be resumed by the controller.
- The desktop/API layer can query AutoResearch status without parsing prompt text.
## Non-Goals
- No large desktop panel in the first implementation.
- No autonomous background daemon. AutoResearch advances only through normal Goal
turns.
- No parallel writable sub-agent redesign in this feature.
- No network, publish, payment, credential, or destructive-operation bypass.
Existing Reasonix gates still apply.
## Proposed Architecture
Add a new package:
```text
internal/autoresearch/
task.go
store.go
schema.go
summary.go
readiness.go
```
The package is responsible for all filesystem state under:
```text
.reasonix/autoresearch/<task-id>/
state/
task_spec.json
progress.json
directions_tried.json
findings.jsonl
iteration_log.jsonl
logs/
heartbeat.jsonl
```
The controller remains the owner of Goal lifecycle. AutoResearch state is a
durable sidecar attached to a running Goal when research mode is on or auto
research is triggered.
## Core Types
`TaskSpec`:
```json
{
"task_id": "20260629-153000-debug-lag",
"goal": "Find the root cause of UI event-loop lag and verify the fix",
"scope": ["desktop/frontend", "desktop"],
"non_goals": [],
"allowed_operations": {
"write": true,
"network": false,
"publish": false
},
"success_criteria": [
{
"id": "root_cause",
"description": "A reproducible root cause is identified",
"required": true,
"evidence_ids": []
}
]
}
```
`Progress`:
```json
{
"status": "running",
"iteration": 4,
"current_direction": "profile markdown rendering",
"stale_count": 1,
"pivot_count": 0,
"blocked_reason": "",
"updated_at": "2026-06-29T15:30:00Z"
}
```
`Finding` JSONL entries:
```json
{
"id": "f1",
"kind": "test",
"summary": "A markdown render benchmark reproduces the lag",
"source": "command",
"command": "pnpm --dir desktop/frontend test",
"paths": ["desktop/frontend/src/components/MarkdownRenderer.tsx"],
"accepted": true,
"created_at": "2026-06-29T15:30:00Z"
}
```
`DirectionTried` entries record normalized direction fingerprints so the host can
detect repeated work:
```json
{
"fingerprint": "profile-markdown-rendering",
"summary": "Profile markdown rendering",
"first_seen_iteration": 2,
"last_seen_iteration": 4,
"count": 2
}
```
## Store API
The first implementation should expose a small host API:
```go
type Store struct { /* workspace root + autoresearch root */ }
func (s *Store) CreateTask(goal string, opts CreateOptions) (*Task, error)
func (s *Store) LoadTask(taskID string) (*Task, error)
func (s *Store) ResumeFromGoalText(goal string) (*Task, bool, error)
func (s *Store) AppendHeartbeat(taskID string, h Heartbeat) error
func (s *Store) AppendFinding(taskID string, f Finding) error
func (s *Store) RecordDirection(taskID string, d Direction) (*Progress, error)
func (s *Store) UpdateProgress(taskID string, patch ProgressPatch) (*Progress, error)
func (s *Store) ValidateTask(taskID string) (*ValidationReport, error)
func (s *Store) Readiness(taskID string) (*ReadinessReport, error)
func (s *Store) Summary(taskID string) (*Summary, error)
```
All writes should be atomic: write to a temp file in the same directory, fsync
where practical, then rename. JSONL appends should validate each entry before
writing.
## Controller Integration
When Goal mode starts:
1. If research mode is off, behavior is unchanged.
2. If AutoResearch is on, the controller creates a task unless the goal contains
an explicit `.reasonix/autoresearch/<task-id>/` path.
3. If an explicit task path exists, the controller loads and validates that task.
4. The active goal state stores `AutoResearchTaskID`.
Before each AutoResearch turn:
- The controller appends a heartbeat with `status=starting_turn`.
- The composed user input includes a concise host-generated summary:
task id, status, iteration, current direction, stale count, pivot count,
blockers, open success criteria, and next required runtime action.
- The static prompt still explains the protocol, but host state is authoritative.
After each turn:
- The controller appends a heartbeat with `status=turn_done`.
- If tools ran, it records basic iteration metadata.
- If no accepted evidence was recorded, or the same direction repeated, the host
increments `stale_count`.
- At `stale_count >= 2`, the next turn summary requires a structural pivot.
- At `stale_count >= 4`, the goal is blocked unless the agent asks for the
smallest external input needed.
The model may still write detailed notes, but host-owned JSON files are the
source of truth.
## Completion Gate
When the model emits `[goal:complete]`, the controller runs AutoResearch
readiness before normal Goal completion:
- `task_spec.json` and `progress.json` must exist and validate.
- Every required success criterion must have at least one accepted evidence id.
- Evidence ids must resolve to entries in `findings.jsonl`.
- If code was changed, there must be accepted verification evidence or an
explicit accepted reason why verification could not run.
- If `progress.status` is blocked, completion is rejected.
- If `stale_count > 0`, completion is allowed only when the final iteration added
accepted evidence that addresses the stale direction.
Failure returns a concrete intercept message to the model listing missing
criteria and required next actions.
## Resume Behavior
AutoResearch resume has two paths:
- Explicit: a goal or prompt includes `.reasonix/autoresearch/<task-id>/`.
- Session-sidecar: the persisted Goal state contains `AutoResearchTaskID`.
On resume, the host validates the task. If state is corrupt, it blocks execution
with a repair message instead of silently asking the model to infer state.
## Desktop/API Surface
The first UI implementation should make AutoResearch visible without turning the
chat surface into a project-management app. It should use the existing desktop
layout patterns: status bar for compact state, side panels for inspectable
details, and transcript cards for turn-local events.
Host methods:
- `AutoResearchStatus(taskID string)`
- `AutoResearchList()`
- `AutoResearchCurrent()`
- `AutoResearchFindings(taskID string, limit int)`
- `AutoResearchOpenTask(taskID string)`
The status payload should include:
- task id
- goal
- status
- iteration
- current direction
- stale count
- pivot count
- last heartbeat
- finding count
- open success criteria
- blocker
`AutoResearchOpenTask` opens `.reasonix/autoresearch/<task-id>/` in the
workspace panel or OS file browser, matching existing workspace reveal behavior.
## Deferred Desktop UI Design
The runtime PR exposes the desktop API and compact tab metadata first. The
default frontend tool/status surface intentionally stays unchanged until the UI
entry points below are implemented and reviewed as a separate product decision.
### Entry Points
AutoResearch should appear in three places:
1. Status bar chip: a compact always-visible indicator when the active tab has a
running or resumable AutoResearch task.
2. Context/side panel section: an inspectable task summary for the active tab.
3. Transcript event cards: lightweight markers for task creation, pivot required,
blocked, resumed, and completed.
This keeps the primary chat workflow intact while making durable research state
visible and recoverable.
### Status Bar Chip
Add an `autoresearch` status item to the existing status bar item system. It is
hidden when no AutoResearch task is active for the current tab.
Display states:
- `Research 4` for running iteration 4.
- `Pivot` when `pivot_required` is true.
- `Blocked` when status is blocked.
- `Done` briefly after completion.
The chip should include an icon, short label, and tooltip. The tooltip contains:
- task id
- current direction
- stale count
- open criteria count
- last heartbeat age
Clicking the chip opens the AutoResearch detail panel.
### Detail Panel
Add a compact AutoResearch section to the right-side context/workspace area. The
panel should be dense and operational, not decorative.
Header:
- task id
- status
- iteration
- last heartbeat
- open task folder button
Summary rows:
- goal
- current direction
- stale count
- pivot count
- blocker
Success criteria list:
- criterion description
- required/optional marker
- evidence count
- status: open, satisfied, blocked
Findings list:
- newest accepted findings first
- kind badge: command, file, test, benchmark, manual, review
- summary
- source command/path if present
- created time
Controls:
- Resume: starts or continues `/goal --research .reasonix/autoresearch/<task-id>/`
for the active tab when not running.
- Pause: clears active Goal continuation without deleting task state.
- Open Folder: reveals the task directory.
- Copy Task ID: copies the task id.
The first implementation can omit inline editing of task spec fields. Task state
is owned by the host and model workflow; UI edits would need validation and a
separate audit path.
### Transcript Cards
Emit lightweight notices or typed events for important AutoResearch lifecycle
changes:
- task created
- task resumed
- heartbeat failed
- pivot required
- readiness blocked completion
- task completed
- task blocked
The transcript should not render the full runtime summary every turn. It should
show only meaningful lifecycle changes, because the full summary is already
available in the detail panel and injecting it visually every turn would add
noise.
### Frontend State Flow
Extend bridge/types with:
```ts
interface AutoResearchStatusView {
taskId: string;
goal: string;
status: "running" | "blocked" | "complete" | "stopped" | "invalid";
iteration: number;
currentDirection: string;
staleCount: number;
pivotCount: number;
pivotRequired: boolean;
lastHeartbeatAt: string;
findingCount: number;
openCriteria: AutoResearchCriterionView[];
blocker: string;
taskPath: string;
}
```
`MetaForTab` should include only the small active-task summary needed to render
the status chip. The heavier findings list should be loaded on demand through
`AutoResearchFindings`, so normal chat turns do not pull large JSONL data into
frontend state.
Refresh strategy:
- Refresh current AutoResearch status after `turn_done`.
- Refresh when switching tabs.
- Refresh when receiving an AutoResearch lifecycle event.
- Do not poll every second. The heartbeat is persisted for durability, not for a
live dashboard animation.
### Empty and Error States
- No active task: hide the chip and show no panel section by default.
- Invalid state: show an `Invalid` status with the exact validation error and an
Open Folder action.
- Missing task folder on resume: show a blocked state and keep the Goal from
silently continuing.
- Stale state: show the pivot requirement prominently but do not mark it as an
error.
### Accessibility and Layout
- Use existing button, tooltip, panel, and status bar patterns.
- Keep the status chip width stable so the status bar does not shift during
streaming.
- Long goals and directions should wrap in the panel but truncate in the chip
tooltip label.
- Findings should be keyboard navigable and copyable.
- Use icons only where the existing design system already uses them; avoid a
marketing-style card layout.
## Error Handling
- Missing task directory: create only when starting a new task; otherwise return
a clear resume error.
- Corrupt JSON: block AutoResearch continuation and surface the exact file.
- Schema mismatch: return validation errors with paths and fields.
- Failed heartbeat write: warn and continue for non-critical writes, but block
completion if state cannot be validated.
- Concurrent writes: serialize per task id inside the store.
## Testing
Unit tests:
- task id generation is stable in shape and collision-safe
- store creates the expected directory layout
- schema validation rejects missing required fields
- JSONL append rejects invalid entries
- direction repetition increments stale count
- pivot threshold produces a pivot requirement
- readiness blocks missing evidence
- readiness accepts complete criteria with accepted findings
- resume loads explicit task id from goal text
Controller tests:
- AutoResearch goal creates task state
- active goal block includes host-generated summary
- every turn appends heartbeat
- `[goal:complete]` is intercepted when readiness fails
- explicit `.reasonix/autoresearch/<task-id>/` resumes existing state
Desktop/API tests:
- app method returns current AutoResearch status for the active tab
- tab metadata includes only compact AutoResearch summary
- findings are loaded on demand and capped by limit
- status chip hides when no task is active
- status chip opens the detail panel
- detail panel renders running, pivot, blocked, invalid, and complete states
- Open Folder calls the existing reveal/open path behavior
- tab switch refreshes the visible AutoResearch summary
## Rollout
Phase 1: implement `internal/autoresearch` store, schemas, summary, readiness,
and tests.
Phase 2: integrate with Goal controller create/resume/heartbeat/summary and
completion intercept.
Phase 3: add desktop/API status methods, status bar chip, detail panel, transcript
lifecycle cards, and frontend tests.
Phase 4: optionally let tools or a dedicated host tool record structured
findings directly, reducing reliance on model-authored JSON.
## Compatibility and Cache Impact
This design should not change provider-visible tool schemas in phase 1. The
active goal prompt changes only when AutoResearch is active. Cache impact is
therefore low for ordinary sessions and medium for AutoResearch sessions because
the injected runtime summary changes each turn.
No existing `.reasonix/autoresearch` task should be deleted or rewritten without
validation and explicit migration logic.
+33
View File
@@ -2015,6 +2015,13 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
errMsg: "blocked by loop guard",
}
}
if out, blocked := a.staleAnchorEditBlock(call); blocked {
return toolOutcome{
output: out,
blocked: true,
errMsg: "blocked: fresh read required",
}
}
if a.planMode.Load() {
// Translate the tool's optional plan-mode self-report into the policy's
// tri-state. Mirrors the t.(tool.Previewer) assertion precedent below.
@@ -2242,6 +2249,32 @@ func (a *Agent) repeatedSuccessBlock(call provider.ToolCall, t tool.Tool) (strin
call.Name, count), true
}
func (a *Agent) staleAnchorEditBlock(call provider.ToolCall) (string, bool) {
if a.evidence == nil || !anchorBasedEditTool(call.Name) {
return "", false
}
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), true, false)
if len(rec.Paths) == 0 {
return "", false
}
writeIndex, ok := a.evidence.LatestSuccessfulWriteIndex(rec.Paths)
if !ok || a.evidence.HasSuccessfulAnchorRefreshReadAfter(rec.Paths, writeIndex) {
return "", false
}
return fmt.Sprintf(
"blocked: [fresh read required] %q targets %s, which was already modified earlier this turn. Re-read the current file with read_file without offset/limit before another anchor-based edit, or combine the final same-file changes in one multi_edit call. This prevents stale old_string anchors and half-deleted ranges.",
call.Name, strings.Join(rec.Paths, ", ")), true
}
func anchorBasedEditTool(name string) bool {
switch name {
case "edit_file", "delete_range":
return true
default:
return false
}
}
func (a *Agent) recordRepeatSuccess(call provider.ToolCall, t tool.Tool) {
sig, ok := repeatSuccessSignature(call, t)
if !ok {
+4
View File
@@ -29,6 +29,10 @@ type BranchMeta struct {
TopicID string `json:"topic_id,omitempty"`
TopicTitle string `json:"topic_title,omitempty"`
Model string `json:"model,omitempty"`
TokenMode string `json:"token_mode,omitempty"`
Mode string `json:"mode,omitempty"`
ToolApprovalMode string `json:"tool_approval_mode,omitempty"`
Goal string `json:"goal,omitempty"`
// SchemaVersion records the BranchMeta version that last wrote the listing
// fields (Turns/Preview) FROM the session's content. It is stamped only by the
// writers that actually derive those counts — Controller.snapshot's
+138
View File
@@ -0,0 +1,138 @@
package agent
import (
"context"
"strings"
"sync/atomic"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func TestAnchorEditRequiresReadAfterSameTurnWrite(t *testing.T) {
var editCalls int32
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "edit_file", readOnly: false, calls: &editCalls})
args := `{"path":"src/map.html","old_string":"before","new_string":"after"}`
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
toolCallChunk("c1", "edit_file", args),
toolCallChunk("c2", "edit_file", args),
{Type: provider.ChunkDone},
},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "edit the map"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := atomic.LoadInt32(&editCalls); got != 1 {
t.Fatalf("edit_file executed %d times, want only the first call", got)
}
results := toolResults(a.session, "edit_file")
if len(results) != 2 {
t.Fatalf("tool results = %d, want 2", len(results))
}
last := results[len(results)-1]
for _, want := range []string{"[fresh read required]", "read_file", "multi_edit"} {
if !strings.Contains(last, want) {
t.Fatalf("blocked result should mention %q, got %q", want, last)
}
}
}
func TestAnchorEditAllowedAfterFreshRead(t *testing.T) {
var editCalls int32
var readCalls int32
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "edit_file", readOnly: false, calls: &editCalls})
reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &readCalls})
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
toolCallChunk("c1", "edit_file", `{"path":"src/map.html","old_string":"before","new_string":"after"}`),
toolCallChunk("c2", "read_file", `{"path":"src/map.html"}`),
toolCallChunk("c3", "edit_file", `{"path":"src/map.html","old_string":"current","new_string":"final"}`),
{Type: provider.ChunkDone},
},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "edit the map with a read between edits"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := atomic.LoadInt32(&readCalls); got != 1 {
t.Fatalf("read_file executed %d times, want 1", got)
}
if got := atomic.LoadInt32(&editCalls); got != 2 {
t.Fatalf("edit_file executed %d times, want 2 after fresh read", got)
}
if last := lastToolResult(a.session, "edit_file"); strings.Contains(last, "[fresh read required]") {
t.Fatalf("fresh read should allow the second edit, got %q", last)
}
}
func TestAnchorEditStillRequiresReadAfterWindowedRead(t *testing.T) {
var editCalls int32
var readCalls int32
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "edit_file", readOnly: false, calls: &editCalls})
reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &readCalls})
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
toolCallChunk("c1", "edit_file", `{"path":"src/map.html","old_string":"before","new_string":"after"}`),
toolCallChunk("c2", "read_file", `{"path":"src/map.html","offset":400,"limit":20}`),
toolCallChunk("c3", "edit_file", `{"path":"src/map.html","old_string":"current","new_string":"final"}`),
{Type: provider.ChunkDone},
},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "edit the map with a narrow read between edits"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := atomic.LoadInt32(&readCalls); got != 1 {
t.Fatalf("read_file executed %d times, want 1", got)
}
if got := atomic.LoadInt32(&editCalls); got != 1 {
t.Fatalf("edit_file executed %d times, want only the first call", got)
}
if last := lastToolResult(a.session, "edit_file"); !strings.Contains(last, "[fresh read required]") {
t.Fatalf("windowed read should not allow the second edit, got %q", last)
}
}
func TestMultiEditAllowedAfterSameTurnWrite(t *testing.T) {
var editCalls int32
var multiCalls int32
reg := tool.NewRegistry()
reg.Add(fakeTool{name: "edit_file", readOnly: false, calls: &editCalls})
reg.Add(fakeTool{name: "multi_edit", readOnly: false, calls: &multiCalls})
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
toolCallChunk("c1", "edit_file", `{"path":"src/map.html","old_string":"before","new_string":"after"}`),
toolCallChunk("c2", "multi_edit", `{"path":"src/map.html","edits":[{"old_string":"current","new_string":"final"}]}`),
{Type: provider.ChunkDone},
},
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "edit the map atomically"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := atomic.LoadInt32(&editCalls); got != 1 {
t.Fatalf("edit_file executed %d times, want 1", got)
}
if got := atomic.LoadInt32(&multiCalls); got != 1 {
t.Fatalf("multi_edit executed %d times, want 1", got)
}
}
+72
View File
@@ -0,0 +1,72 @@
package autoresearch
import "path/filepath"
func (s *Store) Readiness(taskID string) (*ReadinessReport, error) {
report := &ReadinessReport{}
validation, err := s.ValidateTask(taskID)
if err != nil {
return nil, err
}
if !validation.Valid {
for _, validationErr := range validation.Errors {
report.Errors = append(report.Errors, validationErr.File+":"+validationErr.Field+": "+validationErr.Error)
}
return report, nil
}
task, err := s.LoadTask(taskID)
if err != nil {
return nil, err
}
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
var progress Progress
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
return nil, err
}
if progress.Status == StatusBlocked {
report.BlockedReason = progress.BlockedReason
if report.BlockedReason == "" {
report.BlockedReason = "task is blocked"
}
return report, nil
}
findings, err := s.Findings(taskID, 0)
if err != nil {
return nil, err
}
accepted := acceptedFindingIDs(findings)
for _, criterion := range task.Spec.SuccessCriteria {
if !criterion.Required {
continue
}
if countAcceptedEvidence(criterion.EvidenceIDs, accepted) == 0 {
report.MissingCriteria = append(report.MissingCriteria, criterion.ID)
}
}
report.Ready = len(report.MissingCriteria) == 0 && report.BlockedReason == "" && len(report.Errors) == 0
return report, nil
}
func acceptedFindingIDs(findings []Finding) map[string]bool {
accepted := make(map[string]bool, len(findings))
for _, finding := range findings {
if finding.Accepted {
accepted[finding.ID] = true
}
}
return accepted
}
func countAcceptedEvidence(ids []string, accepted map[string]bool) int {
count := 0
for _, id := range ids {
if accepted[id] {
count++
}
}
return count
}
+52
View File
@@ -0,0 +1,52 @@
package autoresearch
import "strings"
func (r *ValidationReport) add(file, field, msg string) {
r.Errors = append(r.Errors, ValidationError{File: file, Field: field, Error: msg})
}
func validateTaskSpec(report *ValidationReport, taskID string, spec TaskSpec) {
if strings.TrimSpace(spec.TaskID) == "" {
report.add("task_spec.json", "task_id", "task id is required")
} else if spec.TaskID != taskID {
report.add("task_spec.json", "task_id", "task id must match directory")
}
if strings.TrimSpace(spec.Goal) == "" {
report.add("task_spec.json", "goal", "goal is required")
}
seenCriteria := map[string]bool{}
for i, c := range spec.SuccessCriteria {
fieldPrefix := "success_criteria"
if strings.TrimSpace(c.ID) == "" {
report.add("task_spec.json", fieldPrefix, "criterion id is required")
} else if seenCriteria[c.ID] {
report.add("task_spec.json", fieldPrefix, "criterion id must be unique")
}
seenCriteria[c.ID] = true
if strings.TrimSpace(c.Description) == "" {
report.add("task_spec.json", fieldPrefix, "criterion description is required")
}
_ = i
}
}
func validateProgress(report *ValidationReport, progress Progress) {
switch progress.Status {
case StatusRunning, StatusBlocked, StatusComplete, StatusStopped, StatusInvalid:
default:
report.add("progress.json", "status", "status is invalid")
}
if progress.Iteration < 0 {
report.add("progress.json", "iteration", "iteration must not be negative")
}
if progress.StaleCount < 0 {
report.add("progress.json", "stale_count", "stale count must not be negative")
}
if progress.PivotCount < 0 {
report.add("progress.json", "pivot_count", "pivot count must not be negative")
}
if progress.UpdatedAt.IsZero() {
report.add("progress.json", "updated_at", "updated_at is required")
}
}
+714
View File
@@ -0,0 +1,714 @@
package autoresearch
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"unicode"
)
var safeTaskID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
var explicitTaskPath = regexp.MustCompile(`\.reasonix/autoresearch/([A-Za-z0-9][A-Za-z0-9._-]*)/?`)
type Store struct {
workspaceRoot string
root string
mu sync.Mutex
taskLocks map[string]*sync.Mutex
}
func NewStore(workspaceRoot string) *Store {
if resolved, err := filepath.EvalSymlinks(workspaceRoot); err == nil {
workspaceRoot = resolved
}
return &Store{
workspaceRoot: workspaceRoot,
root: filepath.Join(workspaceRoot, ".reasonix", "autoresearch"),
taskLocks: map[string]*sync.Mutex{},
}
}
func (s *Store) lockTask(taskID string) func() {
s.mu.Lock()
lock := s.taskLocks[taskID]
if lock == nil {
lock = &sync.Mutex{}
s.taskLocks[taskID] = lock
}
s.mu.Unlock()
lock.Lock()
return lock.Unlock
}
func (s *Store) CreateTask(goal string, opts CreateOptions) (*Task, error) {
goal = strings.TrimSpace(goal)
if goal == "" {
return nil, errors.New("autoresearch: goal is required")
}
now := time.Now().UTC()
if opts.Now != nil {
now = opts.Now().UTC()
}
id, err := s.nextTaskID(now, goal)
if err != nil {
return nil, err
}
if err := os.MkdirAll(s.root, 0o755); err != nil {
return nil, fmt.Errorf("autoresearch: create root dir: %w", err)
}
storeRoot, err := os.OpenRoot(s.root)
if err != nil {
return nil, fmt.Errorf("autoresearch: open root dir: %w", err)
}
defer storeRoot.Close()
taskRel, err := s.taskRel(id)
if err != nil {
return nil, err
}
if err := storeRoot.MkdirAll(filepath.Join(taskRel, "state"), 0o755); err != nil {
return nil, fmt.Errorf("autoresearch: create state dir: %w", err)
}
if err := storeRoot.MkdirAll(filepath.Join(taskRel, "logs"), 0o755); err != nil {
return nil, fmt.Errorf("autoresearch: create logs dir: %w", err)
}
spec := TaskSpec{
TaskID: id,
Goal: goal,
Scope: append([]string(nil), opts.Scope...),
NonGoals: append([]string(nil), opts.NonGoals...),
AllowedOperations: opts.AllowedOperations,
SuccessCriteria: cloneCriteria(opts.SuccessCriteria),
}
progress := Progress{
Status: StatusRunning,
UpdatedAt: now,
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), spec); err != nil {
return nil, err
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil {
return nil, err
}
for _, path := range []string{
filepath.Join(taskRel, "state", "directions_tried.json"),
filepath.Join(taskRel, "state", "findings.jsonl"),
filepath.Join(taskRel, "state", "iteration_log.jsonl"),
filepath.Join(taskRel, "logs", "heartbeat.jsonl"),
} {
if err := storeRoot.WriteFile(path, nil, 0o644); err != nil {
return nil, fmt.Errorf("autoresearch: initialize %s: %w", path, err)
}
}
return &Task{ID: id, Root: s.taskRoot(id), Spec: spec}, nil
}
func (s *Store) ListSummaries() ([]Summary, error) {
entries, err := os.ReadDir(s.root)
if err != nil {
if os.IsNotExist(err) {
return []Summary{}, nil
}
return nil, fmt.Errorf("autoresearch: list tasks: %w", err)
}
ids := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
id := entry.Name()
if validateTaskID(id) != nil {
continue
}
ids = append(ids, id)
}
sort.Sort(sort.Reverse(sort.StringSlice(ids)))
out := make([]Summary, 0, len(ids))
for _, id := range ids {
summary, err := s.Summary(id)
if err != nil {
return nil, err
}
out = append(out, *summary)
}
return out, nil
}
func (s *Store) LoadTask(taskID string) (*Task, error) {
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
info, err := storeRoot.Lstat(taskRel)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("autoresearch: task %s not found", taskID)
}
return nil, fmt.Errorf("autoresearch: stat task %s: %w", taskID, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("autoresearch: task %s is a symlink", taskID)
}
if !info.IsDir() {
return nil, fmt.Errorf("autoresearch: task %s is not a directory", taskID)
}
var spec TaskSpec
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
return nil, err
}
return &Task{ID: taskID, Root: s.taskRoot(taskID), Spec: spec}, nil
}
func (s *Store) ResumeFromGoalText(goal string) (*Task, bool, error) {
match := explicitTaskPath.FindStringSubmatch(goal)
if len(match) < 2 {
return nil, false, nil
}
task, err := s.LoadTask(match[1])
if err != nil {
return nil, true, err
}
if report, err := s.ValidateTask(task.ID); err != nil {
return nil, true, err
} else if !report.Valid {
return nil, true, fmt.Errorf("autoresearch: task %s is invalid: %v", task.ID, report.Errors)
}
return task, true, nil
}
func (s *Store) AppendFinding(taskID string, f Finding) error {
if err := validateTaskID(taskID); err != nil {
return err
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return err
}
defer storeRoot.Close()
if err := validateFinding(f); err != nil {
return err
}
data, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("autoresearch: marshal finding: %w", err)
}
return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data)
}
func (s *Store) RecordEvidence(taskID, criterionID string, f Finding) error {
if err := validateTaskID(taskID); err != nil {
return err
}
criterionID = strings.TrimSpace(criterionID)
if criterionID == "" {
return errors.New("autoresearch: criterion id is required")
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return err
}
defer storeRoot.Close()
if err := validateFinding(f); err != nil {
return err
}
specPath := filepath.Join(taskRel, "state", "task_spec.json")
var spec TaskSpec
if err := readJSONFile(storeRoot, specPath, &spec); err != nil {
return err
}
found := false
for i := range spec.SuccessCriteria {
if spec.SuccessCriteria[i].ID != criterionID {
continue
}
found = true
if !stringSliceContains(spec.SuccessCriteria[i].EvidenceIDs, f.ID) {
spec.SuccessCriteria[i].EvidenceIDs = append(spec.SuccessCriteria[i].EvidenceIDs, f.ID)
}
break
}
if !found {
return fmt.Errorf("autoresearch: criterion %q not found", criterionID)
}
if err := writeJSONFile(storeRoot, specPath, spec); err != nil {
return err
}
data, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("autoresearch: marshal finding: %w", err)
}
return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data)
}
func (s *Store) Findings(taskID string, limit int) ([]Finding, error) {
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
var findings []Finding
if err := readJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), func(data []byte) error {
var f Finding
if err := json.Unmarshal(data, &f); err != nil {
return err
}
findings = append(findings, f)
return nil
}); err != nil {
return nil, err
}
for i, j := 0, len(findings)-1; i < j; i, j = i+1, j-1 {
findings[i], findings[j] = findings[j], findings[i]
}
if limit > 0 && len(findings) > limit {
findings = findings[:limit]
}
return findings, nil
}
func (s *Store) AppendHeartbeat(taskID string, h Heartbeat) error {
if err := validateTaskID(taskID); err != nil {
return err
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return err
}
defer storeRoot.Close()
if err := validateHeartbeat(h); err != nil {
return err
}
data, err := json.Marshal(h)
if err != nil {
return fmt.Errorf("autoresearch: marshal heartbeat: %w", err)
}
return appendJSONL(storeRoot, filepath.Join(taskRel, "logs", "heartbeat.jsonl"), data)
}
func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) {
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
var heartbeats []Heartbeat
if err := readJSONL(storeRoot, filepath.Join(taskRel, "logs", "heartbeat.jsonl"), func(data []byte) error {
var h Heartbeat
if err := json.Unmarshal(data, &h); err != nil {
return err
}
heartbeats = append(heartbeats, h)
return nil
}); err != nil {
return nil, err
}
if limit > 0 && len(heartbeats) > limit {
heartbeats = heartbeats[len(heartbeats)-limit:]
}
return heartbeats, nil
}
func (s *Store) LastHeartbeat(taskID string) (Heartbeat, bool, error) {
heartbeats, err := s.Heartbeats(taskID, 1)
if err != nil {
return Heartbeat{}, false, err
}
if len(heartbeats) == 0 {
return Heartbeat{}, false, nil
}
return heartbeats[0], true, nil
}
func (s *Store) RecordDirection(taskID string, d Direction) (*Progress, error) {
if err := validateTaskID(taskID); err != nil {
return nil, err
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
d.Summary = strings.TrimSpace(d.Summary)
if d.Summary == "" {
return nil, errors.New("autoresearch: direction summary is required")
}
now := d.Now.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
var progress Progress
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
return nil, err
}
progress.Iteration++
progress.CurrentDirection = d.Summary
progress.UpdatedAt = now
directions, err := s.loadDirections(storeRoot, taskRel)
if err != nil {
return nil, err
}
fp := slugify(d.Summary)
repeated := false
for i := range directions {
if directions[i].Fingerprint == fp {
repeated = true
directions[i].Count++
directions[i].LastSeenIteration = progress.Iteration
break
}
}
if !repeated {
directions = append(directions, DirectionTried{
Fingerprint: fp,
Summary: d.Summary,
FirstSeenIteration: progress.Iteration,
LastSeenIteration: progress.Iteration,
Count: 1,
})
}
if repeated || len(d.AcceptedEvidenceIDs) == 0 {
before := progress.StaleCount
progress.StaleCount++
if before < 2 && progress.StaleCount >= 2 {
progress.PivotCount++
}
} else {
progress.StaleCount = 0
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "directions_tried.json"), directions); err != nil {
return nil, err
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil {
return nil, err
}
return &progress, nil
}
func (s *Store) UpdateProgress(taskID string, patch ProgressPatch) (*Progress, error) {
if err := validateTaskID(taskID); err != nil {
return nil, err
}
unlock := s.lockTask(taskID)
defer unlock()
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
var progress Progress
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
return nil, err
}
if patch.Status != nil {
progress.Status = strings.TrimSpace(*patch.Status)
}
if patch.CurrentDirection != nil {
progress.CurrentDirection = strings.TrimSpace(*patch.CurrentDirection)
}
if patch.BlockedReason != nil {
progress.BlockedReason = strings.TrimSpace(*patch.BlockedReason)
}
progress.UpdatedAt = time.Now().UTC()
report := &ValidationReport{Valid: true}
validateProgress(report, progress)
if len(report.Errors) > 0 {
return nil, fmt.Errorf("autoresearch: invalid progress patch: %v", report.Errors)
}
if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil {
return nil, err
}
return &progress, nil
}
func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) {
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
report := &ValidationReport{Valid: true}
info, err := storeRoot.Lstat(taskRel)
if err != nil {
report.add("task", "", err.Error())
report.Valid = false
return report, nil
}
if info.Mode()&os.ModeSymlink != 0 {
report.add("task", "", "task directory must not be a symlink")
report.Valid = false
return report, nil
}
if !info.IsDir() {
report.add("task", "", "task path is not a directory")
report.Valid = false
return report, nil
}
var spec TaskSpec
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
report.add("task_spec.json", "", err.Error())
} else {
validateTaskSpec(report, taskID, spec)
}
var progress Progress
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
report.add("progress.json", "", err.Error())
} else {
validateProgress(report, progress)
}
for _, rel := range []string{
"state/directions_tried.json",
"state/findings.jsonl",
"state/iteration_log.jsonl",
"logs/heartbeat.jsonl",
} {
if _, err := storeRoot.Stat(filepath.Join(taskRel, rel)); err != nil {
report.add(filepath.Base(rel), "", err.Error())
}
}
report.Valid = len(report.Errors) == 0
return report, nil
}
func (s *Store) taskRoot(taskID string) string {
return filepath.Join(s.root, taskID)
}
func (s *Store) taskRel(taskID string, parts ...string) (string, error) {
if err := validateTaskID(taskID); err != nil {
return "", err
}
all := append([]string{taskID}, parts...)
rel := filepath.Join(all...)
if !filepath.IsLocal(rel) {
return "", fmt.Errorf("autoresearch: unsafe task-relative path %q", rel)
}
return rel, nil
}
func (s *Store) openTaskRoot(taskID string) (*os.Root, string, error) {
taskRel, err := s.taskRel(taskID)
if err != nil {
return nil, "", err
}
storeRoot, err := os.OpenRoot(s.root)
if err != nil {
if os.IsNotExist(err) {
return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID)
}
return nil, "", fmt.Errorf("autoresearch: open root dir: %w", err)
}
return storeRoot, taskRel, nil
}
func (s *Store) nextTaskID(now time.Time, goal string) (string, error) {
if err := os.MkdirAll(s.root, 0o755); err != nil {
return "", fmt.Errorf("autoresearch: create root dir: %w", err)
}
storeRoot, err := os.OpenRoot(s.root)
if err != nil {
return "", fmt.Errorf("autoresearch: open root dir: %w", err)
}
defer storeRoot.Close()
base := now.Format("20060102-150405") + "-" + slugify(goal)
if base == now.Format("20060102-150405")+"-" {
base += "task"
}
id := base
for i := 2; ; i++ {
taskRel, err := s.taskRel(id)
if err != nil {
return "", err
}
if _, err := storeRoot.Lstat(taskRel); err != nil {
if os.IsNotExist(err) {
return id, nil
}
return "", fmt.Errorf("autoresearch: stat task id %s: %w", id, err)
}
id = fmt.Sprintf("%s-%d", base, i)
}
}
func validateTaskID(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return errors.New("autoresearch: task id is required")
}
if !safeTaskID.MatchString(id) || strings.Contains(id, "..") || strings.ContainsAny(id, `/\`) {
return fmt.Errorf("autoresearch: unsafe task id %q", id)
}
return nil
}
func writeJSONFile(root *os.Root, path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Errorf("autoresearch: marshal %s: %w", path, err)
}
data = append(data, '\n')
if err := root.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("autoresearch: write %s: %w", path, err)
}
return nil
}
func readJSONFile(root *os.Root, path string, out any) error {
data, err := root.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
return nil
}
func appendJSONL(root *os.Root, path string, data []byte) error {
if err := root.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("autoresearch: create jsonl dir: %w", err)
}
f, err := root.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("autoresearch: open %s: %w", path, err)
}
defer f.Close()
if _, err := f.Write(append(data, '\n')); err != nil {
return fmt.Errorf("autoresearch: append %s: %w", path, err)
}
return nil
}
func readJSONL(root *os.Root, path string, each func([]byte) error) error {
f, err := root.Open(path)
if err != nil {
return fmt.Errorf("autoresearch: open %s: %w", path, err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if err := each([]byte(line)); err != nil {
return fmt.Errorf("autoresearch: parse %s: %w", path, err)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("autoresearch: scan %s: %w", path, err)
}
return nil
}
func (s *Store) loadDirections(root *os.Root, taskRel string) ([]DirectionTried, error) {
path := filepath.Join(taskRel, "state", "directions_tried.json")
data, err := root.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if strings.TrimSpace(string(data)) == "" {
return nil, nil
}
var directions []DirectionTried
if err := json.Unmarshal(data, &directions); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return directions, nil
}
func validateFinding(f Finding) error {
if strings.TrimSpace(f.ID) == "" {
return errors.New("autoresearch: finding id is required")
}
switch f.Kind {
case FindingKindCommand, FindingKindFile, FindingKindTest, FindingKindBenchmark, FindingKindManual, FindingKindReview:
default:
return fmt.Errorf("autoresearch: finding kind %q is invalid", f.Kind)
}
if strings.TrimSpace(f.Summary) == "" {
return errors.New("autoresearch: finding summary is required")
}
if f.CreatedAt.IsZero() {
return errors.New("autoresearch: finding created_at is required")
}
return nil
}
func validateHeartbeat(h Heartbeat) error {
switch h.Status {
case HeartbeatStartingTurn, HeartbeatTurnDone, HeartbeatWarning:
default:
return fmt.Errorf("autoresearch: heartbeat status %q is invalid", h.Status)
}
if h.Iteration < 0 {
return errors.New("autoresearch: heartbeat iteration must not be negative")
}
if h.CreatedAt.IsZero() {
return errors.New("autoresearch: heartbeat created_at is required")
}
return nil
}
func stringSliceContains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func cloneCriteria(in []SuccessCriterion) []SuccessCriterion {
out := make([]SuccessCriterion, len(in))
for i, c := range in {
out[i] = c
out[i].EvidenceIDs = append([]string(nil), c.EvidenceIDs...)
}
return out
}
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
lastDash := false
for _, r := range s {
switch {
case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
b.WriteRune(r)
lastDash = false
default:
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
}
slug := strings.Trim(b.String(), "-")
const maxSlugLen = 56
if len(slug) > maxSlugLen {
slug = strings.Trim(slug[:maxSlugLen], "-")
}
if slug == "" {
return "task"
}
return slug
}
+545
View File
@@ -0,0 +1,545 @@
package autoresearch
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func TestCreateTaskCreatesHostOwnedLayoutAndInitialState(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
store := NewStore(root)
task, err := store.CreateTask("Find the root cause of UI lag", CreateOptions{
Now: func() time.Time {
return time.Date(2026, 6, 29, 15, 30, 0, 0, time.UTC)
},
Scope: []string{"desktop/frontend", "desktop"},
NonGoals: []string{"publish a release"},
AllowedOperations: AllowedOperations{
Write: true,
Network: false,
Publish: false,
},
SuccessCriteria: []SuccessCriterion{
{ID: "root_cause", Description: "A reproducible root cause is identified", Required: true},
},
})
if err != nil {
t.Fatalf("CreateTask returned error: %v", err)
}
if task.ID != "20260629-153000-find-the-root-cause-of-ui-lag" {
t.Fatalf("task id = %q", task.ID)
}
wantRoot := filepath.Join(root, ".reasonix", "autoresearch", task.ID)
if task.Root != wantRoot {
t.Fatalf("task root = %q, want %q", task.Root, wantRoot)
}
for _, rel := range []string{
"state/task_spec.json",
"state/progress.json",
"state/directions_tried.json",
"state/findings.jsonl",
"state/iteration_log.jsonl",
"logs/heartbeat.jsonl",
} {
if _, err := os.Stat(filepath.Join(wantRoot, rel)); err != nil {
t.Fatalf("expected %s to exist: %v", rel, err)
}
}
var spec TaskSpec
readJSON(t, filepath.Join(wantRoot, "state/task_spec.json"), &spec)
if spec.TaskID != task.ID || spec.Goal != "Find the root cause of UI lag" {
t.Fatalf("spec identity = (%q, %q), want (%q, goal)", spec.TaskID, spec.Goal, task.ID)
}
if len(spec.SuccessCriteria) != 1 || spec.SuccessCriteria[0].ID != "root_cause" || !spec.SuccessCriteria[0].Required {
t.Fatalf("success criteria not persisted correctly: %+v", spec.SuccessCriteria)
}
if !spec.AllowedOperations.Write || spec.AllowedOperations.Network || spec.AllowedOperations.Publish {
t.Fatalf("allowed operations = %+v", spec.AllowedOperations)
}
var progress Progress
readJSON(t, filepath.Join(wantRoot, "state/progress.json"), &progress)
if progress.Status != StatusRunning || progress.Iteration != 0 || progress.StaleCount != 0 || progress.PivotCount != 0 {
t.Fatalf("initial progress = %+v", progress)
}
if progress.UpdatedAt.IsZero() {
t.Fatalf("initial progress updated_at was zero")
}
report, err := store.ValidateTask(task.ID)
if err != nil {
t.Fatalf("ValidateTask returned error: %v", err)
}
if !report.Valid || len(report.Errors) != 0 {
t.Fatalf("validation report = %+v, want valid", report)
}
}
func TestCreateTaskAvoidsIDCollisions(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
now := func() time.Time { return time.Date(2026, 6, 29, 15, 30, 0, 0, time.UTC) }
first, err := store.CreateTask("Investigate cache churn", CreateOptions{Now: now})
if err != nil {
t.Fatalf("first CreateTask: %v", err)
}
second, err := store.CreateTask("Investigate cache churn", CreateOptions{Now: now})
if err != nil {
t.Fatalf("second CreateTask: %v", err)
}
if first.ID != "20260629-153000-investigate-cache-churn" {
t.Fatalf("first id = %q", first.ID)
}
if second.ID != "20260629-153000-investigate-cache-churn-2" {
t.Fatalf("second id = %q", second.ID)
}
}
func TestLoadTaskRejectsUnsafeOrMissingID(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
for _, id := range []string{"", "../escape", "bad/id", ".hidden"} {
if _, err := store.LoadTask(id); err == nil {
t.Fatalf("LoadTask(%q) succeeded, want error", id)
}
}
if _, err := store.LoadTask("20260629-153000-missing"); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("LoadTask missing error = %v, want not found", err)
}
}
func TestLoadTaskRejectsSymlinkTaskDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating directory symlinks requires elevated privileges on many Windows hosts")
}
root := t.TempDir()
store := NewStore(root)
taskID := "20260630-120000-symlink-task"
outside := t.TempDir()
if err := os.MkdirAll(filepath.Join(outside, "state"), 0o755); err != nil {
t.Fatalf("create outside state: %v", err)
}
if err := os.WriteFile(filepath.Join(outside, "state", "task_spec.json"), []byte(`{"task_id":"20260630-120000-symlink-task","goal":"escape","success_criteria":[]}`), 0o644); err != nil {
t.Fatalf("write outside spec: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch"), 0o755); err != nil {
t.Fatalf("create autoresearch root: %v", err)
}
if err := os.Symlink(outside, filepath.Join(root, ".reasonix", "autoresearch", taskID)); err != nil {
t.Fatalf("create symlink task: %v", err)
}
if _, err := store.LoadTask(taskID); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatalf("LoadTask symlink error = %v, want symlink rejection", err)
}
}
func TestValidateTaskReportsSchemaErrors(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Validate schema errors", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
specPath := filepath.Join(task.Root, "state/task_spec.json")
var spec TaskSpec
readJSON(t, specPath, &spec)
spec.Goal = ""
writeJSON(t, specPath, spec)
report, err := store.ValidateTask(task.ID)
if err != nil {
t.Fatalf("ValidateTask returned error: %v", err)
}
if report.Valid {
t.Fatalf("ValidateTask reported valid for missing goal")
}
if !containsValidationError(report.Errors, "task_spec.json", "goal") {
t.Fatalf("validation errors = %+v, want task_spec.json goal error", report.Errors)
}
}
func TestAppendFindingRecordsAcceptedEvidenceForReadiness(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Verify accepted findings", CreateOptions{
SuccessCriteria: []SuccessCriterion{
{ID: "verified", Description: "Verification evidence exists", Required: true, EvidenceIDs: []string{"f1"}},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
err = store.AppendFinding(task.ID, Finding{
ID: "f1",
Kind: FindingKindTest,
Summary: "go test ./internal/autoresearch passed",
Source: FindingSourceCommand,
Command: "go test ./internal/autoresearch",
Accepted: true,
CreatedAt: time.Date(2026, 6, 29, 16, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("AppendFinding: %v", err)
}
readiness, err := store.Readiness(task.ID)
if err != nil {
t.Fatalf("Readiness: %v", err)
}
if !readiness.Ready || len(readiness.MissingCriteria) != 0 {
t.Fatalf("readiness = %+v, want ready", readiness)
}
findings, err := store.Findings(task.ID, 10)
if err != nil {
t.Fatalf("Findings: %v", err)
}
if len(findings) != 1 || findings[0].ID != "f1" || !findings[0].Accepted {
t.Fatalf("findings = %+v", findings)
}
}
func TestRecordEvidenceLinksFindingToCriterionAndSatisfiesReadiness(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Record structured evidence", CreateOptions{
SuccessCriteria: []SuccessCriterion{
{ID: "verified", Description: "Verification evidence exists", Required: true},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
err = store.RecordEvidence(task.ID, "verified", Finding{
ID: "f1",
Kind: FindingKindTest,
Summary: "targeted test passed",
Source: FindingSourceCommand,
Command: "go test ./internal/autoresearch",
Accepted: true,
CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordEvidence: %v", err)
}
var spec TaskSpec
readJSON(t, filepath.Join(task.Root, "state/task_spec.json"), &spec)
if len(spec.SuccessCriteria) != 1 || len(spec.SuccessCriteria[0].EvidenceIDs) != 1 || spec.SuccessCriteria[0].EvidenceIDs[0] != "f1" {
t.Fatalf("criterion evidence ids = %+v, want f1", spec.SuccessCriteria)
}
readiness, err := store.Readiness(task.ID)
if err != nil {
t.Fatalf("Readiness: %v", err)
}
if !readiness.Ready {
t.Fatalf("readiness = %+v, want ready after linked accepted evidence", readiness)
}
}
func TestRecordEvidenceRejectsUnknownCriterion(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Reject unknown criterion", CreateOptions{
SuccessCriteria: []SuccessCriterion{
{ID: "known", Description: "Known criterion", Required: true},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
err = store.RecordEvidence(task.ID, "missing", Finding{
ID: "f1",
Kind: FindingKindTest,
Summary: "targeted test passed",
Source: FindingSourceCommand,
Accepted: true,
CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC),
})
if err == nil || !strings.Contains(err.Error(), "criterion") {
t.Fatalf("RecordEvidence unknown criterion error = %v", err)
}
}
func TestAppendFindingRejectsInvalidEntry(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Reject invalid findings", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
err = store.AppendFinding(task.ID, Finding{ID: "", Kind: FindingKindTest, Summary: "missing id", Accepted: true, CreatedAt: time.Now()})
if err == nil || !strings.Contains(err.Error(), "id") {
t.Fatalf("AppendFinding invalid error = %v, want id error", err)
}
}
func TestRecordDirectionIncrementsStaleAndRequiresPivot(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Detect repeated directions", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
progress, err := store.RecordDirection(task.ID, Direction{
Summary: "Profile markdown rendering",
AcceptedEvidenceIDs: []string{"f1"},
Now: time.Date(2026, 6, 29, 16, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection first: %v", err)
}
if progress.Iteration != 1 || progress.StaleCount != 0 || progress.CurrentDirection != "Profile markdown rendering" {
t.Fatalf("first progress = %+v", progress)
}
progress, err = store.RecordDirection(task.ID, Direction{
Summary: "Profile markdown rendering",
Now: time.Date(2026, 6, 29, 16, 1, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection second: %v", err)
}
if progress.Iteration != 2 || progress.StaleCount != 1 {
t.Fatalf("second progress = %+v, want iteration 2 stale 1", progress)
}
progress, err = store.RecordDirection(task.ID, Direction{
Summary: "Profile markdown rendering",
Now: time.Date(2026, 6, 29, 16, 2, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("RecordDirection third: %v", err)
}
if progress.StaleCount != 2 || progress.PivotCount != 1 {
t.Fatalf("third progress = %+v, want stale 2 pivot 1", progress)
}
summary, err := store.Summary(task.ID)
if err != nil {
t.Fatalf("Summary: %v", err)
}
if !summary.PivotRequired || summary.NextRequiredAction == "" {
t.Fatalf("summary = %+v, want pivot required with next action", summary)
}
}
func TestConcurrentDirectionWritesAreSerializedPerTask(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Serialize concurrent directions", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
const writers = 20
var wg sync.WaitGroup
errs := make(chan error, writers)
for i := 0; i < writers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, err := store.RecordDirection(task.ID, Direction{
Summary: "direction",
Now: time.Date(2026, 6, 30, 8, 0, i, 0, time.UTC),
})
errs <- err
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("RecordDirection concurrent error: %v", err)
}
}
summary, err := store.Summary(task.ID)
if err != nil {
t.Fatalf("Summary: %v", err)
}
if summary.Iteration != writers || summary.StaleCount != writers {
t.Fatalf("summary = %+v, want %d serialized iterations", summary, writers)
}
}
func TestReadinessBlocksMissingEvidenceAndBlockedStatus(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Block incomplete completion", CreateOptions{
SuccessCriteria: []SuccessCriterion{
{ID: "root_cause", Description: "Root cause identified", Required: true},
},
})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
readiness, err := store.Readiness(task.ID)
if err != nil {
t.Fatalf("Readiness missing: %v", err)
}
if readiness.Ready || !containsString(readiness.MissingCriteria, "root_cause") {
t.Fatalf("readiness missing = %+v, want root_cause missing", readiness)
}
_, err = store.UpdateProgress(task.ID, ProgressPatch{Status: ptrString(StatusBlocked), BlockedReason: ptrString("needs user input")})
if err != nil {
t.Fatalf("UpdateProgress: %v", err)
}
readiness, err = store.Readiness(task.ID)
if err != nil {
t.Fatalf("Readiness blocked: %v", err)
}
if readiness.Ready || !strings.Contains(readiness.BlockedReason, "needs user input") {
t.Fatalf("blocked readiness = %+v", readiness)
}
}
func TestResumeFromGoalTextLoadsExplicitTaskPath(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Resume explicit path", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
resumed, ok, err := store.ResumeFromGoalText("继续 .reasonix/autoresearch/" + task.ID + "/ 这个任务")
if err != nil {
t.Fatalf("ResumeFromGoalText: %v", err)
}
if !ok || resumed.ID != task.ID {
t.Fatalf("resumed = %+v ok=%v, want %s", resumed, ok, task.ID)
}
}
func TestListSummariesReturnsWorkspaceTasks(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
first, err := store.CreateTask("First research task", CreateOptions{
Now: func() time.Time { return time.Date(2026, 6, 29, 10, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("CreateTask first: %v", err)
}
second, err := store.CreateTask("Second research task", CreateOptions{
Now: func() time.Time { return time.Date(2026, 6, 29, 11, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("CreateTask second: %v", err)
}
summaries, err := store.ListSummaries()
if err != nil {
t.Fatalf("ListSummaries: %v", err)
}
if len(summaries) != 2 {
t.Fatalf("summaries = %+v, want two tasks", summaries)
}
if summaries[0].TaskID != second.ID || summaries[1].TaskID != first.ID {
t.Fatalf("summaries order = %+v, want newest task id first", summaries)
}
if summaries[0].Goal != "Second research task" || summaries[1].Goal != "First research task" {
t.Fatalf("summaries goals = %+v", summaries)
}
}
func TestAppendHeartbeatRecordsDurableTurnStatus(t *testing.T) {
root := t.TempDir()
store := NewStore(root)
task, err := store.CreateTask("Record heartbeats", CreateOptions{})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
err = store.AppendHeartbeat(task.ID, Heartbeat{
Status: HeartbeatStartingTurn,
Iteration: 1,
Message: "starting",
CreatedAt: time.Date(2026, 6, 29, 17, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("AppendHeartbeat: %v", err)
}
heartbeats, err := store.Heartbeats(task.ID, 10)
if err != nil {
t.Fatalf("Heartbeats: %v", err)
}
if len(heartbeats) != 1 || heartbeats[0].Status != HeartbeatStartingTurn || heartbeats[0].Iteration != 1 {
t.Fatalf("heartbeats = %+v", heartbeats)
}
summary, err := store.Summary(task.ID)
if err != nil {
t.Fatalf("Summary: %v", err)
}
if !summary.LastHeartbeatAt.Equal(heartbeats[0].CreatedAt) {
t.Fatalf("summary last heartbeat = %s, want %s", summary.LastHeartbeatAt, heartbeats[0].CreatedAt)
}
}
func readJSON(t *testing.T, path string, out any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
if err := json.Unmarshal(data, out); err != nil {
t.Fatalf("Unmarshal(%s): %v", path, err)
}
}
func writeJSON(t *testing.T, path string, v any) {
t.Helper()
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
t.Fatalf("MarshalIndent: %v", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
func containsValidationError(errors []ValidationError, file, field string) bool {
for _, err := range errors {
if err.File == file && err.Field == field {
return true
}
}
return false
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func ptrString(s string) *string {
return &s
}
+75
View File
@@ -0,0 +1,75 @@
package autoresearch
import "path/filepath"
func (s *Store) Summary(taskID string) (*Summary, error) {
task, err := s.LoadTask(taskID)
if err != nil {
return nil, err
}
storeRoot, taskRel, err := s.openTaskRoot(taskID)
if err != nil {
return nil, err
}
defer storeRoot.Close()
var progress Progress
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
return nil, err
}
findings, err := s.Findings(taskID, 0)
if err != nil {
return nil, err
}
lastHeartbeat, _, err := s.LastHeartbeat(taskID)
if err != nil {
return nil, err
}
accepted := acceptedFindingIDs(findings)
openCriteria := make([]CriterionSummary, 0)
for _, criterion := range task.Spec.SuccessCriteria {
count := countAcceptedEvidence(criterion.EvidenceIDs, accepted)
status := "satisfied"
if criterion.Required && count == 0 {
status = "open"
}
if status == "open" {
openCriteria = append(openCriteria, CriterionSummary{
ID: criterion.ID,
Description: criterion.Description,
Required: criterion.Required,
EvidenceCount: count,
Status: status,
})
}
}
summary := &Summary{
TaskID: task.ID,
Goal: task.Spec.Goal,
Status: progress.Status,
Iteration: progress.Iteration,
CurrentDirection: progress.CurrentDirection,
StaleCount: progress.StaleCount,
PivotCount: progress.PivotCount,
PivotRequired: progress.StaleCount >= 2,
LastHeartbeatAt: lastHeartbeat.CreatedAt,
FindingCount: len(findings),
OpenCriteria: openCriteria,
Blocker: progress.BlockedReason,
TaskPath: task.Root,
NextRequiredAction: nextRequiredAction(progress),
}
return summary, nil
}
func nextRequiredAction(progress Progress) string {
if progress.Status == StatusBlocked {
return "resolve blocker before continuing"
}
if progress.StaleCount >= 4 {
return "ask for the smallest external input needed"
}
if progress.StaleCount >= 2 {
return "make a structural pivot before continuing"
}
return "continue with the next evidence-producing step"
}
+159
View File
@@ -0,0 +1,159 @@
package autoresearch
import "time"
const (
StatusRunning = "running"
StatusBlocked = "blocked"
StatusComplete = "complete"
StatusStopped = "stopped"
StatusInvalid = "invalid"
)
type Task struct {
ID string
Root string
Spec TaskSpec
}
type CreateOptions struct {
Now func() time.Time
Scope []string
NonGoals []string
AllowedOperations AllowedOperations
SuccessCriteria []SuccessCriterion
}
type AllowedOperations struct {
Write bool `json:"write"`
Network bool `json:"network"`
Publish bool `json:"publish"`
}
type SuccessCriterion struct {
ID string `json:"id"`
Description string `json:"description"`
Required bool `json:"required"`
EvidenceIDs []string `json:"evidence_ids"`
}
type TaskSpec struct {
TaskID string `json:"task_id"`
Goal string `json:"goal"`
Scope []string `json:"scope"`
NonGoals []string `json:"non_goals"`
AllowedOperations AllowedOperations `json:"allowed_operations"`
SuccessCriteria []SuccessCriterion `json:"success_criteria"`
}
type Progress struct {
Status string `json:"status"`
Iteration int `json:"iteration"`
CurrentDirection string `json:"current_direction"`
StaleCount int `json:"stale_count"`
PivotCount int `json:"pivot_count"`
BlockedReason string `json:"blocked_reason"`
UpdatedAt time.Time `json:"updated_at"`
}
const (
FindingKindCommand = "command"
FindingKindFile = "file"
FindingKindTest = "test"
FindingKindBenchmark = "benchmark"
FindingKindManual = "manual"
FindingKindReview = "review"
)
const (
FindingSourceCommand = "command"
FindingSourceFile = "file"
FindingSourceManual = "manual"
)
type Finding struct {
ID string `json:"id"`
Kind string `json:"kind"`
Summary string `json:"summary"`
Source string `json:"source"`
Command string `json:"command,omitempty"`
Paths []string `json:"paths,omitempty"`
Accepted bool `json:"accepted"`
CreatedAt time.Time `json:"created_at"`
}
const (
HeartbeatStartingTurn = "starting_turn"
HeartbeatTurnDone = "turn_done"
HeartbeatWarning = "warning"
)
type Heartbeat struct {
Status string `json:"status"`
Iteration int `json:"iteration"`
Message string `json:"message,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Direction struct {
Summary string
AcceptedEvidenceIDs []string
Now time.Time
}
type DirectionTried struct {
Fingerprint string `json:"fingerprint"`
Summary string `json:"summary"`
FirstSeenIteration int `json:"first_seen_iteration"`
LastSeenIteration int `json:"last_seen_iteration"`
Count int `json:"count"`
}
type ProgressPatch struct {
Status *string
CurrentDirection *string
BlockedReason *string
}
type CriterionSummary struct {
ID string `json:"id"`
Description string `json:"description"`
Required bool `json:"required"`
EvidenceCount int `json:"evidence_count"`
Status string `json:"status"`
}
type Summary struct {
TaskID string `json:"task_id"`
Goal string `json:"goal"`
Status string `json:"status"`
Iteration int `json:"iteration"`
CurrentDirection string `json:"current_direction"`
StaleCount int `json:"stale_count"`
PivotCount int `json:"pivot_count"`
PivotRequired bool `json:"pivot_required"`
LastHeartbeatAt time.Time `json:"last_heartbeat_at,omitempty"`
FindingCount int `json:"finding_count"`
OpenCriteria []CriterionSummary `json:"open_criteria"`
Blocker string `json:"blocker"`
TaskPath string `json:"task_path"`
NextRequiredAction string `json:"next_required_action"`
}
type ReadinessReport struct {
Ready bool `json:"ready"`
MissingCriteria []string `json:"missing_criteria"`
BlockedReason string `json:"blocked_reason"`
Errors []string `json:"errors"`
}
type ValidationError struct {
File string `json:"file"`
Field string `json:"field"`
Error string `json:"error"`
}
type ValidationReport struct {
Valid bool `json:"valid"`
Errors []ValidationError `json:"errors"`
}
+1
View File
@@ -1359,6 +1359,7 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
"effort": config.EffectiveEffort(e),
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
"chat_url": e.ChatURL,
"headers": e.Headers,
"proxy_spec": proxy,
"vision": config.EffectiveVision(e),
"vision_detail": e.VisionDetail,
+30
View File
@@ -1142,3 +1142,33 @@ func (gw *BotGateway) UpdateConnectionToolApprovalMode(connID, mode string) {
update.ctrl.SetToolApprovalMode(update.mode)
}
}
// SendToAdapter sends a message through the adapter identified by connID.
// Returns an error if no matching adapter is found.
func (gw *BotGateway) SendToAdapter(ctx context.Context, connID, domain string, msg OutboundMessage) (SendResult, error) {
connID = strings.TrimSpace(connID)
domain = strings.TrimSpace(domain)
var adapter Adapter
gw.mu.Lock()
for _, binding := range gw.adapters {
if strings.TrimSpace(binding.ID) == connID &&
(domain == "" || strings.EqualFold(strings.TrimSpace(binding.Domain), domain)) {
adapter = binding.Adapter
break
}
}
gw.mu.Unlock()
if adapter != nil {
return adapter.Send(ctx, msg)
}
return SendResult{}, fmt.Errorf("SendToAdapter: no adapter found for connection %q (domain %q)", connID, domain)
}
// SendTextToAdapter sends a plain text message through the adapter identified by connID.
func (gw *BotGateway) SendTextToAdapter(ctx context.Context, connID, domain, chatID string, chatType ChatType, text string) (SendResult, error) {
return gw.SendToAdapter(ctx, connID, domain, OutboundMessage{
ChatID: chatID,
ChatType: chatType,
Text: text,
})
}
+70
View File
@@ -8,6 +8,7 @@ import (
"strings"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
@@ -76,6 +77,31 @@ func (f *fakeAdapter) sentMessages() []OutboundMessage {
return out
}
type blockingSendAdapter struct {
*fakeAdapter
entered chan struct{}
release chan struct{}
once sync.Once
}
func newBlockingSendAdapter(platform Platform, name string) *blockingSendAdapter {
return &blockingSendAdapter{
fakeAdapter: newFakeAdapter(platform, name),
entered: make(chan struct{}),
release: make(chan struct{}),
}
}
func (f *blockingSendAdapter) Send(ctx context.Context, msg OutboundMessage) (SendResult, error) {
f.once.Do(func() { close(f.entered) })
select {
case <-f.release:
case <-ctx.Done():
return SendResult{}, ctx.Err()
}
return f.fakeAdapter.Send(ctx, msg)
}
type fakeReactionAdapter struct {
*fakeAdapter
reactions []string
@@ -187,6 +213,50 @@ func TestGatewayStartsHealthyAdaptersWhenOneFails(t *testing.T) {
}
}
func TestGatewaySendToAdapterReleasesLockBeforeSend(t *testing.T) {
adapter := newBlockingSendAdapter(PlatformFeishu, "blocking-feishu")
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
gw := NewGatewayWithAdapterBindings(GatewayConfig{}, []AdapterBinding{{
ID: "feishu-lark",
Domain: "lark",
Platform: PlatformFeishu,
Adapter: adapter,
}}, logger)
sendDone := make(chan error, 1)
go func() {
_, err := gw.SendToAdapter(context.Background(), "feishu-lark", "lark", OutboundMessage{ChatID: "chat", Text: "hello"})
sendDone <- err
}()
select {
case <-adapter.entered:
case <-time.After(500 * time.Millisecond):
t.Fatal("adapter send did not start")
}
updateDone := make(chan struct{})
go func() {
gw.UpdateConnectionToolApprovalMode("feishu-lark", "ask")
close(updateDone)
}()
select {
case <-updateDone:
case <-time.After(500 * time.Millisecond):
t.Fatal("UpdateConnectionToolApprovalMode blocked behind SendToAdapter")
}
close(adapter.release)
select {
case err := <-sendDone:
if err != nil {
t.Fatalf("SendToAdapter returned error: %v", err)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("SendToAdapter did not finish after release")
}
}
func TestGatewayReturnsErrorWhenAllAdaptersFail(t *testing.T) {
cfg := GatewayConfig{
Enabled: map[Platform]bool{PlatformWeixin: true},
+1 -1
View File
@@ -1145,7 +1145,7 @@ func fetchModelListCompat(ctx context.Context, baseURL, apiKey string) ([]string
var lastErr error
var firstHardErr error
for _, u := range candidates {
models, err := openai.FetchModels(ctx, u, apiKey)
models, err := openai.FetchModels(ctx, u, apiKey, nil)
if err == nil {
return models, nil
}
+64 -9
View File
@@ -962,15 +962,16 @@ func NormalizeMemoryCompilerVerbosity(v string) string {
// token budget; the harness compacts older history as a turn's prompt approaches
// it (see agent compaction). 0 disables compaction for the instance.
type ProviderEntry struct {
Name string `toml:"name"`
Kind string `toml:"kind"`
BaseURL string `toml:"base_url"`
ChatURL string `toml:"chat_url"`
Model string `toml:"model"` // a single model (back-compat)
Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models)
ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup
Default string `toml:"default"` // default model when Models is set (else Models[0])
APIKeyEnv string `toml:"api_key_env"`
Name string `toml:"name"`
Kind string `toml:"kind"`
BaseURL string `toml:"base_url"`
ChatURL string `toml:"chat_url"`
Model string `toml:"model"` // a single model (back-compat)
Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models)
ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup
Default string `toml:"default"` // default model when Models is set (else Models[0])
APIKeyEnv string `toml:"api_key_env"`
Headers map[string]string `toml:"headers"` // optional extra HTTP headers for OpenAI-compatible gateways; secrets should stay in api_key_env.
resolvedAPIKey string
resolvedSource CredentialSource
BalanceURL string `toml:"balance_url"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.
@@ -1012,11 +1013,24 @@ type ProviderEntry struct {
// DefaultEffort is the /effort level used when the user picks "auto" or
// has not set Effort. Ignored when SupportedEfforts is empty.
DefaultEffort string `toml:"default_effort"`
// ModelOverrides customizes capability metadata after ResolveModel selects a
// concrete model from a multi-model provider. Use it when a gateway exposes
// mixed DeepSeek/OpenAI/no-reasoning or mixed vision/text models under one
// base_url/key.
ModelOverrides map[string]ProviderModelOverride `toml:"model_overrides"`
visionOverride *bool
// NoProxy reaches this provider's base_url directly, never through the proxy.
// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).
NoProxy bool `toml:"no_proxy"`
}
type ProviderModelOverride struct {
ReasoningProtocol string `toml:"reasoning_protocol"`
SupportedEfforts []string `toml:"supported_efforts"`
DefaultEffort string `toml:"default_effort"`
Vision *bool `toml:"vision"`
}
// ModelList returns the models this provider exposes: the explicit `models` list,
// or the single `model` as a one-element list (back-compat). Empty if neither set.
func (e *ProviderEntry) ModelList() []string {
@@ -1140,6 +1154,44 @@ func (e *ProviderEntry) applyModelPrice() {
e.Price = e.PriceForModel(e.Model)
}
func (e *ProviderEntry) applyModelOverride() {
if e == nil || len(e.ModelOverrides) == 0 {
return
}
ov, ok := e.modelOverrideForModel(e.Model)
if !ok {
return
}
if ov.ReasoningProtocol != "" {
e.ReasoningProtocol = ov.ReasoningProtocol
}
if ov.SupportedEfforts != nil {
e.SupportedEfforts = append([]string(nil), ov.SupportedEfforts...)
}
if ov.DefaultEffort != "" || ov.SupportedEfforts != nil {
e.DefaultEffort = ov.DefaultEffort
}
if ov.Vision != nil {
e.visionOverride = ov.Vision
}
}
func (e *ProviderEntry) modelOverrideForModel(model string) (ProviderModelOverride, bool) {
model = strings.TrimSpace(model)
if e == nil || model == "" || len(e.ModelOverrides) == 0 {
return ProviderModelOverride{}, false
}
if ov, ok := e.ModelOverrides[model]; ok {
return ov, true
}
for k, ov := range e.ModelOverrides {
if strings.EqualFold(strings.TrimSpace(k), model) {
return ov, true
}
}
return ProviderModelOverride{}, false
}
func clonePricing(p *provider.Pricing) *provider.Pricing {
if p == nil {
return nil
@@ -1433,6 +1485,7 @@ func (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {
cp := *e
cp.Model = model
cp.applyModelPrice()
cp.applyModelOverride()
return &cp, true
}
}
@@ -1441,6 +1494,7 @@ func (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {
cp := *e
cp.Model = e.DefaultModel()
cp.applyModelPrice()
cp.applyModelOverride()
return &cp, true
}
// a bare model name → the provider that lists it
@@ -1449,6 +1503,7 @@ func (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) {
cp := c.Providers[i]
cp.Model = ref
cp.applyModelPrice()
cp.applyModelOverride()
return &cp, true
}
}
+44
View File
@@ -688,6 +688,50 @@ func TestEffectiveVisionUsesPerModelVisionList(t *testing.T) {
}
}
func TestResolveModelAppliesModelOverrides(t *testing.T) {
visionOff := false
c := &Config{Providers: []ProviderEntry{{
Name: "gateway",
Kind: "openai",
BaseURL: "https://proxy.example.com/v1",
Models: []string{"deepseek-v4-flash", "plain-chat"},
Default: "plain-chat",
ReasoningProtocol: ReasoningProtocolOpenAI,
SupportedEfforts: []string{"low", "medium", "high"},
ModelOverrides: map[string]ProviderModelOverride{
"deepseek-v4-flash": {
ReasoningProtocol: ReasoningProtocolDeepSeek,
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "max",
Vision: &visionOff,
},
},
}}}
deepseek, ok := c.ResolveModel("gateway/deepseek-v4-flash")
if !ok {
t.Fatal("ResolveModel did not find gateway/deepseek-v4-flash")
}
if protocol := ReasoningProtocolForEntry(deepseek); protocol != ReasoningProtocolDeepSeek {
t.Fatalf("deepseek protocol = %q, want deepseek", protocol)
}
cap := EffortCapabilityForEntry(deepseek)
if cap.Default != "max" || !containsString(cap.Levels, "max") || containsString(cap.Levels, "low") {
t.Fatalf("deepseek effort capability = %+v, want high|max default max", cap)
}
if EffectiveVision(deepseek) {
t.Fatalf("vision override false should disable image input")
}
plain, ok := c.ResolveModel("gateway/plain-chat")
if !ok {
t.Fatal("ResolveModel did not find gateway/plain-chat")
}
if protocol := ReasoningProtocolForEntry(plain); protocol != ReasoningProtocolOpenAI {
t.Fatalf("plain protocol = %q, want provider-level openai", protocol)
}
}
func TestRemoveProvider(t *testing.T) {
c := Default()
c.Agent.PlannerModel = "deepseek-pro"
+57 -2
View File
@@ -196,10 +196,12 @@ func normalizeProviderEffortFields(e *ProviderEntry) {
if e == nil {
return
}
e.Headers = normalizedProviderHeaders(e.Headers)
e.Effort = normalizeStoredEffort(e.Effort)
e.ReasoningProtocol = normalizeReasoningProtocol(e.ReasoningProtocol)
e.DefaultEffort = normalizeEffortLevel(e.DefaultEffort)
e.SupportedEfforts = normalizedSupportedEfforts(e)
e.ModelOverrides = normalizedModelOverrides(e.ModelOverrides)
}
func normalizeStoredEffort(raw string) string {
@@ -317,9 +319,16 @@ func normalizedSupportedEfforts(e *ProviderEntry) []string {
if e == nil || len(e.SupportedEfforts) == 0 {
return nil
}
out := make([]string, 0, len(e.SupportedEfforts))
return normalizedEffortLevels(e.SupportedEfforts)
}
func normalizedEffortLevels(levels []string) []string {
if len(levels) == 0 {
return nil
}
out := make([]string, 0, len(levels))
seen := map[string]bool{}
for _, raw := range e.SupportedEfforts {
for _, raw := range levels {
level := normalizeEffortLevel(raw)
if level == "" || level == "auto" || seen[level] {
continue
@@ -329,3 +338,49 @@ func normalizedSupportedEfforts(e *ProviderEntry) []string {
}
return out
}
func normalizedProviderHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 {
return nil
}
out := make(map[string]string, len(headers))
for rawName, rawValue := range headers {
name := strings.TrimSpace(rawName)
value := strings.TrimSpace(rawValue)
if name == "" || value == "" {
continue
}
out[name] = value
}
if len(out) == 0 {
return nil
}
return out
}
func normalizedModelOverrides(overrides map[string]ProviderModelOverride) map[string]ProviderModelOverride {
if len(overrides) == 0 {
return nil
}
out := make(map[string]ProviderModelOverride, len(overrides))
for rawModel, ov := range overrides {
model := strings.TrimSpace(rawModel)
if model == "" {
continue
}
ov.ReasoningProtocol = normalizeReasoningProtocol(ov.ReasoningProtocol)
ov.SupportedEfforts = normalizedEffortLevels(ov.SupportedEfforts)
ov.DefaultEffort = normalizeEffortLevel(ov.DefaultEffort)
if ov.DefaultEffort != "" && !containsString(ov.SupportedEfforts, ov.DefaultEffort) {
ov.DefaultEffort = ""
}
if ov.ReasoningProtocol == "" && len(ov.SupportedEfforts) == 0 && ov.DefaultEffort == "" && ov.Vision == nil {
continue
}
out[model] = ov
}
if len(out) == 0 {
return nil
}
return out
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (e *ProviderEntry) FetchModels(ctx context.Context) ([]string, error) {
var lastErr error
var firstHardErr error
for _, u := range candidates {
models, err := openai.FetchModels(ctx, u, key)
models, err := openai.FetchModels(ctx, u, key, e.Headers)
if err == nil {
return models, nil
}
+54
View File
@@ -291,6 +291,9 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
fmt.Fprintf(&b, "models_url = %q # auto-fetch models from this URL on startup\n", p.ModelsURL)
}
fmt.Fprintf(&b, "api_key_env = %q\n", p.APIKeyEnv)
if len(p.Headers) > 0 {
fmt.Fprintf(&b, "headers = %s # extra static request headers; keep secrets in api_key_env\n", renderStringMap(p.Headers))
}
if p.BalanceURL != "" {
fmt.Fprintf(&b, "balance_url = %q # optional; wallet-balance endpoint shown in the status bar\n", p.BalanceURL)
}
@@ -327,6 +330,9 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
if p.DefaultEffort != "" {
fmt.Fprintf(&b, "default_effort = %q # used when /effort is auto or unset; must be one of supported_efforts\n", p.DefaultEffort)
}
if len(p.ModelOverrides) > 0 {
fmt.Fprintf(&b, "model_overrides = %s # per-model reasoning/vision overrides for mixed gateways\n", renderModelOverrides(p.ModelOverrides))
}
if p.NoProxy {
b.WriteString("no_proxy = true # reach this base_url directly, never via the proxy\n")
}
@@ -769,6 +775,9 @@ func RenderTOMLProjectDelta(c *Config) string {
fmt.Fprintf(&b, "models_url = %q\n", p.ModelsURL)
}
fmt.Fprintf(&b, "api_key_env = %q\n", p.APIKeyEnv)
if len(p.Headers) > 0 {
fmt.Fprintf(&b, "headers = %s\n", renderStringMap(p.Headers))
}
if p.BalanceURL != "" {
fmt.Fprintf(&b, "balance_url = %q\n", p.BalanceURL)
}
@@ -805,6 +814,9 @@ func RenderTOMLProjectDelta(c *Config) string {
if p.DefaultEffort != "" {
fmt.Fprintf(&b, "default_effort = %q\n", p.DefaultEffort)
}
if len(p.ModelOverrides) > 0 {
fmt.Fprintf(&b, "model_overrides = %s\n", renderModelOverrides(p.ModelOverrides))
}
if p.NoProxy {
b.WriteString("no_proxy = true\n")
}
@@ -1183,6 +1195,48 @@ func renderStringMap(m map[string]string) string {
return b.String()
}
func renderModelOverrides(m map[string]ProviderModelOverride) string {
keys := make([]string, 0, len(m))
for k, ov := range m {
if k == "" || modelOverrideEmpty(ov) {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("{ ")
for i, k := range keys {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%q = %s", k, renderModelOverride(m[k]))
}
b.WriteString(" }")
return b.String()
}
func renderModelOverride(ov ProviderModelOverride) string {
var parts []string
if ov.ReasoningProtocol != "" {
parts = append(parts, fmt.Sprintf("reasoning_protocol = %q", ov.ReasoningProtocol))
}
if len(ov.SupportedEfforts) > 0 {
parts = append(parts, "supported_efforts = "+renderStringArray(ov.SupportedEfforts))
}
if ov.DefaultEffort != "" {
parts = append(parts, fmt.Sprintf("default_effort = %q", ov.DefaultEffort))
}
if ov.Vision != nil {
parts = append(parts, fmt.Sprintf("vision = %t", *ov.Vision))
}
return "{ " + strings.Join(parts, ", ") + " }"
}
func modelOverrideEmpty(ov ProviderModelOverride) bool {
return ov.ReasoningProtocol == "" && len(ov.SupportedEfforts) == 0 && ov.DefaultEffort == "" && ov.Vision == nil
}
func hasPositiveIntMap(m map[string]int) bool {
for k, v := range m {
if strings.TrimSpace(k) != "" && v > 0 {
+48
View File
@@ -828,6 +828,54 @@ func TestRenderTOMLRoundTripsVisionModels(t *testing.T) {
}
}
func TestRenderTOMLRoundTripsProviderHeadersAndModelOverrides(t *testing.T) {
orig := Default()
orig.Providers = []ProviderEntry{{
Name: "gateway",
Kind: "openai",
BaseURL: "https://gateway.example/v1",
Models: []string{"deepseek-v4-flash", "plain-chat"},
Default: "plain-chat",
APIKeyEnv: "GATEWAY_API_KEY",
Headers: map[string]string{
"HTTP-Referer": "https://app.example",
"X-Title": "Reasonix",
},
ModelOverrides: map[string]ProviderModelOverride{
"deepseek-v4-flash": {
ReasoningProtocol: ReasoningProtocolDeepSeek,
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "high",
Vision: boolPtr(false),
},
},
}}
rendered := RenderTOML(orig)
if !strings.Contains(rendered, `headers = { HTTP-Referer = "https://app.example", X-Title = "Reasonix" }`) {
t.Fatalf("rendered TOML missing headers:\n%s", rendered)
}
if !strings.Contains(rendered, `model_overrides`) || !strings.Contains(rendered, `reasoning_protocol = "deepseek"`) {
t.Fatalf("rendered TOML missing model overrides:\n%s", rendered)
}
var got Config
if _, err := toml.Decode(rendered, &got); err != nil {
t.Fatalf("rendered TOML does not parse: %v\n%s", err, rendered)
}
p, ok := got.Provider("gateway")
if !ok {
t.Fatal("gateway provider missing after round trip")
}
if p.Headers["HTTP-Referer"] != "https://app.example" || p.Headers["X-Title"] != "Reasonix" {
t.Fatalf("headers after round trip = %+v", p.Headers)
}
ov := p.ModelOverrides["deepseek-v4-flash"]
if ov.ReasoningProtocol != ReasoningProtocolDeepSeek || !reflect.DeepEqual(ov.SupportedEfforts, []string{"high", "max"}) || ov.DefaultEffort != "high" || ov.Vision == nil || *ov.Vision {
t.Fatalf("model override after round trip = %+v", ov)
}
}
func boolPtr(v bool) *bool { return &v }
func intPtr(v int) *int { return &v }
+3
View File
@@ -66,6 +66,9 @@ func EffectiveVision(e *ProviderEntry) bool {
if e == nil {
return false
}
if e.visionOverride != nil {
return *e.visionOverride
}
if e.Vision {
return true
}
+354 -2
View File
@@ -22,6 +22,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
@@ -29,6 +30,7 @@ import (
"time"
"reasonix/internal/agent"
"reasonix/internal/autoresearch"
"reasonix/internal/billing"
"reasonix/internal/checkpoint"
"reasonix/internal/command"
@@ -121,7 +123,8 @@ type Controller struct {
// goals owns the active goal's FSM (status, intercepts, idle/turn counters)
// and its persistence, behind its own mutex so a per-turn goal save never
// stalls an approval or status poll on c.mu. See goal.go.
goals goalMachine
goals goalMachine
autoResearch *autoresearch.Store
// workspaceRoot is the workspace root: the base for resolving @-refs and slash
// path refs, the working directory for user "!" shell commands and custom
@@ -190,6 +193,16 @@ type pendingAsk struct {
reply chan []event.AskAnswer
}
type AutoResearchEvidenceInput struct {
ID string
Kind string
Summary string
Source string
Command string
Paths []string
Accepted bool
}
type plannerSessionResetter interface {
ResetPlannerSession()
}
@@ -362,6 +375,9 @@ func New(opts Options) *Controller {
externalFolderToolRefs: opts.ExternalFolderToolRefs,
approval: newApprovalManager(opts.Policy, ToolApprovalAsk, opts.ApprovalTimeout),
}
if strings.TrimSpace(opts.WorkspaceRoot) != "" {
c.autoResearch = autoresearch.NewStore(opts.WorkspaceRoot)
}
// Checkpoints: bind a store to the session and route writer pre-edits into it.
c.rebindCheckpoints(opts.SessionPath)
c.setActiveJobSession(opts.SessionPath)
@@ -1554,8 +1570,343 @@ func (c *Controller) SetGoal(goal string) {
}
func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) {
path, data, ok := c.goals.set(goal, researchMode, c.goalTodos())
taskID, blockReason := c.ensureAutoResearchTask(goal, researchMode)
path, data, ok := c.goals.set(goal, researchMode, taskID, c.goalTodos())
c.persistGoalState(path, data, ok)
if blockReason != "" {
path, data, ok := c.goals.stop(GoalStatusBlocked, c.goalTodos())
c.persistGoalState(path, data, ok)
c.notice("autoresearch resume failed: " + blockReason)
}
}
func (c *Controller) ensureAutoResearchTask(goal string, researchMode GoalResearchMode) (string, string) {
goal = strings.TrimSpace(goal)
if goal == "" || c.autoResearch == nil || !shouldUseAutoResearch(goal, researchMode) {
return "", ""
}
currentGoal, currentStatus, _, currentTaskID := c.goals.snapshot()
if strings.TrimSpace(currentGoal) == goal && currentStatus == GoalStatusRunning && strings.TrimSpace(currentTaskID) != "" {
return currentTaskID, ""
}
if task, ok, err := c.autoResearch.ResumeFromGoalText(goal); err != nil {
slog.Warn("controller: resume autoresearch task", "err", err)
if ok {
return "", err.Error()
}
} else if ok {
c.notice("autoresearch task resumed: " + task.ID)
return task.ID, ""
}
task, err := c.autoResearch.CreateTask(goal, autoresearch.CreateOptions{
AllowedOperations: autoresearch.AllowedOperations{
Write: true,
Network: false,
Publish: false,
},
SuccessCriteria: defaultAutoResearchSuccessCriteria(),
})
if err != nil {
slog.Warn("controller: create autoresearch task", "err", err)
return "", ""
}
c.notice("autoresearch task created: " + task.ID)
return task.ID, ""
}
func defaultAutoResearchSuccessCriteria() []autoresearch.SuccessCriterion {
return []autoresearch.SuccessCriterion{
{
ID: "objective_evidence",
Description: "The goal outcome is supported by direct evidence, such as inspected code, reproduced behavior, source material, or concrete findings.",
Required: true,
},
{
ID: "verification",
Description: "The result has relevant verification evidence, such as tests, commands, benchmarks, manual checks, or a documented reason why verification is not applicable.",
Required: true,
},
}
}
func (c *Controller) appendAutoResearchHeartbeat(taskID, status, message string) {
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return
}
iteration := 0
if summary, err := c.autoResearch.Summary(taskID); err == nil {
iteration = summary.Iteration
}
if err := c.autoResearch.AppendHeartbeat(taskID, autoresearch.Heartbeat{
Status: status,
Iteration: iteration,
Message: message,
CreatedAt: time.Now().UTC(),
}); err != nil {
slog.Warn("controller: append autoresearch heartbeat", "task_id", taskID, "status", status, "err", err)
}
}
func (c *Controller) autoResearchAcceptedEvidenceIDs(taskID string) map[string]bool {
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return nil
}
findings, err := c.autoResearch.Findings(taskID, 0)
if err != nil {
slog.Warn("controller: read autoresearch findings", "task_id", taskID, "err", err)
return nil
}
accepted := make(map[string]bool, len(findings))
for _, finding := range findings {
if finding.Accepted {
accepted[finding.ID] = true
}
}
return accepted
}
func (c *Controller) recordAutoResearchTurnProgress(taskID string, acceptedBefore map[string]bool) {
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return
}
acceptedAfter := c.autoResearchAcceptedEvidenceIDs(taskID)
newAccepted := make([]string, 0)
for id := range acceptedAfter {
if acceptedBefore == nil || !acceptedBefore[id] {
newAccepted = append(newAccepted, id)
}
}
sort.Strings(newAccepted)
summary := autoResearchDirectionSummary(lastAssistantText(c.History()))
if _, err := c.autoResearch.RecordDirection(taskID, autoresearch.Direction{
Summary: summary,
AcceptedEvidenceIDs: newAccepted,
Now: time.Now().UTC(),
}); err != nil {
slog.Warn("controller: record autoresearch direction", "task_id", taskID, "err", err)
}
}
func (c *Controller) recordAutoResearchEvidenceFromAssistant(taskID, text string) {
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return
}
for _, item := range parseAutoResearchEvidenceBlocks(text) {
if err := c.recordAutoResearchEvidenceForTask(taskID, item.CriterionID, AutoResearchEvidenceInput{
ID: item.ID,
Kind: item.Kind,
Summary: item.Summary,
Source: item.Source,
Command: item.Command,
Paths: append([]string(nil), item.Paths...),
Accepted: item.Accepted,
}); err != nil {
slog.Warn("controller: record autoresearch evidence block", "task_id", taskID, "criterion_id", item.CriterionID, "err", err)
}
}
}
type autoResearchEvidenceBlock struct {
CriterionID string `json:"criterion_id"`
ID string `json:"id"`
Kind string `json:"kind"`
Summary string `json:"summary"`
Source string `json:"source"`
Command string `json:"command"`
Paths []string `json:"paths"`
Accepted bool `json:"accepted"`
}
const (
autoResearchEvidenceOpen = "<autoresearch-evidence>"
autoResearchEvidenceClose = "</autoresearch-evidence>"
)
func parseAutoResearchEvidenceBlocks(text string) []autoResearchEvidenceBlock {
var out []autoResearchEvidenceBlock
rest := text
for {
start := strings.Index(rest, autoResearchEvidenceOpen)
if start < 0 {
return out
}
rest = rest[start+len(autoResearchEvidenceOpen):]
end := strings.Index(rest, autoResearchEvidenceClose)
if end < 0 {
return out
}
raw := strings.TrimSpace(rest[:end])
rest = rest[end+len(autoResearchEvidenceClose):]
if raw == "" {
continue
}
var many []autoResearchEvidenceBlock
if err := json.Unmarshal([]byte(raw), &many); err == nil {
out = append(out, many...)
continue
}
var one autoResearchEvidenceBlock
if err := json.Unmarshal([]byte(raw), &one); err == nil {
out = append(out, one)
}
}
}
func autoResearchDirectionSummary(text string) string {
text = stripAutoResearchEvidenceBlocks(text)
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
lower := strings.ToLower(line)
if line == "" || strings.HasPrefix(lower, "[goal:") {
continue
}
if len(line) > 160 {
line = line[:160]
}
return line
}
return "turn completed"
}
func stripAutoResearchEvidenceBlocks(text string) string {
var b strings.Builder
rest := text
for {
start := strings.Index(rest, autoResearchEvidenceOpen)
if start < 0 {
b.WriteString(rest)
return b.String()
}
b.WriteString(rest[:start])
afterOpen := rest[start+len(autoResearchEvidenceOpen):]
end := strings.Index(afterOpen, autoResearchEvidenceClose)
if end < 0 {
return b.String()
}
rest = afterOpen[end+len(autoResearchEvidenceClose):]
}
}
func (c *Controller) autoResearchReadinessFailure() string {
taskID := c.goals.currentAutoResearchTaskID()
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return ""
}
report, err := c.autoResearch.Readiness(taskID)
if err != nil {
return "AutoResearch readiness check failed: " + err.Error()
}
if report.Ready {
return ""
}
var parts []string
if len(report.MissingCriteria) > 0 {
parts = append(parts, "missing criteria: "+strings.Join(report.MissingCriteria, ", "))
}
if report.BlockedReason != "" {
parts = append(parts, "blocked: "+report.BlockedReason)
}
if len(report.Errors) > 0 {
parts = append(parts, "state errors: "+strings.Join(report.Errors, "; "))
}
if len(parts) == 0 {
parts = append(parts, "task is not ready")
}
return "AutoResearch readiness check failed: " + strings.Join(parts, "; ")
}
func (c *Controller) AutoResearchSummary() (*autoresearch.Summary, bool) {
taskID := c.goals.currentAutoResearchTaskID()
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return nil, false
}
summary, err := c.autoResearch.Summary(taskID)
if err != nil {
return &autoresearch.Summary{
TaskID: taskID,
Status: autoresearch.StatusInvalid,
Blocker: err.Error(),
}, true
}
return summary, true
}
func (c *Controller) AutoResearchList() ([]autoresearch.Summary, bool) {
if c.autoResearch == nil {
return nil, false
}
summaries, err := c.autoResearch.ListSummaries()
if err != nil {
slog.Warn("controller: list autoresearch tasks", "err", err)
return nil, true
}
return summaries, true
}
func (c *Controller) AutoResearchFindings(limit int) ([]autoresearch.Finding, bool) {
taskID := c.goals.currentAutoResearchTaskID()
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return nil, false
}
findings, err := c.autoResearch.Findings(taskID, limit)
if err != nil {
return nil, true
}
return findings, true
}
func (c *Controller) RecordAutoResearchEvidence(criterionID string, input AutoResearchEvidenceInput) error {
taskID := c.goals.currentAutoResearchTaskID()
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return errors.New("autoresearch: no active task")
}
return c.recordAutoResearchEvidenceForTask(taskID, criterionID, input)
}
func (c *Controller) recordAutoResearchEvidenceForTask(taskID, criterionID string, input AutoResearchEvidenceInput) error {
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return errors.New("autoresearch: no active task")
}
id := strings.TrimSpace(input.ID)
if id == "" {
id = c.nextAutoResearchFindingID(taskID)
}
kind := strings.TrimSpace(input.Kind)
if kind == "" {
kind = autoresearch.FindingKindManual
}
source := strings.TrimSpace(input.Source)
if source == "" {
source = autoresearch.FindingSourceManual
}
finding := autoresearch.Finding{
ID: id,
Kind: kind,
Summary: strings.TrimSpace(input.Summary),
Source: source,
Command: strings.TrimSpace(input.Command),
Paths: append([]string(nil), input.Paths...),
Accepted: input.Accepted,
CreatedAt: time.Now().UTC(),
}
return c.autoResearch.RecordEvidence(taskID, criterionID, finding)
}
func (c *Controller) nextAutoResearchFindingID(taskID string) string {
findings, err := c.autoResearch.Findings(taskID, 0)
if err != nil {
return fmt.Sprintf("f%d", time.Now().UTC().UnixNano())
}
used := make(map[string]bool, len(findings))
for _, finding := range findings {
used[finding.ID] = true
}
for i := 1; ; i++ {
id := fmt.Sprintf("f%d", len(findings)+i)
if !used[id] {
return id
}
}
}
func (c *Controller) ClearGoal() {
@@ -2104,6 +2455,7 @@ func (c *Controller) Resume(s *agent.Session, path string) {
c.mu.Unlock()
c.setActiveJobSession(path)
c.rebindCheckpoints(path)
c.goals.restoreRunningFromState(path)
c.restoreTerminalGoalTodos(path)
c.loadGuardianSession()
c.recoverInterruptedTurn(path)
+46
View File
@@ -480,6 +480,52 @@ func TestResumeKeepsTranscriptTodosForRunningGoalSidecar(t *testing.T) {
}
}
func TestResumeRestoresRunningAutoResearchGoalFromSidecar(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
path := filepath.Join(root, "session.jsonl")
taskID := "investigate-runtime-resume"
if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch", taskID, "logs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "task_spec.json"), []byte(`{"id":"investigate-runtime-resume","goal":"investigate runtime resume","status":"running","created_at":"2026-06-30T00:00:00Z","updated_at":"2026-06-30T00:00:00Z","success_criteria":[{"id":"criterion-1","description":"resume keeps AutoResearch active","required":true}]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "progress.json"), []byte(`{"task_id":"investigate-runtime-resume","iteration":2,"current_direction":"verify resume","stale_count":1,"pivot_count":0,"updated_at":"2026-06-30T00:00:00Z"}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "directions_tried.json"), []byte(`{"task_id":"investigate-runtime-resume","directions":[]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "findings.jsonl"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "logs", "heartbeat.jsonl"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(goalStatePath(path), []byte(`{"goal":"investigate runtime resume","status":"running","researchMode":1,"autoResearchTaskID":"investigate-runtime-resume"}`), 0o644); err != nil {
t.Fatal(err)
}
loaded := agent.NewSession("sys")
exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
c := New(Options{Executor: exec, WorkspaceRoot: root, SessionDir: root, Label: "test"})
c.Resume(loaded, path)
if got := c.Goal(); got != "investigate runtime resume" {
t.Fatalf("Goal() after resume = %q, want running goal from sidecar", got)
}
composed := c.Compose("continue")
if !strings.Contains(composed, "<autoresearch-runtime>") || !strings.Contains(composed, "task_id: "+taskID) {
t.Fatalf("Compose after resume missing AutoResearch runtime for %q:\n%s", taskID, composed)
}
}
func TestRunTurnRecordsDisplayForPersistedUserMessage(t *testing.T) {
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+83 -34
View File
@@ -32,18 +32,19 @@ const (
type goalMachine struct {
// mu guards the FSM fields below; every critical section under it is short
// and non-blocking (no disk I/O, no executor calls).
mu sync.Mutex
goal string
status string
researchMode GoalResearchMode
turns int
blocks int
block string
interceptMsg string
intercepts int
strict bool
selfCheckDone bool
idleTurns int
mu sync.Mutex
goal string
status string
researchMode GoalResearchMode
autoResearchTaskID string
turns int
blocks int
block string
interceptMsg string
intercepts int
strict bool
selfCheckDone bool
idleTurns int
// statePath is the persisted goal-state sidecar; empty disables persistence.
statePath string
@@ -54,14 +55,15 @@ type goalMachine struct {
// goalState is the serializable form of a running goal.
type goalState struct {
Goal string `json:"goal,omitempty"`
Status string `json:"status,omitempty"`
ResearchMode GoalResearchMode `json:"researchMode,omitempty"`
Turns int `json:"turns,omitempty"`
Blocks int `json:"blocks,omitempty"`
Block string `json:"block,omitempty"`
Strict bool `json:"strict,omitempty"`
Todos []evidence.TodoItem `json:"todos,omitempty"`
Goal string `json:"goal,omitempty"`
Status string `json:"status,omitempty"`
ResearchMode GoalResearchMode `json:"researchMode,omitempty"`
AutoResearchTaskID string `json:"autoResearchTaskID,omitempty"`
Turns int `json:"turns,omitempty"`
Blocks int `json:"blocks,omitempty"`
Block string `json:"block,omitempty"`
Strict bool `json:"strict,omitempty"`
Todos []evidence.TodoItem `json:"todos,omitempty"`
}
// goalAdvanceInput carries everything the FSM needs for one continuation step,
@@ -97,10 +99,10 @@ func (g *goalMachine) setStatePath(path string) {
}
// snapshot returns the fields Compose injects into outgoing turns.
func (g *goalMachine) snapshot() (goal, status string, mode GoalResearchMode) {
func (g *goalMachine) snapshot() (goal, status string, mode GoalResearchMode, autoResearchTaskID string) {
g.mu.Lock()
defer g.mu.Unlock()
return g.goal, g.status, g.researchMode
return g.goal, g.status, g.researchMode, g.autoResearchTaskID
}
func (g *goalMachine) goalText() string {
@@ -109,6 +111,15 @@ func (g *goalMachine) goalText() string {
return g.goal
}
func (g *goalMachine) currentAutoResearchTaskID() string {
g.mu.Lock()
defer g.mu.Unlock()
if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning {
return ""
}
return g.autoResearchTaskID
}
// active reports whether a goal is currently running.
func (g *goalMachine) active() bool {
g.mu.Lock()
@@ -129,20 +140,20 @@ func (g *goalMachine) statusForDisplay() string {
// set installs a session-scoped goal (or clears it when goal is empty), resets
// the per-goal counters, and returns the state to persist. ok is false (no
// persistence) when the goal is unchanged or no state path is configured.
func (g *goalMachine) set(goal string, mode GoalResearchMode, todos []evidence.TodoItem) (string, []byte, bool) {
func (g *goalMachine) set(goal string, mode GoalResearchMode, autoResearchTaskID string, todos []evidence.TodoItem) (string, []byte, bool) {
goal = strings.TrimSpace(goal)
g.mu.Lock()
defer g.mu.Unlock()
if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode {
if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode && g.autoResearchTaskID == autoResearchTaskID {
return "", nil, false
}
g.turns, g.blocks, g.block = 0, 0, ""
g.interceptMsg, g.intercepts = "", 0
g.selfCheckDone, g.idleTurns, g.strict = false, 0, false
if goal == "" {
g.goal, g.status, g.researchMode = "", GoalStatusStopped, GoalResearchAuto
g.goal, g.status, g.researchMode, g.autoResearchTaskID = "", GoalStatusStopped, GoalResearchAuto, ""
} else {
g.goal, g.status, g.researchMode = goal, GoalStatusRunning, mode
g.goal, g.status, g.researchMode, g.autoResearchTaskID = goal, GoalStatusRunning, mode, autoResearchTaskID
}
return g.buildStateLocked(todos)
}
@@ -279,14 +290,15 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
return "", nil, false
}
state := goalState{
Goal: g.goal,
Status: g.status,
ResearchMode: g.researchMode,
Turns: g.turns,
Blocks: g.blocks,
Block: g.block,
Strict: g.strict,
Todos: todos,
Goal: g.goal,
Status: g.status,
ResearchMode: g.researchMode,
AutoResearchTaskID: g.autoResearchTaskID,
Turns: g.turns,
Blocks: g.blocks,
Block: g.block,
Strict: g.strict,
Todos: todos,
}
b, err := json.Marshal(state)
if err != nil {
@@ -358,6 +370,43 @@ func (g *goalMachine) terminalTodosFromState(sessionPath string) ([]evidence.Tod
return append([]evidence.TodoItem(nil), state.Todos...), true
}
// restoreRunningFromState reloads the active running goal from the persisted
// sidecar during cold resume. Terminal sidecar data is intentionally ignored:
// terminal todo repair is handled by terminalTodosFromState without reviving the
// goal loop.
func (g *goalMachine) restoreRunningFromState(sessionPath string) {
if strings.TrimSpace(sessionPath) == "" {
return
}
data, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
if !os.IsNotExist(err) {
slog.Warn("controller: read goal state", "err", err)
}
return
}
var state goalState
if err := json.Unmarshal(data, &state); err != nil {
slog.Warn("controller: parse goal state", "err", err)
return
}
if state.Status != GoalStatusRunning || strings.TrimSpace(state.Goal) == "" {
return
}
g.mu.Lock()
defer g.mu.Unlock()
g.goal = strings.TrimSpace(state.Goal)
g.status = GoalStatusRunning
g.researchMode = state.ResearchMode
g.autoResearchTaskID = strings.TrimSpace(state.AutoResearchTaskID)
g.turns = state.Turns
g.blocks = state.Blocks
g.block = state.Block
g.strict = state.Strict
g.interceptMsg, g.intercepts = "", 0
g.selfCheckDone, g.idleTurns = false, 0
}
// formatIncompleteTodos renders the reminder shown when [goal:complete] arrives
// while the executor's canonical todos or project-readiness checks aren't done.
// Returns empty when nothing is blocking. Pure: the caller gathers todos and the
+523
View File
@@ -1,6 +1,8 @@
package control
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -165,6 +167,488 @@ func TestPlainInputAutoStartedGoalPreservesRefs(t *testing.T) {
}
}
func TestResearchGoalCreatesHostManagedAutoResearchTask(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
ag := agent.New(&scriptedTurns{}, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
c := New(Options{WorkspaceRoot: root, SessionPath: sessionPath, Runner: ag, Executor: ag})
c.SetGoalWithResearchMode("fix the typo and add a test", GoalResearchOn)
data, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
t.Fatalf("read goal state: %v", err)
}
var state goalState
if err := json.Unmarshal(data, &state); err != nil {
t.Fatalf("unmarshal goal state: %v", err)
}
if state.AutoResearchTaskID == "" {
t.Fatalf("AutoResearchTaskID was empty in persisted goal state: %+v", state)
}
for _, rel := range []string{
"state/task_spec.json",
"state/progress.json",
"state/findings.jsonl",
"logs/heartbeat.jsonl",
} {
path := filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, rel)
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected autoresearch file %s: %v", rel, err)
}
}
var spec struct {
SuccessCriteria []struct {
ID string `json:"id"`
Required bool `json:"required"`
} `json:"success_criteria"`
}
readJSONFileForTest(t, filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, "state", "task_spec.json"), &spec)
if len(spec.SuccessCriteria) != 2 || spec.SuccessCriteria[0].ID != "objective_evidence" || spec.SuccessCriteria[1].ID != "verification" {
t.Fatalf("default success criteria = %+v, want objective_evidence and verification", spec.SuccessCriteria)
}
for _, criterion := range spec.SuccessCriteria {
if !criterion.Required {
t.Fatalf("default criterion %+v was not required", criterion)
}
}
composed := c.Compose("continue")
if !strings.Contains(composed, "<autoresearch-runtime>") || !strings.Contains(composed, "task_id: "+state.AutoResearchTaskID) || !strings.Contains(composed, "objective_evidence") {
t.Fatalf("Compose missing runtime summary for task %q:\n%s", state.AutoResearchTaskID, composed)
}
}
func TestResearchGoalCreatedEmitsLifecycleNotice(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
events := make(chan event.Event, 4)
c := New(Options{
WorkspaceRoot: root,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
events <- e
}
}),
})
c.SetGoalWithResearchMode("investigate lifecycle notice", GoalResearchOn)
select {
case e := <-events:
if !strings.Contains(e.Text, "autoresearch task created") {
t.Fatalf("notice = %q, want autoresearch task created", e.Text)
}
default:
t.Fatal("expected autoresearch lifecycle notice")
}
}
func TestResearchGoalRepeatedSetReusesAutoResearchTask(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
events := make(chan event.Event, 8)
c := New(Options{
WorkspaceRoot: root,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
events <- e
}
}),
})
goal := "请持续研究当前项目的 AutoResearch 状态栏展示链路,验证任务创建、状态刷新、右侧 Context 面板展示、状态栏 chip 展示是否一致。不要只看表面现象,需要找到根因、记录 evidence,并在完成前确认所有验证步骤通过。不要修改文件"
c.SetGoalWithResearchMode(goal, GoalResearchOn)
_, _, _, firstTaskID := c.goals.snapshot()
if firstTaskID == "" {
t.Fatal("first AutoResearch task id was empty")
}
c.SetGoalWithResearchMode(goal, GoalResearchOn)
c.SetGoal(goal)
_, _, _, repeatedTaskID := c.goals.snapshot()
if repeatedTaskID != firstTaskID {
t.Fatalf("repeated SetGoal created a new task: got %q, want %q", repeatedTaskID, firstTaskID)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch"))
if err != nil {
t.Fatalf("read autoresearch dir: %v", err)
}
if len(entries) != 1 {
t.Fatalf("autoresearch task count = %d, want 1", len(entries))
}
createdNotices := 0
for {
select {
case e := <-events:
if strings.Contains(e.Text, "autoresearch task created") {
createdNotices++
}
default:
if createdNotices != 1 {
t.Fatalf("created notices = %d, want 1", createdNotices)
}
return
}
}
}
func TestResearchGoalMissingExplicitTaskBlocksInsteadOfCreatingNewTask(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
var notices []string
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
}),
})
c.SetGoalWithResearchMode("resume .reasonix/autoresearch/missing-task/", GoalResearchOn)
if got := c.GoalStatus(); got != GoalStatusBlocked {
t.Fatalf("GoalStatus() = %q, want blocked for missing explicit AutoResearch task", got)
}
if got := c.goals.currentAutoResearchTaskID(); got != "" {
t.Fatalf("current AutoResearch task id = %q, want none for missing explicit task", got)
}
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch"))
if err != nil && !os.IsNotExist(err) {
t.Fatalf("ReadDir autoresearch root: %v", err)
}
if len(entries) != 0 {
t.Fatalf("created tasks for missing explicit resume: %+v", entries)
}
if !containsNotice(notices, "autoresearch resume failed") || !containsNotice(notices, "missing-task") {
t.Fatalf("notices = %+v, want explicit resume failure", notices)
}
}
func TestResearchGoalTurnAppendsAutoResearchHeartbeats(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Finished.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 4)
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.Submit("/goal --research fix the typo and add a test")
waitForTurnDone(t, events)
data, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
t.Fatalf("read goal state: %v", err)
}
var state goalState
if err := json.Unmarshal(data, &state); err != nil {
t.Fatalf("unmarshal goal state: %v", err)
}
heartbeats, err := c.autoResearch.Heartbeats(state.AutoResearchTaskID, 10)
if err != nil {
t.Fatalf("Heartbeats: %v", err)
}
if len(heartbeats) < 2 {
t.Fatalf("heartbeats = %+v, want at least starting and done", heartbeats)
}
if heartbeats[0].Status != "starting_turn" || heartbeats[len(heartbeats)-1].Status != "turn_done" {
t.Fatalf("heartbeats = %+v, want starting_turn then turn_done", heartbeats)
}
}
func TestResearchGoalTurnUpdatesAutoResearchStaleProgress(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Still investigating.\n\n[goal:continue]"),
textTurn("No new evidence without external input.\n\n[goal:blocked:needs a repro trace]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 8)
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.Submit("/goal --research investigate stale progress")
waitForTurnDone(t, events)
data, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
t.Fatalf("read goal state: %v", err)
}
var state goalState
if err := json.Unmarshal(data, &state); err != nil {
t.Fatalf("unmarshal goal state: %v", err)
}
summary, err := c.autoResearch.Summary(state.AutoResearchTaskID)
if err != nil {
t.Fatalf("Summary: %v", err)
}
if summary.Iteration < 2 || summary.StaleCount != summary.Iteration || !summary.PivotRequired || summary.PivotCount != 1 {
t.Fatalf("summary = %+v, want stale progress for every no-evidence turn and pivot required", summary)
}
}
func TestResearchGoalCompletionIsInterceptedWhenReadinessFails(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("I am done.\n\n[goal:complete]"),
textTurn("Still need evidence.\n\n[goal:blocked:missing evidence]"),
textTurn("Still need evidence.\n\n[goal:blocked:missing evidence]"),
textTurn("Still need evidence.\n\n[goal:blocked:missing evidence]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
events := make(chan event.Event, 8)
var notices []string
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
if e.Kind == event.TurnDone || e.Kind == event.Notice {
events <- e
}
}),
})
c.SetGoalWithResearchMode("identify the root cause", GoalResearchOn)
if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil {
t.Fatalf("runGoalLoopWithRawDisplay: %v", err)
}
if got := c.GoalStatus(); got == GoalStatusComplete {
t.Fatalf("GoalStatus() = complete, want readiness intercept to keep goal running")
}
if prov.call != 4 {
t.Fatalf("provider calls = %d, want initial + readiness intercept + blocked audit", prov.call)
}
if !sessionContainsUserText(ag.Session().Messages, "AutoResearch readiness check failed", "objective_evidence", "verification") {
t.Fatalf("transcript missing readiness intercept; last user:\n%s", lastUserMessage(ag.Session().Messages))
}
if !containsNotice(notices, "autoresearch readiness blocked completion") {
t.Fatalf("notices = %+v, want autoresearch readiness blocked completion", notices)
}
}
func TestControllerRecordsAutoResearchEvidence(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
c := New(Options{WorkspaceRoot: root})
c.SetGoalWithResearchMode("verify the fix", GoalResearchOn)
taskID := c.goals.currentAutoResearchTaskID()
if taskID == "" {
t.Fatal("expected autoresearch task id")
}
err := c.RecordAutoResearchEvidence("objective_evidence", AutoResearchEvidenceInput{
ID: "f-objective",
Kind: "file",
Summary: "implementation inspected",
Source: "file",
Paths: []string{"internal/control/controller.go"},
Accepted: true,
})
if err != nil {
t.Fatalf("RecordAutoResearchEvidence objective_evidence: %v", err)
}
err = c.RecordAutoResearchEvidence("verification", AutoResearchEvidenceInput{
ID: "f-verification",
Kind: "test",
Summary: "go test passed",
Source: "command",
Command: "go test ./internal/control",
Accepted: true,
})
if err != nil {
t.Fatalf("RecordAutoResearchEvidence verification: %v", err)
}
report, err := c.autoResearch.Readiness(taskID)
if err != nil {
t.Fatalf("Readiness: %v", err)
}
if !report.Ready {
t.Fatalf("readiness = %+v, want ready", report)
}
}
func TestAutoResearchEvidenceDoesNotChangeDefaultToolSurface(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
reg := tool.NewRegistry()
New(Options{WorkspaceRoot: root, Registry: reg})
if _, ok := reg.Get("autoresearch_record_evidence"); ok {
t.Fatalf("autoresearch_record_evidence should not be registered in the default provider-visible tool surface; tools=%v", reg.Names())
}
for _, schema := range reg.Schemas() {
if schema.Name == "autoresearch_record_evidence" {
t.Fatalf("autoresearch_record_evidence should not appear in provider schemas: %+v", reg.Schemas())
}
}
}
func TestResearchGoalCompletionMarksAutoResearchTaskComplete(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn(`Done.
<autoresearch-evidence>
{"criterion_id":"objective_evidence","id":"f-objective","kind":"file","summary":"The implementation state was inspected directly.","source":"file","paths":["internal/control/controller.go"],"accepted":true}
</autoresearch-evidence>
<autoresearch-evidence>
{"criterion_id":"verification","id":"f-verification","kind":"test","summary":"The focused AutoResearch tests passed.","source":"command","command":"go test ./internal/control","accepted":true}
</autoresearch-evidence>
[goal:complete]`),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
var notices []string
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
}),
})
c.SetGoalWithResearchMode("verify completion lifecycle", GoalResearchOn)
taskID := c.goals.currentAutoResearchTaskID()
if taskID == "" {
t.Fatal("expected autoresearch task id")
}
if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil {
t.Fatalf("runGoalLoopWithRawDisplay: %v", err)
}
summary, err := c.autoResearch.Summary(taskID)
if err != nil {
t.Fatalf("Summary: %v", err)
}
if summary.Status != "complete" {
t.Fatalf("AutoResearch status = %q, want complete", summary.Status)
}
if summary.StaleCount != 0 {
t.Fatalf("AutoResearch stale_count = %d, want 0 after accepted evidence", summary.StaleCount)
}
findings, err := c.autoResearch.Findings(taskID, 0)
if err != nil {
t.Fatalf("Findings: %v", err)
}
if len(findings) != 2 {
t.Fatalf("findings = %+v, want two assistant evidence records", findings)
}
if !containsNotice(notices, "autoresearch task completed") {
t.Fatalf("notices = %+v, want autoresearch task completed", notices)
}
}
func TestResearchGoalBlockedMarksAutoResearchTaskBlocked(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Blocked.\n\n[goal:blocked:needs credentials]"),
textTurn("Still blocked.\n\n[goal:blocked:needs credentials]"),
textTurn("Still blocked.\n\n[goal:blocked:needs credentials]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
var notices []string
c := New(Options{
WorkspaceRoot: root,
SessionPath: sessionPath,
Runner: ag,
Executor: ag,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
}),
})
c.SetGoalWithResearchMode("verify blocked lifecycle", GoalResearchOn)
taskID := c.goals.currentAutoResearchTaskID()
if taskID == "" {
t.Fatal("expected autoresearch task id")
}
if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil {
t.Fatalf("runGoalLoopWithRawDisplay: %v", err)
}
summary, err := c.autoResearch.Summary(taskID)
if err != nil {
t.Fatalf("Summary: %v", err)
}
if summary.Status != "blocked" || !strings.Contains(summary.Blocker, "needs credentials") {
t.Fatalf("AutoResearch summary = %+v, want blocked with reason", summary)
}
if !containsNotice(notices, "autoresearch task blocked") {
t.Fatalf("notices = %+v, want autoresearch task blocked", notices)
}
}
func TestPlainInputAutoStartDoesNotMutateGoalWhenTurnRunning(t *testing.T) {
c := New(Options{})
c.mu.Lock()
@@ -557,3 +1041,42 @@ func TestStrictGoalBlocksRepeatedComplete(t *testing.T) {
t.Fatal("strict mode should not allow goal completion with incomplete todos")
}
}
func readJSONFileForTest(t *testing.T, path string, out any) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
if err := json.Unmarshal(data, out); err != nil {
t.Fatalf("Unmarshal(%s): %v", path, err)
}
}
func sessionContainsUserText(messages []provider.Message, needles ...string) bool {
for _, msg := range messages {
if msg.Role != provider.RoleUser {
continue
}
ok := true
for _, needle := range needles {
if !strings.Contains(msg.Content, needle) {
ok = false
break
}
}
if ok {
return true
}
}
return false
}
func containsNotice(notices []string, needle string) bool {
for _, notice := range notices {
if strings.Contains(notice, needle) {
return true
}
}
return false
}
+62 -7
View File
@@ -2,6 +2,7 @@ package control
import (
"context"
"strconv"
"strings"
"unicode"
@@ -149,10 +150,14 @@ func (c *Controller) Compose(text string) string {
reasoningLanguage := c.reasoningLanguage
c.mu.Unlock()
notes := c.memory.drainPending()
goal, goalStatus, goalResearchMode := c.goals.snapshot()
goal, goalStatus, goalResearchMode, autoResearchTaskID := c.goals.snapshot()
if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning {
text = activeGoalBlock(goal, goalResearchMode) + "\n\n" + text
prefix := activeGoalBlock(goal, goalResearchMode)
if runtime := c.autoResearchRuntimeBlock(autoResearchTaskID); runtime != "" {
prefix += "\n\n" + runtime
}
text = prefix + "\n\n" + text
}
if plan {
text = PlanModeMarker + "\n\n" + text
@@ -185,6 +190,53 @@ func (c *Controller) Compose(text string) string {
return text
}
func (c *Controller) autoResearchRuntimeBlock(taskID string) string {
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return ""
}
summary, err := c.autoResearch.Summary(taskID)
if err != nil {
return "<autoresearch-runtime>\nstatus: invalid\nerror: " + strings.ReplaceAll(err.Error(), autoResearchRuntimeClose, "<\\/autoresearch-runtime>") + "\n</autoresearch-runtime>"
}
var b strings.Builder
b.WriteString("<autoresearch-runtime>\n")
b.WriteString("task_id: " + summary.TaskID + "\n")
b.WriteString("status: " + summary.Status + "\n")
b.WriteString("iteration: ")
b.WriteString(strconv.Itoa(summary.Iteration))
b.WriteString("\n")
b.WriteString("current_direction: " + summary.CurrentDirection + "\n")
b.WriteString("stale_count: ")
b.WriteString(strconv.Itoa(summary.StaleCount))
b.WriteString("\n")
b.WriteString("pivot_count: ")
b.WriteString(strconv.Itoa(summary.PivotCount))
b.WriteString("\n")
if summary.PivotRequired {
b.WriteString("pivot_required: true\n")
} else {
b.WriteString("pivot_required: false\n")
}
b.WriteString("open_success_criteria: ")
b.WriteString(strconv.Itoa(len(summary.OpenCriteria)))
b.WriteString("\n")
for _, criterion := range summary.OpenCriteria {
b.WriteString("- ")
b.WriteString(criterion.ID)
b.WriteString(": ")
b.WriteString(strings.ReplaceAll(criterion.Description, "\n", " "))
b.WriteString("\n")
}
if summary.Blocker != "" {
b.WriteString("blocker: " + summary.Blocker + "\n")
}
b.WriteString("next_required_action: " + summary.NextRequiredAction + "\n")
b.WriteString("</autoresearch-runtime>")
return b.String()
}
const autoResearchRuntimeClose = "</autoresearch-runtime>"
func reasoningLanguageBlock(lang string) string {
return agent.ReasoningLanguageBlock(lang)
}
@@ -217,14 +269,17 @@ func activeGoalBlock(goal string, researchMode GoalResearchMode) string {
}
const autoResearchGoalInstructions = `AutoResearch protocol: this goal looks like long-horizon research, debugging, optimization, or implementation work. Treat AutoResearch as a durable strategy for this Goal, not as a background daemon or a global skill.
- Say briefly in the first visible reply that the goal is being handled with AutoResearch and that state will live under .reasonix/autoresearch/<task-id>/.
- Say briefly in the first visible reply that the goal is being handled with AutoResearch and that host-owned state lives under .reasonix/autoresearch/<task-id>/, using the actual task_id from <autoresearch-runtime>.
- Keep dynamic state out of REASONIX.md, AGENTS.md, project memory, system prompts, and tool schemas. Use project-local .reasonix/autoresearch/ state only.
- For a new task, create a collision-resistant task id YYYYMMDD-HHMMSS-slug, check .reasonix/autoresearch/ first, and append -2, -3, etc. only on collision. Reuse an explicitly supplied .reasonix/autoresearch/<task-id>/ path exactly.
- Maintain state/task_spec.md, state/progress.json, state/findings.jsonl, state/directions_tried.json, state/iteration_log.jsonl, and logs/heartbeat.jsonl. Record goal, scope, non-goals, allowed operations, success criteria, verification gates, iteration direction, evidence, stale_count, pivots, blockers, and completion summary.
- Before each iteration, read the existing state files as authoritative, append a heartbeat, choose a direction that differs materially from directions already tried, execute the smallest evidence-producing chunk, verify it, then persist JSON/JSONL state before reporting.
- Use the task_id and open_success_criteria in <autoresearch-runtime> as authoritative. The host creates task ids and owns state/task_spec.json, state/progress.json, state/findings.jsonl, state/directions_tried.json, state/iteration_log.jsonl, and logs/heartbeat.jsonl.
- Do not hand-edit the host-owned AutoResearch state files. When you have direct evidence for an open criterion, include an <autoresearch-evidence> block in your assistant reply so the host can persist it:
<autoresearch-evidence>
{"criterion_id":"objective_evidence","kind":"file","summary":"What was directly observed","source":"file","paths":["relative/path"],"accepted":true}
</autoresearch-evidence>
- Before each iteration, use the runtime summary as authoritative, choose a direction that differs materially from directions already tried, execute the smallest evidence-producing chunk, verify it, and report accepted evidence with <autoresearch-evidence> blocks.
- Increment stale_count when an iteration lacks accepted evidence or repeats a prior direction. At stale_count >= 2, make a structural pivot such as changing evidence source, entrypoint, implementation boundary, test oracle, benchmark, decomposition, environment, platform, or refutation angle. At stale_count >= 4, stop autonomous digging and ask for the smallest external input needed.
- Workers or subagents may gather evidence, but the orchestrator owns canonical state writes. Workers must not publish, push, delete, contact external systems, or write canonical state unless explicitly designated.
- Complete only after auditing every success criterion in task_spec.md against direct evidence. Public publishing, destructive changes, credential use, payments, external notifications, privacy-sensitive output, and cache-sensitive changes still require the normal Reasonix gates.`
- Complete only after auditing every open success criterion in <autoresearch-runtime> against direct evidence. Public publishing, destructive changes, credential use, payments, external notifications, privacy-sensitive output, and cache-sensitive changes still require the normal Reasonix gates.`
func shouldUseAutoResearch(goal string, mode GoalResearchMode) bool {
switch mode {
+8 -4
View File
@@ -263,15 +263,19 @@ func TestComposeIncludesActiveGoal(t *testing.T) {
}
func TestGoalAutoResearchTriggersForLongHorizonGoals(t *testing.T) {
c := New(Options{})
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
c := New(Options{WorkspaceRoot: root})
c.SetGoal("持续排查这个线上卡顿直到根因明确,并验证修复")
got := c.Compose("next step?")
for _, want := range []string{
"AutoResearch protocol",
".reasonix/autoresearch/<task-id>/",
"YYYYMMDD-HHMMSS-slug",
"state/task_spec.md",
"<autoresearch-runtime>",
"task_id:",
"pivot_required:",
"stale_count >= 2",
"durable strategy for this Goal",
} {
+5
View File
@@ -4,6 +4,7 @@ import (
"context"
"reasonix/internal/agent"
"reasonix/internal/autoresearch"
"reasonix/internal/billing"
"reasonix/internal/checkpoint"
"reasonix/internal/command"
@@ -93,6 +94,10 @@ type Goals interface {
SetGoalWithResearchMode(goal string, researchMode GoalResearchMode)
GoalStrict(strict bool)
ClearGoal()
AutoResearchSummary() (*autoresearch.Summary, bool)
AutoResearchList() ([]autoresearch.Summary, bool)
AutoResearchFindings(limit int) ([]autoresearch.Finding, bool)
RecordAutoResearchEvidence(criterionID string, input AutoResearchEvidenceInput) error
AutoStartResearchGoal(input string) (string, bool)
ResetPlannerSession()
PlanMode() bool
+49 -1
View File
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"reasonix/internal/agent"
"reasonix/internal/autoresearch"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/jobs"
@@ -97,10 +99,17 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
defer func() { c.hooks.Stop(context.Background(), lastAssistantText(c.History()), turn) }()
}
c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw))
autoResearchTaskID := c.goals.currentAutoResearchTaskID()
autoResearchAcceptedBefore := c.autoResearchAcceptedEvidenceIDs(autoResearchTaskID)
c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatStartingTurn, "")
err := c.runner.Run(ctx, input)
if err == nil {
c.recordAutoResearchEvidenceFromAssistant(autoResearchTaskID, lastAssistantText(c.History()))
c.recordAutoResearchTurnProgress(autoResearchTaskID, autoResearchAcceptedBefore)
c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatTurnDone, "")
c.clearInFlightTurn()
} else {
c.appendAutoResearchHeartbeat(autoResearchTaskID, autoresearch.HeartbeatWarning, err.Error())
// When the user explicitly cancels (Ctrl+C), the incomplete turn's
// assistant messages and tool results are already saved to the
// session. If they stay, the next turn's model sees leftover
@@ -195,7 +204,11 @@ func (o *turnOrchestrator) continueGoal(ctx context.Context) error {
turn := goalContinueTurn
if msg, ok := c.goals.takeIntercept(); ok {
turn = msg
c.notice("goal intercept: incomplete todos remain (override with a second [goal:complete])")
if strings.Contains(msg, "AutoResearch readiness check failed") {
c.notice("autoresearch readiness blocked completion")
} else {
c.notice("goal intercept: incomplete todos remain (override with a second [goal:complete])")
}
}
if err := o.runSyntheticTurnWithRawDisplay(ctx, turn, turn, ""); err != nil {
if ctx.Err() != nil {
@@ -212,10 +225,18 @@ func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
// snapshot the executor's todos + readiness, and check tool activity. None
// of these touch goal state, so the machine's critical section stays pure.
status, reason, _ := parseGoalStatusMarker(lastAssistantText(c.History()))
autoResearchTaskID := c.goals.currentAutoResearchTaskID()
var readiness string
if c.executor != nil {
readiness = c.executor.GoalReadinessFailure()
}
if arReadiness := c.autoResearchReadinessFailure(); arReadiness != "" {
if readiness != "" {
readiness += "\n" + arReadiness
} else {
readiness = arReadiness
}
}
res := c.goals.advance(goalAdvanceInput{
status: status,
reason: reason,
@@ -225,6 +246,7 @@ func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
})
c.persistGoalState(res.path, res.data, res.ok)
if res.notice != "" {
c.finalizeAutoResearchTask(autoResearchTaskID, res.notice)
c.notice(res.notice)
}
if res.notice == goalCompleteNotice && c.executor != nil {
@@ -233,6 +255,32 @@ func (o *turnOrchestrator) advanceGoalAfterTurn() bool {
return res.cont
}
func (c *Controller) finalizeAutoResearchTask(taskID, notice string) {
if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
return
}
switch {
case notice == goalCompleteNotice:
status := autoresearch.StatusComplete
if _, err := c.autoResearch.UpdateProgress(taskID, autoresearch.ProgressPatch{Status: &status}); err != nil {
c.notice("autoresearch task completion update failed: " + err.Error())
return
}
c.notice("autoresearch task completed: " + taskID)
case strings.HasPrefix(notice, "goal blocked: ") || notice == "goal continuation limit reached":
status := autoresearch.StatusBlocked
reason := strings.TrimPrefix(notice, "goal blocked: ")
if reason == "" {
reason = notice
}
if _, err := c.autoResearch.UpdateProgress(taskID, autoresearch.ProgressPatch{Status: &status, BlockedReason: &reason}); err != nil {
c.notice("autoresearch task blocked update failed: " + err.Error())
return
}
c.notice("autoresearch task blocked: " + taskID)
}
}
// completeRemainingGoalTodos force-completes any remaining incomplete canonical
// todos when the goal FSM transitions to completed and emits a synthetic
// todo_write event so the frontend panel reflects the final state. Handles the
+15
View File
@@ -14,10 +14,14 @@ import (
"strings"
"sync"
"time"
"reasonix/internal/proc"
)
const ProbeTimeout = 2 * time.Second
const probeWaitDelay = time.Second
const probeCacheTTL = 5 * time.Minute
const maxRenderedTools = 24
@@ -201,6 +205,7 @@ func runOne(ctx context.Context, command string, opts ProbeOptions) ProbeResult
cmdCtx, cancel := context.WithTimeout(ctx, ProbeTimeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, exe, parts[1:]...)
prepareProbeCommand(cmd)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
@@ -226,6 +231,16 @@ func runOne(ctx context.Context, command string, opts ProbeOptions) ProbeResult
return res
}
func prepareProbeCommand(cmd *exec.Cmd) {
proc.HideWindow(cmd)
proc.SetProcessGroupKill(cmd)
cmd.Cancel = func() error {
proc.KillTree(cmd)
return nil
}
cmd.WaitDelay = probeWaitDelay
}
func sortResults(results []ProbeResult) {
sort.Slice(results, func(i, j int) bool {
if results[i].Found != results[j].Found {
+15
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@@ -128,6 +129,20 @@ func TestRunProbesReportsTimeout(t *testing.T) {
}
}
func TestPrepareProbeCommandSetsCancellationBudget(t *testing.T) {
cmd := exec.Command("reasonix-test-probe")
prepareProbeCommand(cmd)
if cmd.Cancel == nil {
t.Fatal("probe command must install a cancellation hook")
}
if cmd.WaitDelay != probeWaitDelay {
t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, probeWaitDelay)
}
if runtime.GOOS == "windows" && cmd.SysProcAttr == nil {
t.Fatal("probe command must hide console windows on Windows")
}
}
func TestRunProbesCachesByFingerprint(t *testing.T) {
resetProbeCacheForTest(t, time.Unix(100, 0))
dir := t.TempDir()
@@ -0,0 +1,24 @@
//go:build windows
package environment
import (
"os/exec"
"testing"
)
const testCreateNoWindow = 0x08000000
func TestPrepareProbeCommandHidesConsoleWindowOnWindows(t *testing.T) {
cmd := exec.Command("cmd", "/c", "echo", "hi")
prepareProbeCommand(cmd)
if cmd.SysProcAttr == nil {
t.Fatal("SysProcAttr is nil")
}
if !cmd.SysProcAttr.HideWindow {
t.Fatal("HideWindow is false")
}
if cmd.SysProcAttr.CreationFlags&testCreateNoWindow == 0 {
t.Fatalf("CREATE_NO_WINDOW not set; CreationFlags=%#x", cmd.SysProcAttr.CreationFlags)
}
}
+28
View File
@@ -0,0 +1,28 @@
package event
// FanOut dispatches each event to every registered sink in order.
// A nil sink in the list is silently skipped. Use it when you want one
// event stream to reach multiple consumers — e.g. the desktop tab UI and
// a bot-channel notifier.
type FanOut struct {
sinks []Sink
}
// NewFanOut returns a FanOut that delivers every Emit call to every sink
// in the given order. A zero-length list is valid (no-op).
func NewFanOut(sinks ...Sink) *FanOut {
return &FanOut{sinks: sinks}
}
// Emit forwards e to every registered sink. Nil sinks are skipped.
func (f *FanOut) Emit(e Event) {
for _, s := range f.sinks {
if s == nil {
continue
}
s.Emit(e)
}
}
// Len returns the number of registered sinks.
func (f *FanOut) Len() int { return len(f.sinks) }
+47
View File
@@ -359,6 +359,53 @@ func (l *Ledger) LatestSuccessfulWriteIndex(paths []string) (int, bool) {
return latest, latest >= 0
}
// HasSuccessfulAnchorRefreshReadAfter reports whether read_file refreshed a
// wanted path after the given receipt index. Windowed reads and grep/ls receipts
// are deliberately not enough for same-turn anchor edits: they may have observed
// a different region than the next old_string/delete_range anchor.
func (l *Ledger) HasSuccessfulAnchorRefreshReadAfter(paths []string, after int) bool {
wanted := pathSet(normalizePaths(paths))
if l == nil || len(wanted) == 0 {
return false
}
start := after + 1
if start < 0 {
start = 0
}
l.mu.Lock()
defer l.mu.Unlock()
for i := start; i < len(l.receipts); i++ {
r := l.receipts[i]
if !r.Success || !anchorRefreshRead(r) {
continue
}
for _, p := range r.Paths {
if wanted[p] {
return true
}
}
}
return false
}
func anchorRefreshRead(r Receipt) bool {
if r.ToolName != "read_file" || !r.Read {
return false
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(r.Args, &fields); err != nil {
return false
}
if limit, ok := intField(fields, "limit"); ok && limit > 0 {
return false
}
if offset, ok := intField(fields, "offset"); ok && offset > 0 {
return false
}
return true
}
func (l *Ledger) LatestSuccessfulWriterIndex() (int, bool) {
if l == nil {
return 0, false
+25
View File
@@ -46,6 +46,31 @@ func TestLedgerMatchesFileReadAndWriteReceipts(t *testing.T) {
}
}
func TestLedgerReportsAnchorRefreshReadsAfterWrites(t *testing.T) {
ledger := NewLedger()
ledger.Record(Receipt{ToolName: "write_file", Success: true, Paths: []string{`src\a.go`}, Write: true})
writeIndex, ok := ledger.LatestSuccessfulWriteIndex([]string{`src/a.go`})
if !ok {
t.Fatal("expected latest write index")
}
if ledger.HasSuccessfulAnchorRefreshReadAfter([]string{`src/a.go`}, writeIndex) {
t.Fatal("read-after-write should be false before a read")
}
ledger.Record(Receipt{ToolName: "grep", Success: true, Paths: []string{`src/a.go`}, Read: true, Args: json.RawMessage(`{"path":"src/a.go","pattern":"func"}`)})
if ledger.HasSuccessfulAnchorRefreshReadAfter([]string{`src/a.go`}, writeIndex) {
t.Fatal("grep should not refresh anchor edit state")
}
ledger.Record(Receipt{ToolName: "read_file", Success: true, Paths: []string{`src/a.go`}, Read: true, Args: json.RawMessage(`{"path":"src/a.go","offset":100,"limit":20}`)})
if ledger.HasSuccessfulAnchorRefreshReadAfter([]string{`src/a.go`}, writeIndex) {
t.Fatal("windowed read_file should not refresh anchor edit state")
}
ledger.Record(Receipt{ToolName: "read_file", Success: true, Paths: []string{`src/a.go`}, Read: true, Args: json.RawMessage(`{"path":"src/a.go"}`)})
if !ledger.HasSuccessfulAnchorRefreshReadAfter([]string{`src/a.go`}, writeIndex) {
t.Fatal("read-after-write should be true after a successful read")
}
}
func TestLedgerReportsFinalReadinessReceiptsAfterWriter(t *testing.T) {
ledger := NewLedger()
ledger.Record(Receipt{ToolName: "bash", Success: true, Command: "go test ./..."})
+2 -1
View File
@@ -33,7 +33,7 @@ func IsModelFetchEndpointMiss(err error) bool {
// FetchModels calls the OpenAI-compatible GET /models endpoint and returns the
// available model IDs.
func FetchModels(ctx context.Context, baseURL, apiKey string) ([]string, error) {
func FetchModels(ctx context.Context, baseURL, apiKey string, headers map[string]string) ([]string, error) {
cli := &http.Client{Timeout: 10 * time.Second}
url := strings.TrimRight(baseURL, "/")
if !strings.HasSuffix(url, "/models") {
@@ -48,6 +48,7 @@ func FetchModels(ctx context.Context, baseURL, apiKey string) ([]string, error)
req.Header.Set("Authorization", "Bearer "+apiKey)
}
req.Header.Set("Accept", "application/json")
applyCustomHeaders(req.Header, headers)
resp, err := cli.Do(req)
if err != nil {
+27 -3
View File
@@ -28,7 +28,7 @@ func TestFetchModels(t *testing.T) {
}))
defer srv.Close()
models, err := FetchModels(context.Background(), srv.URL, "test-key")
models, err := FetchModels(context.Background(), srv.URL, "test-key", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -40,13 +40,37 @@ func TestFetchModels(t *testing.T) {
}
}
func TestFetchModelsSendsCustomHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HTTP-Referer") != "https://app.example" || r.Header.Get("X-Title") != "Reasonix" {
http.Error(w, `{"error":"missing headers"}`, http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]string{{"id": "model-a"}},
})
}))
defer srv.Close()
models, err := FetchModels(context.Background(), srv.URL, "key", map[string]string{
"HTTP-Referer": "https://app.example",
"X-Title": "Reasonix",
})
if err != nil {
t.Fatalf("FetchModels: %v", err)
}
if len(models) != 1 || models[0] != "model-a" {
t.Fatalf("models = %v, want [model-a]", models)
}
}
func TestFetchModelsAuthError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":{"message":"invalid key"}}`, http.StatusUnauthorized)
}))
defer srv.Close()
_, err := FetchModels(context.Background(), srv.URL, "bad-key")
_, err := FetchModels(context.Background(), srv.URL, "bad-key", nil)
if err == nil {
t.Fatal("expected error for bad key")
}
@@ -58,7 +82,7 @@ func TestFetchModelsEmptyResponse(t *testing.T) {
}))
defer srv.Close()
models, err := FetchModels(context.Background(), srv.URL, "key")
models, err := FetchModels(context.Background(), srv.URL, "key", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+38
View File
@@ -64,6 +64,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
protocol = normalizeReasoningProtocol(protocol)
chatURL, _ := cfg.Extra["chat_url"].(string)
chatURL = normalizeChatURL(cfg.BaseURL, chatURL)
headers, _ := cfg.Extra["headers"].(map[string]string)
vision, _ := cfg.Extra["vision"].(bool)
visionDetail, _ := cfg.Extra["vision_detail"].(string)
visionDetail = strings.ToLower(strings.TrimSpace(visionDetail))
@@ -119,6 +120,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
keySource: keySource,
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
chatURL: chatURL,
headers: cleanCustomHeaders(headers),
model: cfg.Model,
deepseek: deepseek,
minimax: minimax,
@@ -147,6 +149,7 @@ type client struct {
keySource string // source of keyEnv, surfaced in auth errors
baseURL string
chatURL string
headers map[string]string
model string
http *http.Client
deepseek bool
@@ -186,6 +189,40 @@ func normalizeChatURL(baseURL, chatURL string) string {
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/chat/completions"
}
func cleanCustomHeaders(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for rawName, rawValue := range in {
name := strings.TrimSpace(rawName)
value := strings.TrimSpace(rawValue)
if name == "" || value == "" || reservedCustomHeader(name) {
continue
}
out[name] = value
}
if len(out) == 0 {
return nil
}
return out
}
func applyCustomHeaders(h http.Header, headers map[string]string) {
for name, value := range cleanCustomHeaders(headers) {
h.Set(name, value)
}
}
func reservedCustomHeader(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "authorization", "content-type", "accept", "host":
return true
default:
return false
}
}
// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn
// allocates a buffer, marshals the request, and sends it — pooling avoids the
// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is
@@ -215,6 +252,7 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
}
httpReq.Header.Set("Accept", "text/event-stream")
applyCustomHeaders(httpReq.Header, c.headers)
return httpReq, nil
}
resp, err := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq)
+48
View File
@@ -177,6 +177,54 @@ func TestStreamUsesConfiguredChatURL(t *testing.T) {
}
}
func TestStreamSendsCustomHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer real-key" {
http.Error(w, "authorization was not preserved", http.StatusUnauthorized)
return
}
if r.Header.Get("HTTP-Referer") != "https://app.example" || r.Header.Get("X-Title") != "Reasonix" {
http.Error(w, "custom headers missing", http.StatusForbidden)
return
}
if r.Header.Get("Accept") != "text/event-stream" {
http.Error(w, "reserved Accept header was overwritten", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
}))
defer srv.Close()
p, err := New(provider.Config{
Name: "custom",
BaseURL: srv.URL,
Model: "model-a",
APIKey: "real-key",
Extra: map[string]any{"headers": map[string]string{
"Authorization": "Bearer wrong",
"Accept": "application/json",
"HTTP-Referer": "https://app.example",
"X-Title": "Reasonix",
}},
})
if err != nil {
t.Fatalf("New: %v", err)
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
for chunk := range ch {
if chunk.Type == provider.ChunkError {
t.Fatalf("stream error: %v", chunk.Err)
}
}
}
// TestBuildRequestAlwaysSerializesContent guards the DeepSeek 400 regression:
// DeepSeek rejects a message missing the `content` field, so every message must
// serialize one. A pure tool_calls assistant turn carries null (OpenAI-spec,
+53 -4
View File
@@ -167,7 +167,7 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error)
cmd.WaitDelay = bashWaitDelay
cmd.Stdout = out
cmd.Stderr = out
tracked, runErr := runShellProcess(cmd, shouldTrackShellProcess(sh, p.Command, p.PreserveBackgroundProcesses))
tracked, runErr := runShellProcess(jobCtx, cmd, shouldTrackShellProcess(sh, p.Command, p.PreserveBackgroundProcesses))
if shouldReapAfterRun(jobCtx, sh, p.Command, p.PreserveBackgroundProcesses) {
reapShellProcess(cmd, tracked) // reap process-group stragglers the job left running (#3702)
}
@@ -195,7 +195,7 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error)
}
cmd.Stdout = w
cmd.Stderr = w
tracked, err := runShellProcess(cmd, shouldTrackShellProcess(sh, p.Command, p.PreserveBackgroundProcesses))
tracked, err := runShellProcess(runCtx, cmd, shouldTrackShellProcess(sh, p.Command, p.PreserveBackgroundProcesses))
// A foreground command that spawned a lingering child (e.g. `bazel run`'s
// server) leaves it in the process group; Wait only reaped the shell leader.
// Kill the group so those don't accumulate into an OOM (#3702). On cancel/
@@ -267,7 +267,7 @@ func shouldTrackShellProcess(sh sandbox.Shell, command string, preserveBackgroun
return sh.Kind != sandbox.ShellBash || !hasExplicitBackgroundKeepalive(command)
}
func runShellProcess(cmd *exec.Cmd, track bool) (*trackedShellProcess, error) {
func runShellProcess(ctx context.Context, cmd *exec.Cmd, track bool) (*trackedShellProcess, error) {
if !track {
setKillTree(cmd)
return nil, cmd.Run()
@@ -283,7 +283,56 @@ func runShellProcess(cmd *exec.Cmd, track bool) (*trackedShellProcess, error) {
return tracked, err
}
tracked.setJob(job)
return tracked, cmd.Wait()
return tracked, waitForTrackedShellProcess(ctx, tracked, cmd.Wait, bashWaitDelay+time.Second)
}
func waitForTrackedShellProcess(ctx context.Context, tracked *trackedShellProcess, wait func() error, grace time.Duration) error {
waitCh := make(chan error, 1)
go func() { waitCh <- wait() }()
select {
case err := <-waitCh:
return err
case <-ctx.Done():
}
tracked.kill()
// If the shell's Wait path is wedged on a held pipe or a platform-specific
// process-tree edge, do not keep the foreground turn hostage after Stop.
select {
case err := <-waitCh:
return canceledShellWaitError{cause: context.Cause(ctx), waitErr: err}
case <-time.After(grace):
return context.Cause(ctx)
}
}
type canceledShellWaitError struct {
cause error
waitErr error
}
func (e canceledShellWaitError) Error() string {
if e.cause != nil {
return e.cause.Error()
}
if e.waitErr != nil {
return e.waitErr.Error()
}
return "shell wait canceled"
}
func (e canceledShellWaitError) Unwrap() []error {
if e.cause != nil && e.waitErr != nil {
return []error{e.cause, e.waitErr}
}
if e.cause != nil {
return []error{e.cause}
}
if e.waitErr != nil {
return []error{e.waitErr}
}
return nil
}
func reapShellProcess(cmd *exec.Cmd, tracked *trackedShellProcess) {
@@ -76,6 +76,87 @@ func TestNormalizeBashRunErrorAllowsPreservedWaitDelay(t *testing.T) {
}
}
func TestWaitForTrackedShellProcessReturnsWhenWaitStallsAfterCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
tracked := &trackedShellProcess{}
releaseWait := make(chan struct{})
defer close(releaseWait)
start := time.Now()
err := waitForTrackedShellProcess(ctx, tracked, func() error {
<-releaseWait
return nil
}, 20*time.Millisecond)
elapsed := time.Since(start)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
if elapsed > time.Second {
t.Fatalf("cancelled stalled wait returned too slowly: %v", elapsed)
}
if !tracked.killed {
t.Fatal("tracked process was not marked killed")
}
}
func TestWaitForTrackedShellProcessKeepsWaitErrorAfterCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
tracked := &trackedShellProcess{}
waitStarted := make(chan struct{})
releaseWait := make(chan struct{})
errWait := errors.New("wait failed after cancel")
done := make(chan error, 1)
go func() {
done <- waitForTrackedShellProcess(ctx, tracked, func() error {
close(waitStarted)
<-releaseWait
return errWait
}, time.Second)
}()
<-waitStarted
cancel()
waitUntilTrackedShellKilled(t, tracked)
close(releaseWait)
select {
case err := <-done:
if err.Error() != context.Canceled.Error() {
t.Fatalf("error text = %q, want %q", err.Error(), context.Canceled.Error())
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
if !errors.Is(err, errWait) {
t.Fatalf("error = %v, want wrapped wait error %v", err, errWait)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for cancelled shell wait")
}
}
func waitUntilTrackedShellKilled(t *testing.T, tracked *trackedShellProcess) {
t.Helper()
deadline := time.After(time.Second)
for {
tracked.mu.Lock()
killed := tracked.killed
tracked.mu.Unlock()
if killed {
return
}
select {
case <-deadline:
t.Fatal("timed out waiting for tracked shell kill")
default:
time.Sleep(time.Millisecond)
}
}
}
func longSleepCommand(sh sandbox.Shell) string {
if sh.Kind == sandbox.ShellPowerShell {
return "Start-Sleep -Seconds 2"
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff. Show more