mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
484bca854f | ||
|
|
9ff0b20589 | ||
|
|
a2eaf80601 | ||
|
|
cf2a8b061b | ||
|
|
fcd6180976 | ||
|
|
6cbffaa78b | ||
|
|
9ee254b2ec | ||
|
|
cae565c158 | ||
|
|
3255803636 | ||
|
|
c8ac697010 | ||
|
|
8d8bccd334 | ||
|
|
3b0b9c0546 | ||
|
|
3608146829 | ||
|
|
3b3c9dba57 | ||
|
|
e1c2f46e8a | ||
|
|
798c7ce85a | ||
|
|
2ade3cd50d | ||
|
|
da348d552e | ||
|
|
96431bc31f | ||
|
|
2b56585f8f | ||
|
|
4ea38a7a22 | ||
|
|
5b8a9c0cd5 | ||
|
|
1f4d811ac3 | ||
|
|
7cab46ac3a | ||
|
|
0a38be6852 | ||
|
|
0ae11ebdef | ||
|
|
fdb99115d8 | ||
|
|
2bcac618d7 | ||
|
|
25184026bc | ||
|
|
3a1d04ae23 | ||
|
|
e73221c8b7 | ||
|
|
bd9923d644 | ||
|
|
6892b5a035 | ||
|
|
885ad5627f | ||
|
|
e702eb082e | ||
|
|
ebd73457da | ||
|
|
3f037fa745 | ||
|
|
f83b84b896 | ||
|
|
be59e66976 | ||
|
|
d924d998a0 | ||
|
|
8b04b164dc | ||
|
|
118fe34378 | ||
|
|
170771fc3a | ||
|
|
06fc964bc9 | ||
|
|
6643299111 | ||
|
|
72ce3d8d93 | ||
|
|
0aad3bd186 | ||
|
|
1858974dc7 | ||
|
|
ac2f6695c5 | ||
|
|
69d0185ff6 | ||
|
|
4d8caaa944 | ||
|
|
5e40ed682b | ||
|
|
1284d1c6c1 | ||
|
|
3093f03585 | ||
|
|
f39f06896f | ||
|
|
cb1a0bfd18 | ||
|
|
c757d24483 | ||
|
|
c38b0801cc | ||
|
|
8d42210d7c | ||
|
|
191772931f | ||
|
|
56a53237f4 | ||
|
|
c045230307 | ||
|
|
946255ce89 | ||
|
|
397605ab13 | ||
|
|
a753893797 | ||
|
|
23bf5dd464 | ||
|
|
c600b12693 | ||
|
|
d2cf3addc7 | ||
|
|
f1951c5d1f | ||
|
|
82a9d6829e | ||
|
|
7981c96d70 | ||
|
|
ac1b2ad45d | ||
|
|
f00bb1ea85 | ||
|
|
68b7ea6c07 | ||
|
|
d239e346f6 | ||
|
|
5527e8e805 | ||
|
|
cf7ee9e400 | ||
|
|
c580472167 | ||
|
|
462fd5e281 | ||
|
|
8ddad32b4b | ||
|
|
2bc0d2f010 | ||
|
|
af13bc76be | ||
|
|
4d767f3db4 |
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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 $1$ 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("> 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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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": "已傳送,謝謝!",
|
||||
|
||||
@@ -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}秒",
|
||||
|
||||
|
||||
+1250
-62
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
@@ -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("")
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+248
-5
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
} {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
|
||||
@@ -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 ./..."})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in new issue
Block a user