mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d68fb1a93 | ||
|
|
5ea5593f40 | ||
|
|
bdeeb644c4 | ||
|
|
6c9671aa57 | ||
|
|
030a4d5363 | ||
|
|
9087238c7a | ||
|
|
704b15f4d2 | ||
|
|
38ec4093fe | ||
|
|
079293f0d1 | ||
|
|
4c5e5a9572 | ||
|
|
a22eba4a86 | ||
|
|
99c1539142 | ||
|
|
4524016932 | ||
|
|
10e637bce6 | ||
|
|
0af75a1939 | ||
|
|
344895e496 | ||
|
|
0c2efcd5ae | ||
|
|
3cb37b5c9c | ||
|
|
eb9e3e8e0f | ||
|
|
d0cc98d9d3 | ||
|
|
c3ef4bfc92 | ||
|
|
7cca6e9770 | ||
|
|
431dddb1ef | ||
|
|
8178abc0bf | ||
|
|
009a2359cb | ||
|
|
972f052277 | ||
|
|
f501f3dba0 | ||
|
|
80cf5274bf | ||
|
|
4317a6176b | ||
|
|
3716462392 | ||
|
|
2f4a6995f1 | ||
|
|
1401a07a49 | ||
|
|
d39a7694d3 | ||
|
|
fb1840f22d | ||
|
|
8652137e92 | ||
|
|
75c858e543 | ||
|
|
9c058ef04f | ||
|
|
a665c88192 | ||
|
|
fa646b75c9 | ||
|
|
2d489f5c5f | ||
|
|
4098c4d98f | ||
|
|
07c65c2222 | ||
|
|
8357312237 | ||
|
|
0403d1d1a4 | ||
|
|
824f232478 | ||
|
|
14801d3118 | ||
|
|
64253776a8 | ||
|
|
2a3543adca | ||
|
|
cd9023ecf8 | ||
|
|
78133b1a26 | ||
|
|
8b0492525a | ||
|
|
43b147e4f2 | ||
|
|
30ecaf4354 | ||
|
|
9dce3d05fc | ||
|
|
8be5af9f6f | ||
|
|
c9ea166b04 | ||
|
|
5992b2ef25 | ||
|
|
bc556ce363 | ||
|
|
b98d4277c5 | ||
|
|
c009c1bfdf | ||
|
|
7dd84c5d25 | ||
|
|
7ac91a41ec | ||
|
|
53bd6bf901 | ||
|
|
d743a99ae8 | ||
|
|
3cfc430341 | ||
|
|
b07876d2c2 | ||
|
|
4b764dae33 | ||
|
|
741f8e58d5 | ||
|
|
54f11756af | ||
|
|
c0d0235274 | ||
|
|
eb8f4b82ff | ||
|
|
7c437b2d9a | ||
|
|
17074c1ede | ||
|
|
089a3144fb | ||
|
|
b2dae608ad | ||
|
|
65cded04a9 | ||
|
|
2fbce2834a | ||
|
|
9535e411e3 | ||
|
|
7eeb5c2125 | ||
|
|
725e10e560 | ||
|
|
c0100dd509 | ||
|
|
6a68799e18 | ||
|
|
60735b4f02 | ||
|
|
c070dc7432 | ||
|
|
3b677cf383 | ||
|
|
27962e7d95 | ||
|
|
bea5daa725 | ||
|
|
6dd0b063e8 | ||
|
|
96f850438c | ||
|
|
2edf7fdae0 | ||
|
|
f8bef2ac7d | ||
|
|
c92b7b528c | ||
|
|
da6edfcc6f | ||
|
|
f2b3f40901 | ||
|
|
4b00b175e5 | ||
|
|
dfbc998926 | ||
|
|
8dd8666194 | ||
|
|
82f39271ff | ||
|
|
9cbe684a5a | ||
|
|
3a5b4d9fef | ||
|
|
1c17637267 | ||
|
|
ead3fec4fd | ||
|
|
2dcc7fd5a8 | ||
|
|
d23d347897 | ||
|
|
85833b9c07 | ||
|
|
279ec453fe | ||
|
|
7f445eafaa | ||
|
|
36c293cd80 | ||
|
|
8b6fa8c452 | ||
|
|
a7b5a0487e | ||
|
|
d2d37c47ed | ||
|
|
56667add01 | ||
|
|
74fa880ef3 | ||
|
|
7a75155823 | ||
|
|
92fa78cbc3 | ||
|
|
4facd0d6bd | ||
|
|
bc1add0b86 | ||
|
|
9d00a3716d |
No files matched your search
@@ -1,2 +1,28 @@
|
||||
.gitattributes text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.npmrc text eol=lf
|
||||
*.astro text eol=lf
|
||||
*.css text eol=lf
|
||||
*.go text eol=lf
|
||||
*.html text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.nsi text eol=lf
|
||||
*.plist text eol=lf
|
||||
*.py text eol=lf
|
||||
*.rs text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.sql text eol=lf
|
||||
*.sum text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
.githooks/** text eol=lf
|
||||
benchmarks/**/*.sh text eol=lf
|
||||
@@ -163,6 +163,53 @@ jobs:
|
||||
- name: test
|
||||
run: go test ./...
|
||||
|
||||
# Desktop project-root matching is case-insensitive only on Windows
|
||||
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
|
||||
# regression tests for that contract are named *OnWindows and can never
|
||||
# run on the ubuntu leg above.
|
||||
desktop-windows:
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- 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: desktop/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
go install github.com/wailsapp/wails/v2/cmd/[email protected]
|
||||
|
||||
# go:embed of frontend/dist needs a built frontend before the package
|
||||
# compiles, same as the ubuntu desktop leg.
|
||||
- name: Build frontend
|
||||
run: |
|
||||
wails generate module
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir frontend build
|
||||
|
||||
- name: test (Windows-only path semantics)
|
||||
timeout-minutes: 10
|
||||
run: go test . -run 'OnWindows$' -v
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -122,3 +122,52 @@ jobs:
|
||||
done
|
||||
echo "::error::public compatibility manifest unreachable at $url"
|
||||
exit 1
|
||||
|
||||
# A stable CLI release must never leave the npm line behind: v1.17.5
|
||||
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
|
||||
# (#5822) — every `npm update -g` user was silently downgraded to a
|
||||
# months-old version, and nothing noticed because the npm line
|
||||
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
|
||||
# stable npm tag was simply never pushed. release-npm.yml's own verify
|
||||
# step only guards runs that happen; this guard catches the run that
|
||||
# DIDN'T.
|
||||
#
|
||||
# Two distinct states, two responses (the runs race: both workflows wait
|
||||
# on the release environment independently, and npm dist-tags propagate
|
||||
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
|
||||
# mid-release state, not a failure):
|
||||
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
|
||||
# nobody pushed the npm release at all.
|
||||
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
|
||||
# The npm run may still be awaiting its own environment approval;
|
||||
# release-npm.yml's verify step owns asserting the dist-tag lands.
|
||||
- name: Check npm latest dist-tag freshness
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
version="${TAG#v}"
|
||||
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
|
||||
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
|
||||
if [ -n "$got" ]; then
|
||||
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
|
||||
if [ "$newest" = "$got" ]; then
|
||||
echo "npm latest -> $got (>= $version) OK"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands."
|
||||
exit 0
|
||||
+62
-14
@@ -523,6 +523,7 @@ func (a *App) tabsRestoredSignal() <-chan struct{} {
|
||||
func (a *App) showMainWindow() {
|
||||
if a.ctx != nil {
|
||||
showFromBackground(a.ctx, a.backgroundMaximised.Swap(false))
|
||||
a.kickDeferredRebuildRetry()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +640,14 @@ func (a *App) restoreOrBuildTabs() {
|
||||
tab.effort = cloneStringPtr(entry.Effort)
|
||||
tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)
|
||||
tab.mode = persistedTabMode(entry.Mode)
|
||||
tab.goal = strings.TrimSpace(entry.Goal)
|
||||
// Validate the persisted goal against the session's goal-state
|
||||
// sidecar: a typed /new or /clear rotates the session through the
|
||||
// controller without passing App.NewSession/ClearSession, so
|
||||
// entry.Goal can be stale. Session rotation writes a stopped
|
||||
// goal-state onto the fresh path; reading it here stops a restart
|
||||
// from re-seeding the cleared goal into the rotated session. A
|
||||
// session without a sidecar keeps the persisted goal (legacy).
|
||||
tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), strings.TrimSpace(entry.Goal))
|
||||
tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
|
||||
if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {
|
||||
tab.toolApprovalMode = control.ToolApprovalYolo
|
||||
@@ -983,12 +991,23 @@ func (a *App) tabReadOnly(tabID string) bool {
|
||||
|
||||
func (a *App) tabAndCtrlByID(tabID string) (*WorkspaceTab, control.SessionAPI) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
tab := a.tabByIDLocked(tabID)
|
||||
if tab == nil {
|
||||
a.mu.RUnlock()
|
||||
return nil, nil
|
||||
}
|
||||
return tab, tab.Ctrl
|
||||
ctrl := tab.Ctrl
|
||||
retryStartup := ctrl == nil && tab.StartupErrLeaseHeld
|
||||
a.mu.RUnlock()
|
||||
if retryStartup && a.tryRecoverStartupLeaseHeldTab(tab) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.tabs[tab.ID] != tab {
|
||||
return nil, nil
|
||||
}
|
||||
return tab, tab.Ctrl
|
||||
}
|
||||
return tab, ctrl
|
||||
}
|
||||
|
||||
// activeTabAndCtrl snapshots the active tab and its controller in one locked
|
||||
@@ -1185,7 +1204,7 @@ func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error {
|
||||
if current := a.tabs[tab.ID]; current == tab {
|
||||
tab.Ctrl = nil
|
||||
tab.Ready = false
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.ActivityStatus = ""
|
||||
if tab.sink == nil {
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
@@ -1513,6 +1532,10 @@ func (a *App) NewSession() error {
|
||||
// telemetry keeps the previous session's totals and the status bar 会话费用
|
||||
// silently turns into an all-sessions running total (#5850).
|
||||
tab.resetTelemetry(ctrl.SessionPath())
|
||||
// Mirror the controller: NewSession cleared the active goal, and the tab's
|
||||
// persisted copy must follow — otherwise the next rebuild/restart would
|
||||
// re-seed the old goal into the fresh session via SetGoal(tab.goal).
|
||||
a.clearTabGoal(tab)
|
||||
a.assignFreshSessionTopic(tab)
|
||||
a.persistTabSessionPath(tab, ctrl.SessionPath())
|
||||
a.invalidatePromptHistoryCache()
|
||||
@@ -1619,11 +1642,27 @@ func (a *App) ClearSession() error {
|
||||
return userFacingSessionLeaseError("", err)
|
||||
}
|
||||
tab.resetTelemetry(ctrl.SessionPath())
|
||||
// Mirror the controller: ClearSession cleared the active goal.
|
||||
a.clearTabGoal(tab)
|
||||
a.persistTabSessionPath(tab, ctrl.SessionPath())
|
||||
a.invalidatePromptHistoryCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts
|
||||
// cannot re-seed a goal the controller has already cleared on session rotation.
|
||||
func (a *App) clearTabGoal(tab *WorkspaceTab) {
|
||||
if tab == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
tab.goal = ""
|
||||
if current := a.tabs[tab.ID]; current == tab {
|
||||
a.saveTabsLocked()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {
|
||||
if tab == nil || oldCtrl == nil {
|
||||
return fmt.Errorf("workspace is still starting")
|
||||
@@ -1711,7 +1750,9 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
|
||||
newCtrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(newCtrl, snap.mode)
|
||||
applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
|
||||
newCtrl.SetGoal(snap.goal)
|
||||
// Clearing the session clears the active goal too (same contract as
|
||||
// Controller.ClearSession): the snapshot's goal belongs to the destroyed
|
||||
// conversation and must not seed the replacement.
|
||||
path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
newCtrl.Close()
|
||||
@@ -1738,7 +1779,8 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
|
||||
tab.SessionPath = path
|
||||
tab.Label = newCtrl.Label()
|
||||
tab.Ready = true
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.goal = ""
|
||||
// Supersede any in-flight startup build: the session it was resuming
|
||||
// was just destroyed, and finishing later would pass the generation
|
||||
// check and overwrite this controller.
|
||||
@@ -1750,6 +1792,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
|
||||
tab.resetTelemetry(path)
|
||||
oldCtrl.CloseAfterDestroy()
|
||||
a.emitProjectTreeChanged()
|
||||
a.notifyTabRuntimeRebuilt(tab)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2230,7 +2273,7 @@ func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool,
|
||||
WorkspaceRoot: s.WorkspaceRoot,
|
||||
TopicID: s.TopicID,
|
||||
TopicTitle: s.TopicTitle,
|
||||
Recovered: s.Recovered,
|
||||
Recovered: sessionInfoIsAutomaticRecovery(s),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2731,7 +2774,7 @@ func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
|
||||
return fmt.Errorf("workspaceRoot is required")
|
||||
}
|
||||
saveWorkspace(workspaceRoot)
|
||||
_ = addProject(workspaceRoot, "")
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
actualRoot = workspaceRoot
|
||||
} else {
|
||||
actualRoot = globalWorkspaceRoot()
|
||||
@@ -3113,7 +3156,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
|
||||
tab.SessionPath = sessionPath
|
||||
applyTabSessionProfile(tab, profile)
|
||||
tab.Ready = false
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.ActivityStatus = ""
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
a.saveTabsLocked()
|
||||
@@ -3155,7 +3198,7 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string
|
||||
tab.SessionPath = sessionPath
|
||||
applyTabSessionProfile(tab, profile)
|
||||
tab.Ready = false
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.ActivityStatus = ""
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
a.saveTabsLocked()
|
||||
@@ -3773,7 +3816,7 @@ func (a *App) ListWorkspaces() []WorkspaceMeta {
|
||||
out = append(out, WorkspaceMeta{
|
||||
Path: project.Root,
|
||||
Name: projectDisplayName(project),
|
||||
Current: activeRoot != "" && project.Root == activeRoot,
|
||||
Current: activeRoot != "" && sameProjectRoot(project.Root, activeRoot),
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -4019,6 +4062,7 @@ func (a *App) singleSurfaceLayoutEnabled() bool {
|
||||
type HistoryMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
SubmitText string `json:"submitText,omitempty"`
|
||||
CheckpointTurn *int `json:"checkpointTurn,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
@@ -4793,6 +4837,7 @@ type previewEventRecord struct {
|
||||
UpdatedAt json.RawMessage `json:"updatedAt"`
|
||||
UpdatedAtSnake json.RawMessage `json:"updated_at"`
|
||||
Text string `json:"text"`
|
||||
Detail string `json:"detail"`
|
||||
Content string `json:"content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ReasoningContent string `json:"reasoningContent"`
|
||||
@@ -4900,7 +4945,7 @@ func previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {
|
||||
if level != "warn" {
|
||||
level = "info"
|
||||
}
|
||||
out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content)})
|
||||
out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content), Detail: rec.Detail})
|
||||
case "compaction_started":
|
||||
c := rec.compactionPayload()
|
||||
out = append(out, HistoryMessage{Role: "compaction", Pending: true, Trigger: c.Trigger})
|
||||
@@ -7303,6 +7348,7 @@ func (a *App) SetModelForTab(tabID, name string) error {
|
||||
// The runtime now reflects the on-disk config; drop any deferred refresh.
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.notifyTabRuntimeRebuilt(tab)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7443,7 +7489,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
tab.model = modelRef
|
||||
tab.effort = &effort
|
||||
tab.Label = newCtrl.Label()
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = true
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
a.saveTabsLocked()
|
||||
@@ -7454,6 +7500,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.notifyTabRuntimeRebuilt(tab)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7570,7 +7617,7 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
||||
tab.model = modelRef
|
||||
tab.tokenMode = mode
|
||||
tab.Label = newCtrl.Label()
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = true
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
a.saveTabsLocked()
|
||||
@@ -7581,6 +7628,7 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
||||
// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
a.persistTabSessionPath(tab, path)
|
||||
a.notifyTabRuntimeRebuilt(tab)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+195
-4
@@ -31,6 +31,7 @@ import (
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/store"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -114,16 +115,26 @@ func isolateDesktopUserDirs(t *testing.T) string {
|
||||
func primarySessionFiles(paths []string) []string {
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
base := filepath.Base(path)
|
||||
if strings.HasSuffix(base, ".jsonl") &&
|
||||
!strings.HasSuffix(base, ".events.jsonl") &&
|
||||
!strings.HasSuffix(base, ".guardian.jsonl") {
|
||||
if store.IsSessionTranscriptName(filepath.Base(path)) {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readConflictLogLines(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read conflict log: %v", err)
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(text, "\n")
|
||||
}
|
||||
|
||||
func setDesktopTestCredential(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
if _, err := config.SetCredential(key, value); err != nil {
|
||||
@@ -1376,6 +1387,53 @@ func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMigratesLegacyStepFunPresetBaseURLs(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
cfg := config.Default()
|
||||
stepfun, ok := config.CuratedProviderPreset("stepfun")
|
||||
if !ok || len(stepfun.Entries) != 1 {
|
||||
t.Fatal("missing stepfun preset")
|
||||
}
|
||||
stepfunEntry := stepfun.Entries[0]
|
||||
stepfunEntry.BaseURL = "https://api.stepfun.ai/step_plan/v1"
|
||||
stepfunAnthropic, ok := config.CuratedProviderPreset("stepfun-anthropic")
|
||||
if !ok || len(stepfunAnthropic.Entries) != 1 {
|
||||
t.Fatal("missing stepfun-anthropic preset")
|
||||
}
|
||||
stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
|
||||
stepfunAnthropicEntry.BaseURL = "https://api.stepfun.ai/step_plan"
|
||||
cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
|
||||
cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
view := NewApp().Settings()
|
||||
for _, id := range []string{"stepfun", "stepfun-anthropic"} {
|
||||
presetView := providerPresetViewByID(t, view, id)
|
||||
if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
|
||||
t.Fatalf("%s preset view = %+v, want installed after migration", id, presetView)
|
||||
}
|
||||
}
|
||||
|
||||
migrated := config.LoadForEdit(config.UserConfigPath())
|
||||
stepfunEntryView, ok := migrated.Provider("stepfun")
|
||||
if !ok {
|
||||
t.Fatal("stepfun provider missing after migration")
|
||||
}
|
||||
if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.com/step_plan/v1" {
|
||||
t.Fatalf("stepfun base_url = %q, want official URL", got)
|
||||
}
|
||||
stepfunAnthropicEntryView, ok := migrated.Provider("stepfun-anthropic")
|
||||
if !ok {
|
||||
t.Fatal("stepfun-anthropic provider missing after migration")
|
||||
}
|
||||
if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.com/step_plan" {
|
||||
t.Fatalf("stepfun-anthropic base_url = %q, want official URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
preset, ok := config.CuratedProviderPreset("mimo-api")
|
||||
@@ -2079,6 +2137,7 @@ func TestDeferredRebuildRetryAppliesAfterLeaseRelease(t *testing.T) {
|
||||
sink: &tabEventSink{tabID: "tab_deferred_retry", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
installNoopRuntimeEvents(app, tab.sink)
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
@@ -2189,6 +2248,7 @@ func TestDeferredRebuildWaitsForTabToBecomeActive(t *testing.T) {
|
||||
sink: &tabEventSink{tabID: "tab_pending", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
installNoopRuntimeEvents(app, tab.sink)
|
||||
other := &WorkspaceTab{
|
||||
ID: "tab_other",
|
||||
Scope: "global",
|
||||
@@ -2344,6 +2404,137 @@ func TestSetEffortForTabLeaseHeldKeepsOldControllerAlive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old"}
|
||||
cfg.Providers = []config.ProviderEntry{{
|
||||
Name: "old",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://example.invalid/v1",
|
||||
Model: "old-model",
|
||||
APIKeyEnv: "OLD_MODEL_KEY",
|
||||
SupportedEfforts: []string{"low", "max"},
|
||||
}}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
recoveryPath := filepath.Join(dir, "effort-switch-conflict-recovery-deadbeef.jsonl")
|
||||
disk := agent.NewSession("old system prompt")
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := disk.Save(recoveryPath); err != nil {
|
||||
t.Fatalf("save recovery branch: %v", err)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
}
|
||||
meta.Recovered = true
|
||||
meta.ParentID = "effort-switch-conflict"
|
||||
meta.RecoveryReason = "snapshot conflict"
|
||||
meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
|
||||
if err := agent.SaveBranchMeta(recoveryPath, meta); err != nil {
|
||||
t.Fatalf("SaveBranchMeta: %v", err)
|
||||
}
|
||||
|
||||
stale := agent.NewSession("old system prompt")
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_depth_cap_effort",
|
||||
Scope: "global",
|
||||
SessionPath: recoveryPath,
|
||||
Ready: true,
|
||||
model: "old/old-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
oldCtrl := control.New(control.Options{
|
||||
Executor: oldExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: recoveryPath,
|
||||
Label: "old",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
tab.Ctrl = oldCtrl
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
stale.IncrementRewrite()
|
||||
|
||||
if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
|
||||
t.Fatalf("SetEffortForTab: %v", err)
|
||||
}
|
||||
if got := tab.Ctrl.SessionPath(); got != recoveryPath {
|
||||
t.Fatalf("session path after effort switch = %q, want current recovery branch %q", got, recoveryPath)
|
||||
}
|
||||
if got := tab.currentSessionPath(); got != recoveryPath {
|
||||
t.Fatalf("tab current session path = %q, want %q", got, recoveryPath)
|
||||
}
|
||||
if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
|
||||
t.Fatalf("tab lease path = %q, want %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob recovery branches: %v", err)
|
||||
}
|
||||
matches = primarySessionFiles(matches)
|
||||
if len(matches) != 1 || matches[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after effort switch = %v, want only %q", matches, recoveryPath)
|
||||
}
|
||||
|
||||
lines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("conflict log lines = %v, want one depth-cap diagnostic", lines)
|
||||
}
|
||||
if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_force_saved"`) {
|
||||
t.Fatalf("conflict diagnostic = %s, want depth-cap outcome", lines[0])
|
||||
}
|
||||
if strings.Contains(lines[0], dir) || strings.Contains(lines[0], recoveryPath) {
|
||||
t.Fatalf("conflict diagnostic leaked local path: %s", lines[0])
|
||||
}
|
||||
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot after effort switch recovery: %v", err)
|
||||
}
|
||||
afterLines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
|
||||
if len(afterLines) != len(lines) {
|
||||
t.Fatalf("follow-up snapshot appended conflict diagnostics: before=%v after=%v", lines, afterLines)
|
||||
}
|
||||
matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob recovery branches after snapshot: %v", err)
|
||||
}
|
||||
matches = primarySessionFiles(matches)
|
||||
if len(matches) != 1 || matches[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
|
||||
|
||||
+153
-6
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -10,18 +11,22 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// deferredRebuildRetryInterval is how often the retry loop probes a held
|
||||
// session lease. Package-level so tests can shorten it.
|
||||
var deferredRebuildRetryInterval = 2 * time.Second
|
||||
|
||||
const deferredStartupBuildLabel = "__startup__"
|
||||
|
||||
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
|
||||
// runtime could not refresh because the session lease was held by another
|
||||
// Reasonix process. A single background loop probes the lease and replays the
|
||||
// rebuild once the other side releases it. The loop only runs after
|
||||
// enableDeferredRebuildRetry (the wails startup hook); tests that never call
|
||||
// it get the pending bookkeeping without a background goroutine.
|
||||
// runtime could not refresh, plus tabs whose initial startup failed, because
|
||||
// the session lease was held by another Reasonix process. A single background
|
||||
// loop probes the lease and replays the rebuild once the other side releases
|
||||
// it. The loop only runs after enableDeferredRebuildRetry (the wails startup
|
||||
// hook); tests that never call it get the pending bookkeeping without a
|
||||
// background goroutine.
|
||||
type deferredRebuildState struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]string // tab ID -> setting label for notices
|
||||
@@ -75,6 +80,14 @@ func (a *App) scheduleDeferredRebuild(tabID, setting string) {
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
func (a *App) scheduleDeferredStartupBuild(tabID string) {
|
||||
a.scheduleDeferredRebuild(tabID, deferredStartupBuildLabel)
|
||||
}
|
||||
|
||||
func isDeferredStartupBuild(setting string) bool {
|
||||
return setting == deferredStartupBuildLabel
|
||||
}
|
||||
|
||||
func (a *App) clearDeferredRebuild(tabID string) {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
@@ -127,10 +140,16 @@ func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
|
||||
// deferredRebuildTickDone runs one retry pass and reports true when the loop
|
||||
// should exit because nothing is pending anymore.
|
||||
func (a *App) deferredRebuildTickDone() bool {
|
||||
return a.deferredRebuildTick(true)
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildTick(markIdle bool) bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
if d.stopped || len(d.pending) == 0 {
|
||||
d.running = false
|
||||
if markIdle {
|
||||
d.running = false
|
||||
}
|
||||
d.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
@@ -146,6 +165,15 @@ func (a *App) deferredRebuildTickDone() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) kickDeferredRebuildRetry() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
a.goSafe("deferredRebuildKick", func() {
|
||||
_ = a.deferredRebuildTick(false)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
@@ -156,6 +184,10 @@ func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if isDeferredStartupBuild(setting) {
|
||||
a.retryDeferredStartupBuild(tabID, tab)
|
||||
return
|
||||
}
|
||||
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
|
||||
// the session lease, and a concurrent manual rebuild's ensureSessionLease
|
||||
// would see that probe as "held by another runtime" and spuriously defer.
|
||||
@@ -200,6 +232,121 @@ func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
if !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
err := a.rebuildStartupTabLocked(tab)
|
||||
if err == nil {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return
|
||||
}
|
||||
a.clearDeferredRebuild(tabID)
|
||||
slog.Warn("desktop: deferred session startup failed", "tab", tabID, "err", err)
|
||||
}
|
||||
|
||||
func (a *App) tabHasRetryableStartupLeaseError(tab *WorkspaceTab) bool {
|
||||
if tab == nil {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == nil && tab.StartupErrLeaseHeld
|
||||
}
|
||||
|
||||
func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
|
||||
buildCtx, cancel := context.WithCancel(a.bootContext())
|
||||
a.mu.Lock()
|
||||
if tab == nil || a.tabs[tab.ID] != tab || tab.removed {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if tab.Ctrl != nil {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if !tab.StartupErrLeaseHeld {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
tab.buildGeneration++
|
||||
generation := tab.buildGeneration
|
||||
if tab.buildCancel != nil {
|
||||
tab.buildCancel()
|
||||
}
|
||||
tab.buildCancel = cancel
|
||||
tab.Ready = false
|
||||
clearTabStartupError(tab)
|
||||
tab.ActivityStatus = ""
|
||||
if tab.sink == nil {
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
}
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
|
||||
|
||||
a.mu.RLock()
|
||||
stillCurrent := false
|
||||
var ctrl control.SessionAPI
|
||||
startupErr := ""
|
||||
leaseHeld := false
|
||||
if tab != nil {
|
||||
stillCurrent = a.tabs[tab.ID] == tab && !tab.removed
|
||||
ctrl = tab.Ctrl
|
||||
startupErr = tab.StartupErr
|
||||
leaseHeld = tab.StartupErrLeaseHeld
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if !stillCurrent || ctrl != nil {
|
||||
return nil
|
||||
}
|
||||
if leaseHeld {
|
||||
return agent.ErrSessionLeaseHeld
|
||||
}
|
||||
if strings.TrimSpace(startupErr) != "" {
|
||||
return fmt.Errorf("session startup: %s", startupErr)
|
||||
}
|
||||
return fmt.Errorf("session startup: controller was not built")
|
||||
}
|
||||
|
||||
func (a *App) tryRecoverStartupLeaseHeldTab(tab *WorkspaceTab) bool {
|
||||
if a.ctx == nil || !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
return false
|
||||
}
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
if !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
return a.controllerForTab(tab) != nil
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return false
|
||||
}
|
||||
err := a.rebuildStartupTabLocked(tab)
|
||||
if err == nil {
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
return a.controllerForTab(tab) != nil
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
a.scheduleDeferredStartupBuild(tab.ID)
|
||||
} else {
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
|
||||
// could be acquired right now, without touching tab.sessionLease (only the
|
||||
// serialized rebuild paths may mutate that). The probe path can lag the
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
|
||||
+129
-50
@@ -38,14 +38,14 @@ import { useToast } from "./lib/toast";
|
||||
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
|
||||
import { asArray } from "./lib/array";
|
||||
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
|
||||
import { useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onEvent, onProjectTreeChanged, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
|
||||
import { localizedBackendNoticeText, useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered } from "./lib/bridge";
|
||||
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
|
||||
import { playSuccessChime } from "./lib/sound";
|
||||
import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound";
|
||||
import { Transcript } from "./components/Transcript";
|
||||
import { Composer } from "./components/Composer";
|
||||
import { TodoPanel } from "./components/TodoPanel";
|
||||
import { ApprovalModal } from "./components/ApprovalModal";
|
||||
import { ApprovalModal, approvalToolLabel } from "./components/ApprovalModal";
|
||||
import { AskCard } from "./components/AskCard";
|
||||
import { UndoRewindBanner } from "./components/UndoRewindBanner";
|
||||
import { ClearContextCard } from "./components/ClearContextCard";
|
||||
@@ -161,6 +161,83 @@ import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry
|
||||
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
|
||||
import logoWordmark from "./assets/logo-wordmark.svg";
|
||||
|
||||
function noticePreviewMockEnabled(): boolean {
|
||||
const value = browserMockScenarioParam();
|
||||
return value === "notice" || value === "notices" || value === "notice-preview";
|
||||
}
|
||||
|
||||
function noticePreviewItems(): Item[] {
|
||||
const notice = (index: number, level: "info" | "warn", text: string, detail: string): Item => ({
|
||||
kind: "notice",
|
||||
id: `notice-preview-${index}`,
|
||||
level,
|
||||
text: localizedBackendNoticeText(text),
|
||||
detail,
|
||||
});
|
||||
return [
|
||||
notice(0, "info", "Task status needs one more check; asking the assistant to finish or explain what is blocking it.", "final-answer readiness blocked: latest successful todo_write still has incomplete items: UI spinner in_progress, settings persistence pending"),
|
||||
notice(1, "info", "No visible answer was produced; asking the assistant to respond again.", "empty final answer blocked: qwen3.7-plus returned no visible answer text (finish=stop, reasoning=2314 chars); retrying"),
|
||||
notice(2, "info", "The assistant answered before taking action; asking it to use the required tools.", "executor handoff: assistant produced a proposal before running required repository commands; nudged to execute"),
|
||||
notice(3, "info", "Tool round limit reached; asking the assistant to summarize progress.", "tool budget reached after 128 tool calls; requesting a progress summary before continuing"),
|
||||
notice(4, "info", "The assistant is stuck retrying a blocked action; asking it to change approach.", "loop guard: repeated command failure matched the same stderr signature across 3 attempts"),
|
||||
notice(5, "info", "Context is getting large; preserving cache until cleanup is needed.", "context window 82% full; deferred cleanup to preserve reusable prompt cache"),
|
||||
notice(6, "info", "Context cleanup skipped for now.", "cleanup skipped: recent turn included unresolved user approval state"),
|
||||
notice(7, "info", "Automatic context cleanup paused because the context window is too small.", "configured compact threshold exceeds current model context window; auto cleanup paused for this model"),
|
||||
notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"),
|
||||
notice(9, "info", "Planning mode enabled for this multi-step task.", "auto plan detector matched implementation task with repository edits and verification steps"),
|
||||
notice(10, "info", "Plan detection requested a plan.", "plan detector confidence=0.83; task includes multiple dependent UI/backend changes"),
|
||||
notice(11, "info", "Plan detection was uncertain; using the fallback planner heuristic.", "plan detector confidence=0.48; fallback heuristic selected because files and tests are involved"),
|
||||
notice(12, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"),
|
||||
notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"),
|
||||
notice(14, "warn", "AutoResearch status update failed.", "autoresearch task completion update failed: write .reasonix/autoresearch/task-42/state/task_spec.json: permission denied"),
|
||||
notice(15, "warn", "AutoResearch task marked blocked.", "autoresearch task blocked: task-42\nreason: missing accepted verification evidence after three turns"),
|
||||
notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"),
|
||||
notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"),
|
||||
notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"),
|
||||
notice(19, "warn", "Some plan-mode tool settings were ignored.", "plan-mode tool settings ignored: unsupported tool allowlist entry \"browser.screenshot\""),
|
||||
notice(20, "warn", "Some plan-mode command settings were ignored.", "plan-mode command settings ignored: invalid read-only prefix \"npm && test\""),
|
||||
notice(21, "warn", "Config migration did not complete.", "config migration failed at providers.defaultModel: unknown provider reference \"old/deepseek\""),
|
||||
notice(22, "warn", "Selected model is missing its API key.", "selected model deepseek/deepseek-v4-pro requires DEEPSEEK_API_KEY, but no key is configured"),
|
||||
notice(23, "warn", "An MCP server failed to start.", "mcp server \"github\" failed to start: command not found: mcp-server-github"),
|
||||
notice(24, "warn", "Some MCP servers failed to start; run /mcp for details.", "mcp startup failures: github(command not found), linear(authentication expired)"),
|
||||
notice(25, "warn", "Guardian was disabled because its model was not found.", "guardian model \"glm-5-guard\" is not present in the configured provider catalog"),
|
||||
notice(26, "warn", "Guardian was disabled because it could not start.", "guardian startup failed: provider returned 401 unauthorized"),
|
||||
];
|
||||
}
|
||||
|
||||
function NoticePreviewPanel({ detailsLabel }: { detailsLabel: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
padding: "44px 24px 128px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 920, margin: "0 auto" }}>
|
||||
{noticePreviewItems().map((item) => {
|
||||
if (item.kind !== "notice") return null;
|
||||
return (
|
||||
<div key={item.id} className={`notice-line notice-line--${item.level}`} data-entrance="true">
|
||||
<span className="notice-line__icon">{item.level === "warn" ? "⚠ " : "ℹ "}</span>
|
||||
<div className="notice-line__text">
|
||||
{item.text}
|
||||
{item.detail ? (
|
||||
<details className="notice-line__details">
|
||||
<summary>{detailsLabel}</summary>
|
||||
<div>{item.detail}</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
|
||||
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
|
||||
|
||||
@@ -854,6 +931,9 @@ function sessionItemsToMarkdown(title: string, items: Item[], live?: LiveStream)
|
||||
break;
|
||||
case "notice":
|
||||
lines.push(`### ${item.level === "warn" ? "Warning" : "Notice"}`, "", item.text.trim(), "");
|
||||
if (item.detail?.trim()) {
|
||||
lines.push("Details:", "", item.detail.trim(), "");
|
||||
}
|
||||
break;
|
||||
case "compaction":
|
||||
lines.push("### Context Compaction", "");
|
||||
@@ -1010,6 +1090,7 @@ export default function App() {
|
||||
const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
|
||||
const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
|
||||
const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
|
||||
const attentionChimeEvents = useRef(new Set<string>());
|
||||
// Bump dockRefreshKey after each turn so WorkspacePanel/ContextPanel re-fetch
|
||||
// workspace changes, git history, and session metadata after AI tool writes.
|
||||
useEffect(() => {
|
||||
@@ -1017,11 +1098,31 @@ export default function App() {
|
||||
if (e.kind === "turn_done") {
|
||||
setDockRefreshKey((v) => v + 1);
|
||||
}
|
||||
if (shouldPlayAttentionChimeForEvent(e, attentionChimeEvents.current)) {
|
||||
playAttentionChime();
|
||||
}
|
||||
if (e.kind === "turn_done") {
|
||||
if (!e.err) playSuccessChime();
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
// Runtime rebuilds (model/effort/settings switch) replace the controller,
|
||||
// whose approval/ask ids restart from "1" — stale dedupe keys would mute
|
||||
// the first prompt after a rebuild. agent:ready fires when a (re)build
|
||||
// completes; clear that tab's keys (or all, for tab-less ready events).
|
||||
const unsubReady = onReady((readyTabId) => {
|
||||
clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId);
|
||||
});
|
||||
// Model/effort/token-mode switches and clear-while-running replace the
|
||||
// controller WITHOUT an agent:ready — they signal runtime:rebuilt instead
|
||||
// (a ready here would trigger a full session reload the UI already did).
|
||||
const unsubRebuilt = onRuntimeRebuilt((rebuiltTabId) => {
|
||||
clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
unsubReady();
|
||||
unsubRebuilt();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
|
||||
@@ -2266,7 +2367,6 @@ export default function App() {
|
||||
if (!rewindState) return transcriptItems;
|
||||
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];
|
||||
@@ -2593,36 +2693,10 @@ export default function App() {
|
||||
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
|
||||
[enqueueNavigation]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecovered((event) => {
|
||||
const scope = event.scope === "project" ? "project" : "global";
|
||||
const workspaceRoot = scope === "project" ? event.workspaceRoot || "" : "";
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
showToast(t("recovery.toast", { title: event.topicTitle || t("recovery.branch") }), "warn", event.topicId
|
||||
? {
|
||||
actionLabel: t("recovery.open"),
|
||||
durationMs: 9000,
|
||||
onAction: () => {
|
||||
void enqueueNavigation({
|
||||
kind: "topic",
|
||||
scope,
|
||||
workspaceRoot,
|
||||
topicId: event.topicId || "",
|
||||
sessionPath: event.recoveryPath || "",
|
||||
});
|
||||
},
|
||||
}
|
||||
: { durationMs: 9000 });
|
||||
});
|
||||
}, [enqueueNavigation, refreshTabMetas, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecoveryFailed((event) => {
|
||||
const key = event.reason === "lease_held" ? "recovery.failedLease" : "recovery.failedUnavailable";
|
||||
showToast(t(key), "error", { durationMs: 9000 });
|
||||
});
|
||||
}, [showToast, t]);
|
||||
useEffect(() => onSessionRecovered(() => {
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
}), [refreshTabMetas]);
|
||||
|
||||
const handleNewTab = useCallback(async () => {
|
||||
closeTransientOverlays();
|
||||
@@ -2747,6 +2821,19 @@ export default function App() {
|
||||
},
|
||||
[state.running, deleteSession, refreshHistoryView],
|
||||
);
|
||||
const onDeleteManySessions = useCallback(
|
||||
async (paths: string[]) => {
|
||||
if (state.running) return;
|
||||
const uniquePaths = Array.from(new Set(paths));
|
||||
for (const path of uniquePaths) {
|
||||
// Best effort per path: one locked/missing file must not abandon the
|
||||
// rest of the sweep. The final refresh reconciles whatever happened.
|
||||
await deleteSession(path).catch(() => undefined);
|
||||
}
|
||||
await refreshHistoryView();
|
||||
},
|
||||
[state.running, deleteSession, refreshHistoryView],
|
||||
);
|
||||
const onRenameSession = useCallback(
|
||||
async (path: string, title: string) => {
|
||||
if (state.running) return;
|
||||
@@ -2884,13 +2971,6 @@ export default function App() {
|
||||
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
|
||||
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
|
||||
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
|
||||
const recoveryBannerTitle = activeTab?.recovered
|
||||
? [
|
||||
activeTab.recoveryReason ? t("recovery.reason", { reason: activeTab.recoveryReason }) : "",
|
||||
activeTab.recoveryDigest ? t("recovery.digest", { digest: activeTab.recoveryDigest.slice(0, 12) }) : "",
|
||||
activeTab.recoveryParentId ? t("recovery.parent", { parent: activeTab.recoveryParentId }) : "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
const sidebarWorkbench = desktopLayoutStyle === "workbench";
|
||||
const windowsFramelessChrome = desktopPlatform === "windows";
|
||||
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
@@ -3442,13 +3522,6 @@ export default function App() {
|
||||
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
|
||||
)}
|
||||
|
||||
{activeTab?.recovered && !sidebarImDetailConnection && (
|
||||
<div className="banner banner--recovery" title={recoveryBannerTitle || undefined}>
|
||||
<span className="banner__badge">{t("recovery.branch")}</span>
|
||||
<span>{t("recovery.banner")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
|
||||
|
||||
<main className="main">
|
||||
@@ -3460,6 +3533,8 @@ export default function App() {
|
||||
onManageAllowlist={() => openBotAllowlistSettings(sidebarImDetailConnection.connectionId)}
|
||||
onOpenSession={() => void openSidebarImConnectionSession(sidebarImDetailConnection)}
|
||||
/>
|
||||
) : noticePreviewMockEnabled() ? (
|
||||
<NoticePreviewPanel detailsLabel={t("notice.details")} />
|
||||
) : (
|
||||
<Transcript
|
||||
items={displayItems}
|
||||
@@ -3535,6 +3610,7 @@ export default function App() {
|
||||
onStop={() => {
|
||||
cancel();
|
||||
}}
|
||||
toolApprovalMode={toolApprovalMode}
|
||||
/>
|
||||
)}
|
||||
{state.ask && (
|
||||
@@ -3586,6 +3662,8 @@ export default function App() {
|
||||
turnStartAt={state.turnStartAt}
|
||||
turnTokens={state.turnTokens}
|
||||
retry={state.retry}
|
||||
pendingApprovalLabel={state.approval ? approvalToolLabel(state.approval.tool, t) : null}
|
||||
pendingAsk={state.ask != null}
|
||||
transientDismissSignal={transientOverlayDismissSignal}
|
||||
sessionKey={composerSessionKey}
|
||||
fileRefRefreshKey={composerFileRefRefreshKey}
|
||||
@@ -3731,6 +3809,7 @@ export default function App() {
|
||||
onRestore={onRestoreTrashedSession}
|
||||
onPurge={onPurgeTrashedSession}
|
||||
onPurgeAll={onPurgeAllTrashedSessions}
|
||||
onDeleteMany={onDeleteManySessions}
|
||||
onClose={closeHistory}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -312,6 +312,25 @@ console.log("\napproval modal file references");
|
||||
);
|
||||
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
|
||||
|
||||
// Consequence preview row: follows the keyboard-selected action by default,
|
||||
// arrow navigation, and hover; each pill carries a native title fallback.
|
||||
const consequence = () => document.querySelector(".approval-consequence__text")?.textContent ?? null;
|
||||
eq(consequence(), "Allow this call only; the next one asks again.", "consequence row previews the selected action by default");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(consequence(), "Allow matching calls until this session ends; resets on restart.", "arrow navigation moves the consequence preview");
|
||||
|
||||
const pills = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
|
||||
eq(pills[2]?.getAttribute("title"), "Save a persistent allow rule; future sessions stop asking too.", "action pills carry a native title fallback");
|
||||
await act(async () => {
|
||||
pills[3].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(consequence(), "Reject this call; the model sees the refusal and continues.", "hover overrides the consequence preview");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -111,6 +111,29 @@ console.log("\nask card layout");
|
||||
eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf");
|
||||
ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics");
|
||||
|
||||
// Detail preview row: shows the first described option by default, follows
|
||||
// hover, and each option carries a native-title fallback for truncation.
|
||||
const detail = document.querySelector(".ask-shelf__detail") as HTMLElement | null;
|
||||
if (!detail) throw new Error("ask detail preview row did not render");
|
||||
eq(
|
||||
detail.querySelector(".ask-shelf__detail-text")?.textContent,
|
||||
ask.questions[0].options[0].description,
|
||||
"detail row previews the first described option by default",
|
||||
);
|
||||
|
||||
const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
|
||||
eq(optionButtons[1]?.getAttribute("title"), ask.questions[0].options[1].description, "options carry a native title fallback");
|
||||
|
||||
await act(async () => {
|
||||
optionButtons[1].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
document.querySelector(".ask-shelf__detail-text")?.textContent,
|
||||
ask.questions[0].options[1].description,
|
||||
"detail row follows the hovered option",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Stand-in for bundler-handled asset imports (.svg) in tsx-run tests; see the
|
||||
// resolve hook in history-recovery-copies.test.tsx.
|
||||
export default "";
|
||||
@@ -0,0 +1,85 @@
|
||||
// Run: tsx src/__tests__/bridge-breadcrumbs.test.ts
|
||||
|
||||
import { snapshotBreadcrumbs } from "../lib/breadcrumbs";
|
||||
import { app } from "../lib/bridge";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWindow() {
|
||||
if (typeof window === "undefined") {
|
||||
(globalThis as Record<string, unknown>).window = {} as Window & typeof globalThis;
|
||||
}
|
||||
}
|
||||
|
||||
function newBreadcrumbMessages(since: number, cat: string): string[] {
|
||||
return snapshotBreadcrumbs()
|
||||
.slice(since)
|
||||
.filter((crumb) => crumb.cat === cat)
|
||||
.map((crumb) => crumb.msg);
|
||||
}
|
||||
|
||||
console.log("\nbridge breadcrumbs");
|
||||
|
||||
const previousPerformance = (globalThis as { performance?: Performance }).performance;
|
||||
let now = 0;
|
||||
(globalThis as Record<string, unknown>).performance = { now: () => now };
|
||||
|
||||
ensureWindow();
|
||||
const previousGo = window.go;
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
async IsMainWindowMaximised() {
|
||||
now = 42;
|
||||
return true;
|
||||
},
|
||||
async CheckUpdate() {
|
||||
now = 125;
|
||||
throw new Error("offline");
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
};
|
||||
|
||||
const successStart = snapshotBreadcrumbs().length;
|
||||
now = 10;
|
||||
await app.IsMainWindowMaximised();
|
||||
const successBridge = newBreadcrumbMessages(successStart, "bridge");
|
||||
ok(
|
||||
successBridge.includes("window IsMainWindowMaximised") &&
|
||||
successBridge.includes("window IsMainWindowMaximised done ms=32"),
|
||||
"records bridge start and completion timing breadcrumbs",
|
||||
);
|
||||
|
||||
const errorStart = snapshotBreadcrumbs().length;
|
||||
now = 100;
|
||||
try {
|
||||
await app.CheckUpdate();
|
||||
} catch {
|
||||
// Expected: this branch verifies the breadcrumb emitted by the proxy rejection path.
|
||||
}
|
||||
const errorBridge = newBreadcrumbMessages(errorStart, "bridge");
|
||||
const bridgeErrors = newBreadcrumbMessages(errorStart, "bridge.error");
|
||||
ok(errorBridge.includes("update CheckUpdate"), "records bridge start breadcrumb before failed calls");
|
||||
ok(bridgeErrors.includes("CheckUpdate ms=25"), "records bridge failure timing breadcrumbs");
|
||||
|
||||
window.go = previousGo;
|
||||
if (previousPerformance) {
|
||||
(globalThis as Record<string, unknown>).performance = previousPerformance;
|
||||
} else {
|
||||
delete (globalThis as { performance?: Performance }).performance;
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -257,7 +257,7 @@ console.log("\ncomposer goal toggle");
|
||||
turnStartAt: Date.now(),
|
||||
});
|
||||
|
||||
const stopButton = document.querySelector(".composer-runstatus__stop") as HTMLButtonElement | null;
|
||||
const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
|
||||
if (!stopButton) throw new Error("composer stop button did not render");
|
||||
|
||||
await act(async () => {
|
||||
@@ -643,7 +643,7 @@ console.log("\ncomposer goal toggle");
|
||||
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");
|
||||
eq(textarea.placeholder, "Running — type guidance, Enter adds it 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");
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// Run: tsx src/__tests__/composer-run-strip.test.tsx
|
||||
//
|
||||
// The run state lives inside the composer card (no floating pill, no layout
|
||||
// jump), stop has a fixed home next to send, and a pending approval/ask shifts
|
||||
// the strip into a waiting state instead of a ticking "working" spinner.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const calls = { cancel: 0 };
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
onSend: () => {},
|
||||
onCancel: () => {
|
||||
calls.cancel += 1;
|
||||
return undefined;
|
||||
},
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: () => {},
|
||||
onSetToolApprovalMode: () => {},
|
||||
onToggleYoloApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, calls, rerender: paint };
|
||||
}
|
||||
|
||||
console.log("\ncomposer run strip");
|
||||
|
||||
// Idle: no strip, no stop button, plain send arrow.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root } = await renderComposer();
|
||||
|
||||
eq(document.querySelector(".composer-run-strip"), null, "idle composer renders no run strip");
|
||||
eq(document.querySelector(".composer__btn--stop"), null, "idle composer renders no stop button");
|
||||
ok(document.querySelector(".composer__btn--send") !== null, "idle composer keeps the send button");
|
||||
eq(document.querySelector(".composer-toolbar--status-only"), null, "floating status pill is gone");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Running: strip lives inside the card, ticker is aria-hidden, stop cancels.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() });
|
||||
|
||||
const strip = document.querySelector(".composer-card .composer-run-strip");
|
||||
ok(strip !== null, "running strip renders inside the composer card");
|
||||
const ticker = strip?.querySelector(".composer-run-strip__text");
|
||||
eq(ticker?.getAttribute("aria-hidden"), "true", "ticking spinner text stays out of the accessibility tree");
|
||||
const live = strip?.querySelector(".sr-only[role=\"status\"]");
|
||||
eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only");
|
||||
ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier");
|
||||
|
||||
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
|
||||
if (!stop) throw new Error("running composer stop button did not render");
|
||||
await act(async () => {
|
||||
stop.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.cancel, 1, "stop button next to send cancels the turn");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Waiting on approval: strip switches to the waiting state and stops ticking.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
|
||||
|
||||
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
|
||||
|
||||
const strip = document.querySelector(".composer-run-strip");
|
||||
ok(strip?.classList.contains("composer-run-strip--waiting") === true, "pending approval shifts the strip into waiting");
|
||||
const text = strip?.querySelector(".composer-run-strip__text");
|
||||
eq(text?.textContent, "Waiting for your approval — Run command", "waiting strip names the tool awaiting approval");
|
||||
eq(text?.getAttribute("aria-hidden"), null, "waiting text is static and stays accessible");
|
||||
eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card");
|
||||
ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier");
|
||||
|
||||
const modeButtons = [...document.querySelectorAll(".composer-modebar--approval .composer-modebar__item")] as HTMLButtonElement[];
|
||||
ok(modeButtons.length === 3 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer");
|
||||
|
||||
await rerender({ pendingApprovalLabel: null, pendingAsk: true });
|
||||
eq(
|
||||
document.querySelector(".composer-run-strip__text")?.textContent,
|
||||
"Waiting for your answer",
|
||||
"pending ask question shows the ask waiting state",
|
||||
);
|
||||
ok(
|
||||
modeButtons.every((b) => b.disabled),
|
||||
"approval bar stays disabled for non-approval reasons",
|
||||
);
|
||||
|
||||
await rerender({ pendingAsk: false, disabled: false });
|
||||
ok(
|
||||
document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true",
|
||||
"resolving the prompt returns the strip to the ticking spinner",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Cancel restores queued guidance: stop means "stop acting", never "discard
|
||||
// what I typed".
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer({
|
||||
running: true,
|
||||
turnStartAt: Date.now(),
|
||||
guidanceQueuePreviewItems: ["数到一半改用英文", "最后给出一句总结"],
|
||||
});
|
||||
|
||||
ok(document.querySelector(".composer-guidance-shelf") !== null, "queued guidance renders in the shelf");
|
||||
|
||||
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
|
||||
if (!stop) throw new Error("stop button did not render");
|
||||
await act(async () => {
|
||||
stop.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.cancel, 1, "stop cancels the running turn");
|
||||
const ta = document.querySelector("textarea") as HTMLTextAreaElement;
|
||||
eq(ta.value, "数到一半改用英文\n最后给出一句总结", "stop folds unconsumed queued guidance back into the draft");
|
||||
eq(document.querySelector(".composer-guidance-shelf"), null, "restored queue clears the shelf");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Waiting on the user pauses the ticker clock: elapsed time means model time.
|
||||
{
|
||||
const dom = installDom();
|
||||
const start = Date.now() - 30000;
|
||||
const { root, rerender } = await renderComposer({ running: true, turnStartAt: start });
|
||||
|
||||
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2400));
|
||||
});
|
||||
await rerender({ pendingApprovalLabel: null, disabled: false });
|
||||
|
||||
const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
|
||||
ok(/ 30s| 31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`);
|
||||
ok(!/ 32s| 33s/.test(ticker), "ticker does not count the ~2.4s approval wait as model time");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Resize consistency: --composer-height always carries the logical height in
|
||||
// every writer (React render, live drag, keyboard), with the run strip's
|
||||
// reservation isolated in a CSS calc — so dragging a resized composer during a
|
||||
// running turn cannot flash-shrink the card.
|
||||
{
|
||||
const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8");
|
||||
ok(
|
||||
stylesSource.includes("calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px))"),
|
||||
"resized card height combines logical height and strip reservation in CSS",
|
||||
);
|
||||
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
|
||||
|
||||
const handle = document.querySelector(".composer-resize-handle") as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
handle.focus();
|
||||
handle.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const card = document.querySelector(".composer-card") as HTMLElement;
|
||||
eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one");
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "running card reserves the strip height via its own variable");
|
||||
|
||||
// Drag while running: the live writer stays in logical-height space.
|
||||
await act(async () => {
|
||||
handle.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, clientY: 300 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(card.style.getPropertyValue("--composer-height"), "104px", "drag start does not flash-shrink the running card");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientY: 280 }));
|
||||
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientY: 280 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path");
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "strip reservation survives the drag");
|
||||
eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height");
|
||||
|
||||
await rerender({ running: false, turnStartAt: undefined });
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation");
|
||||
eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildPerformancePayload,
|
||||
formatPerformanceContext,
|
||||
globalCrashReportReason,
|
||||
installPerformancePressureMonitor,
|
||||
normalizeCrashError,
|
||||
parseReportedPerf,
|
||||
performanceLabelForReason,
|
||||
@@ -151,6 +152,42 @@ eq(shouldPromptForPerformanceLabel(false, 5 * 60_000, false), false, "respects t
|
||||
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, true), false, "never prompts while the window is hidden");
|
||||
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, false, false), false, "never prompts while unfocused");
|
||||
|
||||
{
|
||||
let interval: (() => void) | undefined;
|
||||
let now = 0;
|
||||
let promptPainted = false;
|
||||
const previousWindow = (globalThis as any).window;
|
||||
const previousDocument = (globalThis as any).document;
|
||||
const previousPerformance = (globalThis as any).performance;
|
||||
const previousPerformanceObserver = (globalThis as any).PerformanceObserver;
|
||||
(globalThis as any).performance = { now: () => now };
|
||||
(globalThis as any).window = {
|
||||
runtime: {},
|
||||
setInterval: (cb: () => void) => {
|
||||
interval = cb;
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
(globalThis as any).document = {
|
||||
visibilityState: "visible",
|
||||
hasFocus: () => true,
|
||||
addEventListener: () => {},
|
||||
getElementById: () => {
|
||||
promptPainted = true;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
(globalThis as any).PerformanceObserver = undefined;
|
||||
installPerformancePressureMonitor();
|
||||
now = 26_000;
|
||||
interval?.();
|
||||
eq(promptPainted, false, "first post-grace event-loop tick primes without reporting startup backlog");
|
||||
(globalThis as any).window = previousWindow;
|
||||
(globalThis as any).document = previousDocument;
|
||||
(globalThis as any).performance = previousPerformance;
|
||||
(globalThis as any).PerformanceObserver = previousPerformanceObserver;
|
||||
}
|
||||
|
||||
const reportedPerf = serializeReportedPerf(new Set(["performance.lag"]), "abc123");
|
||||
eq([...parseReportedPerf(reportedPerf, "abc123")], ["performance.lag"], "round-trips reported labels for the same build");
|
||||
eq([...parseReportedPerf(reportedPerf, "def456")], [], "re-surfaces reported labels on a new build");
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// Run: tsx src/__tests__/history-recovery-copies.test.tsx
|
||||
//
|
||||
// Recovery-copy bulk actions in HistoryPanel: the history view sweeps idle
|
||||
// copies into the trash (skipping current/open ones), the trash view purges
|
||||
// them, and both flows keep normal sessions untouched.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { SessionMeta } from "../lib/types";
|
||||
|
||||
// HistoryPanel transitively imports Welcome's SVG wordmark; tsx has no asset
|
||||
// loader, so redirect .svg specifiers to an empty-string module stub, the way
|
||||
// Vite would default-export a URL.
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".svg")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
return dom;
|
||||
}
|
||||
|
||||
const now = 1_750_000_000_000;
|
||||
|
||||
function session(overrides: Partial<SessionMeta> & { path: string }): SessionMeta {
|
||||
return {
|
||||
preview: "session preview",
|
||||
turns: 3,
|
||||
createdAt: now - 3_600_000,
|
||||
lastActivityAt: now,
|
||||
modTime: now,
|
||||
current: false,
|
||||
open: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPanel(props: Record<string, unknown>) {
|
||||
const { HistoryPanel } = await import("../components/HistoryPanel");
|
||||
const { LocaleProvider } = await import("../lib/i18n");
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<HistoryPanel
|
||||
running={false}
|
||||
onResume={() => {}}
|
||||
onPreview={async () => []}
|
||||
onDelete={() => {}}
|
||||
onRename={() => {}}
|
||||
onClose={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers(30);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function findButton(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.trim() === text) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
}
|
||||
|
||||
async function click(button: HTMLButtonElement) {
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await flushTimers(20);
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nhistory panel recovery-copy bulk actions");
|
||||
|
||||
// History view: the sweep button trashes idle recovery copies only.
|
||||
{
|
||||
const dom = installDom();
|
||||
const deleted: string[][] = [];
|
||||
const purged: string[][] = [];
|
||||
const root = await renderPanel({
|
||||
kind: "history",
|
||||
sessions: [
|
||||
session({ path: "/s/normal.jsonl" }),
|
||||
session({ path: "/s/idle-recovery-0123456789abcdef.jsonl", recovered: true }),
|
||||
session({ path: "/s/open-recovery-0123456789abcdef.jsonl", recovered: true, open: true }),
|
||||
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
|
||||
],
|
||||
onDeleteMany: (paths: string[]) => deleted.push(paths),
|
||||
onPurgeAll: (paths: string[]) => purged.push(paths),
|
||||
});
|
||||
|
||||
const sweep = findButton("Trash recovery copies");
|
||||
ok(Boolean(sweep), "history view shows the trash-recovery-copies button");
|
||||
if (sweep) {
|
||||
await click(sweep); // arm
|
||||
const confirm = findButton("Confirm trash copies");
|
||||
ok(Boolean(confirm), "sweep arms into a confirm step");
|
||||
if (confirm) await click(confirm);
|
||||
}
|
||||
eq(deleted, [["/s/idle-recovery-0123456789abcdef.jsonl"]], "sweep trashes only idle recovery copies");
|
||||
eq(purged, [], "history sweep never purges");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// History view: no idle copies → no sweep button.
|
||||
{
|
||||
const dom = installDom();
|
||||
const root = await renderPanel({
|
||||
kind: "history",
|
||||
sessions: [
|
||||
session({ path: "/s/normal.jsonl" }),
|
||||
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
|
||||
],
|
||||
onDeleteMany: () => {},
|
||||
});
|
||||
ok(!findButton("Trash recovery copies"), "history view hides the sweep button when every copy is live");
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Trash view: clear copies purges recovery copies, empty trash stays separate.
|
||||
{
|
||||
const dom = installDom();
|
||||
const purged: string[][] = [];
|
||||
const root = await renderPanel({
|
||||
kind: "trash",
|
||||
sessions: [
|
||||
session({ path: "/t/normal.jsonl", deletedAt: now }),
|
||||
session({ path: "/t/a-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
|
||||
session({ path: "/t/b-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
|
||||
],
|
||||
onRestore: () => {},
|
||||
onPurge: () => {},
|
||||
onPurgeAll: (paths: string[]) => purged.push(paths),
|
||||
});
|
||||
|
||||
const clear = findButton("Clear copies");
|
||||
ok(Boolean(clear), "trash view shows the clear-copies button");
|
||||
if (clear) {
|
||||
await click(clear); // arm
|
||||
const confirm = findButton("Confirm clear copies");
|
||||
ok(Boolean(confirm), "clear copies arms into a confirm step");
|
||||
if (confirm) await click(confirm);
|
||||
}
|
||||
eq(
|
||||
purged,
|
||||
[["/t/a-recovery-0123456789abcdef.jsonl", "/t/b-recovery-0123456789abcdef.jsonl"]],
|
||||
"clear copies purges every trashed recovery copy and nothing else",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,210 @@
|
||||
// Run: tsx src/__tests__/ready-event-meta-sync.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function tabMeta(ready: boolean): TabMeta {
|
||||
return {
|
||||
id: "tab-ready",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
topicId: "topic-ready",
|
||||
topicTitle: "Ready race",
|
||||
sessionPath: "/repo/sessions/ready.jsonl",
|
||||
label: "model",
|
||||
ready,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
};
|
||||
}
|
||||
|
||||
function meta(ready: boolean): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready,
|
||||
eventChannel: "agent:event",
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nready event meta sync");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
const historyGate = deferred<HistoryMessage[]>();
|
||||
let backendReady = false;
|
||||
let listTabsCalls = 0;
|
||||
let historyCalls = 0;
|
||||
let metaCalls = 0;
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => {
|
||||
listTabsCalls += 1;
|
||||
return [tabMeta(backendReady)];
|
||||
},
|
||||
MetaForTab: async () => {
|
||||
metaCalls += 1;
|
||||
return meta(backendReady);
|
||||
},
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async () => historyGate.promise,
|
||||
HistoryPageForTab: async () => {
|
||||
historyCalls += 1;
|
||||
const messages = await historyGate.promise;
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
|
||||
eq(historyCalls, 1, "startup begins one history hydration");
|
||||
eq(metaCalls, 0, "startup history remains in flight before ancillary meta");
|
||||
|
||||
backendReady = true;
|
||||
await act(async () => {
|
||||
for (const handler of readyHandlers) handler("tab-ready");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("ready metadata refreshed before history settles", () => controller?.state.meta?.ready === true);
|
||||
|
||||
eq(listTabsCalls >= 2, true, "ready event refreshes active tab metadata from ListTabs");
|
||||
eq(historyCalls, 1, "ready event joins the in-flight hydrate instead of reloading history");
|
||||
eq(metaCalls, 0, "ready event does not wait for ancillary MetaForTab before unlocking send");
|
||||
|
||||
await act(async () => {
|
||||
historyGate.resolve([{ role: "user", content: "hello" }]);
|
||||
await historyGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("history finishes", () => controller?.state.hydrating === false);
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after the ready metadata sync");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,220 @@
|
||||
// Run: tsx src/__tests__/ready-meta-reconcile.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises(25);
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function tabMeta(id: string, ready: boolean, active: boolean): TabMeta {
|
||||
return {
|
||||
id,
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
topicId: `topic-${id}`,
|
||||
topicTitle: id,
|
||||
sessionPath: `/repo/sessions/${id}.jsonl`,
|
||||
label: "model",
|
||||
ready,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active,
|
||||
cwd: "/repo",
|
||||
};
|
||||
}
|
||||
|
||||
function meta(tabId: string, ready: boolean): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready,
|
||||
eventChannel: "agent:event",
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
sessionPath: `/repo/sessions/${tabId}.jsonl`,
|
||||
gitBranch: "main",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nready meta reconcile");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
const historyGate = deferred<HistoryMessage[]>();
|
||||
let backendReady = false;
|
||||
let listTabsCalls = 0;
|
||||
let historyCalls = 0;
|
||||
let metaCalls = 0;
|
||||
let approvalModeCalls = 0;
|
||||
const metaTabIds: string[] = [];
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => {
|
||||
listTabsCalls += 1;
|
||||
return [
|
||||
tabMeta("tab-ready", backendReady, true),
|
||||
tabMeta("tab-inactive", true, false),
|
||||
];
|
||||
},
|
||||
MetaForTab: async (tabId: string) => {
|
||||
metaCalls += 1;
|
||||
metaTabIds.push(tabId);
|
||||
return meta(tabId, tabId === "tab-ready" ? backendReady : true);
|
||||
},
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async () => historyGate.promise,
|
||||
HistoryPageForTab: async (tabId: string) => {
|
||||
historyCalls += 1;
|
||||
const messages = await historyGate.promise;
|
||||
return {
|
||||
messages,
|
||||
startTurn: 0,
|
||||
endTurn: messages.filter((message) => message.role === "user").length,
|
||||
totalTurns: messages.filter((message) => message.role === "user").length,
|
||||
hasOlder: false,
|
||||
};
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetToolApprovalModeForTab: async () => {
|
||||
approvalModeCalls += 1;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
|
||||
eq(historyCalls, 1, "startup begins one active-tab history hydration");
|
||||
eq(listTabsCalls, 1, "startup fetches the active tab once");
|
||||
|
||||
backendReady = true;
|
||||
await waitFor("ready metadata is reconciled without a ready event", () => controller?.state.meta?.ready === true);
|
||||
|
||||
ok(metaCalls >= 1, "active tab metadata is polled after a missed ready event");
|
||||
ok(metaTabIds.length > 0 && metaTabIds.every((tabId) => tabId === "tab-ready"), "ready polling is limited to the active tab");
|
||||
eq(listTabsCalls, 1, "ready polling does not re-list or activate tabs");
|
||||
eq(historyCalls, 1, "ready polling does not start another history hydration");
|
||||
eq(approvalModeCalls, 0, "ready polling does not rely on approval-mode changes");
|
||||
|
||||
await act(async () => {
|
||||
historyGate.resolve([{ role: "user", content: "hello" }]);
|
||||
await historyGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("history finishes", () => controller?.state.hydrating === false);
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after ready reconciliation");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,31 @@
|
||||
// Run: tsx src/__tests__/recovery-banner-privacy.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(cond: boolean, label: string) {
|
||||
if (cond) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
|
||||
console.log("\nquiet recovery prompt privacy");
|
||||
|
||||
ok(!appSource.includes("banner--recovery"), "App does not render a persistent recovery banner");
|
||||
ok(!appSource.includes("recoveryDigest"), "App does not expose recovery digest through recovery prompts");
|
||||
ok(!appSource.includes("recoveryParentId"), "App does not expose parent session id through recovery prompts");
|
||||
ok(!appSource.includes("recoveryBannerTitle"), "App does not build a detailed recovery tooltip");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,62 @@
|
||||
// Run: tsx src/__tests__/recovery-quiet-notifications.test.ts
|
||||
//
|
||||
// Recovery copies remain protected by the backend, but the desktop UI should
|
||||
// not interrupt users with recovery lifecycle banners, toasts, or transcript
|
||||
// notices.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { en } from "../locales/en";
|
||||
import { zh } from "../locales/zh";
|
||||
import { zhTW } from "../locales/zh-TW";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(cond: boolean, label: string) {
|
||||
if (cond) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
|
||||
|
||||
console.log("\nquiet recovery notifications");
|
||||
|
||||
ok(!appSource.includes("banner--recovery"), "App does not render a recovery banner");
|
||||
ok(!appSource.includes("recovery.toast"), "App does not show a recovery-created toast");
|
||||
ok(!appSource.includes("onSessionRecoveryFailed"), "App does not show recovery failure toasts");
|
||||
ok(!appSource.includes("AcknowledgeTabRecovery"), "App does not expose recovery acknowledgement controls");
|
||||
ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery compare controls");
|
||||
ok(!appSource.includes("recovery.openOriginalFailed"), "App does not carry recovery compare failure text");
|
||||
|
||||
ok(controllerSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText))"), "raw quiet notices are skipped before localization");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text))"), "localized quiet notices are skipped before rendering");
|
||||
|
||||
const removedPromptKeys = [
|
||||
"recovery.open",
|
||||
"recovery.toast",
|
||||
"recovery.failedLease",
|
||||
"recovery.failedUnavailable",
|
||||
"recovery.banner",
|
||||
"recovery.bannerCompare",
|
||||
"recovery.bannerDismiss",
|
||||
"recovery.openOriginalFailed",
|
||||
];
|
||||
for (const [name, dict] of [["en", en], ["zh", zh], ["zh-TW", zhTW]] as const) {
|
||||
for (const key of removedPromptKeys) {
|
||||
ok(!(key in dict), `${name} locale omits ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -42,6 +42,19 @@ function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function installCanvasMock(win: Window) {
|
||||
Object.defineProperty(win.HTMLCanvasElement.prototype, "getContext", {
|
||||
configurable: true,
|
||||
value(type: string) {
|
||||
if (type !== "2d") return null;
|
||||
return {
|
||||
font: "",
|
||||
measureText: () => ({ width: 0 }),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
@@ -172,6 +185,7 @@ const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body>
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
installCanvasMock(dom.window as unknown as Window);
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Run: tsx src/__tests__/sound.test.ts
|
||||
|
||||
import { attentionChimeEventKey, clearAttentionChimeKeys, shouldPlayAttentionChimeForEvent } from "../lib/sound";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nsound notifications");
|
||||
|
||||
{
|
||||
eq(attentionChimeEventKey({ kind: "approval_request", tabId: "tab-a", approval: { id: "approval-1" } }), "approval:tab-a:approval-1", "approval request builds a tab-scoped chime key");
|
||||
eq(attentionChimeEventKey({ kind: "ask_request", tabId: "tab-a", ask: { id: "ask-1" } }), "ask:tab-a:ask-1", "ask request builds a tab-scoped chime key");
|
||||
eq(attentionChimeEventKey({ kind: "approval_request", approval: { id: "approval-1" } }), "approval::approval-1", "legacy approval events without a tab still build a stable key");
|
||||
eq(attentionChimeEventKey({ kind: "turn_done" }), undefined, "non-attention events do not build chime keys");
|
||||
}
|
||||
|
||||
{
|
||||
const seen = new Set<string>();
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "first approval event plays");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), false, "replayed approval event for the same tab is deduped");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-b", approval: { id: "1" } }, seen), true, "same approval id from another tab still plays");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-a", ask: { id: "1" } }, seen), true, "ask id sharing an approval id still plays");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request" }, seen), false, "malformed approval event does not play");
|
||||
}
|
||||
|
||||
{
|
||||
// The dedupe set stays bounded: after many unique prompts it self-prunes
|
||||
// while still deduping recently seen ids.
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: String(i) } }, seen);
|
||||
}
|
||||
eq(seen.size <= 1024, true, "dedupe set stays bounded after 2000 unique prompts");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: "1999" } }, seen), false, "most recent prompt id is still deduped after pruning");
|
||||
}
|
||||
|
||||
{
|
||||
// Runtime rebuild clears dedupe keys so reissued ids chime again.
|
||||
const seen = new Set<string>();
|
||||
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen);
|
||||
shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen);
|
||||
clearAttentionChimeKeys(seen, "tab-a");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "rebuilt tab's reissued approval id chimes again");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), false, "other tab's keys survive a scoped clear");
|
||||
clearAttentionChimeKeys(seen);
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), true, "tab-less ready clears every key");
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(`sound notifications: ${failed} failed, ${passed} passed`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`sound notifications: ${passed} passed`);
|
||||
@@ -0,0 +1,114 @@
|
||||
// Run: tsx src/__tests__/tool-card-error-details.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ToolCard } from "../components/ToolCard";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
console.log("\ntool card error details");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const error = "evidence 1: verification command \"Select-String -Path @(...long...)\" has no matching successful receipt — cite the command exactly as it ran in the session; commands that ran: [\"unique-command-tail\"] — pick the matching one and retry complete_step";
|
||||
const item: ToolItem = {
|
||||
kind: "tool",
|
||||
id: "complete-step-error",
|
||||
name: "complete_step",
|
||||
args: "",
|
||||
readOnly: true,
|
||||
status: "error",
|
||||
output: `error: ${error}`,
|
||||
error,
|
||||
durationMs: 62,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LocaleProvider, null,
|
||||
React.createElement(ToolCard, { item }),
|
||||
),
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(document.querySelectorAll(".code-block").length, 0, "duplicate error output is not rendered as a code block");
|
||||
ok(document.querySelector(".tool__err-summary")?.textContent?.includes("verification command has no matching successful receipt"), "compact error summary is visible");
|
||||
ok(!document.querySelector(".tool__err-details"), "full error details are hidden by default");
|
||||
ok(!document.body.textContent?.includes("unique-command-tail"), "long receipt list is not visible before expansion");
|
||||
|
||||
const toggle = document.querySelector(".tool__err-toggle") as HTMLButtonElement | null;
|
||||
if (!toggle) throw new Error("error details toggle did not render");
|
||||
await act(async () => {
|
||||
toggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
ok(document.querySelector(".tool__err-details")?.textContent?.includes("unique-command-tail"), "full error details render after expansion");
|
||||
|
||||
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,7 +1,7 @@
|
||||
// Run: tsx src/__tests__/use-controller-meta.test.ts
|
||||
|
||||
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, initialState, localizedBackendNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText } from "../lib/useController";
|
||||
import type { Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText } from "../lib/useController";
|
||||
import type { HistoryMessage, Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
|
||||
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
|
||||
|
||||
@@ -172,6 +172,111 @@ console.log("\nuse controller meta");
|
||||
'Model "old/model" is no longer available; switched to new/model.',
|
||||
"backend model fallback notice is localized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"legacy recovery branch notice can be normalized without exposing internal branch id",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"recovery copy notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"legacy repeated recovery conflict notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"repeated recovery conflict notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
|
||||
"The session changed on disk, so Reasonix adopted the newer transcript.",
|
||||
"adopted transcript notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; adopted the newer transcript (local changes already covered)"),
|
||||
"The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
|
||||
"covered adopted transcript notice can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("The assistant answered before taking action; asking it to use the required tools."),
|
||||
"The assistant answered before taking action; asking it to use the required tools.",
|
||||
"canonical backend notice is routed through the locale dictionary",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("background export failed: needs attention"),
|
||||
"Background export needs attention.",
|
||||
"dynamic background job notice is user-facing",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let s = reducer(initialState, {
|
||||
type: "event",
|
||||
e: { kind: "notice", level: "warn", text: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
|
||||
});
|
||||
s = reducer(s, {
|
||||
type: "event",
|
||||
e: { kind: "notice", level: "warn", text: "repeated save conflicts were detected; saved the current conflict copy in place" },
|
||||
});
|
||||
const recoveryNotices = s.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
|
||||
eq(recoveryNotices.length, 0, "recovery conflict notices stay silent in the live transcript");
|
||||
eq(s.seq, 0, "silent recovery notices do not consume sequence ids");
|
||||
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
|
||||
const ordinaryNotices = s.items.filter((item) => item.kind === "notice" && item.text === "runtime notice");
|
||||
eq(ordinaryNotices.length, 2, "ordinary repeated notices remain visible");
|
||||
}
|
||||
|
||||
{
|
||||
const quietLifecycleMessages = [
|
||||
{ level: "info", text: "guardian enabled · model=guardian-test" },
|
||||
{ level: "warn", text: "2 MCP server(s) failed to start: fs, browser — run /mcp for details" },
|
||||
{ level: "warn", text: "mcp fs: stdio plugin \"fs\": command \"missing-fs\" not found on PATH" },
|
||||
{ level: "info", text: "settings applied: session refreshed after the lease was released" },
|
||||
{ level: "info", text: "plugin \"slowserver\" has been slow 3 startups in a row (last 30000ms, budget 1000ms); demoting to background startup this session" },
|
||||
] as const;
|
||||
let s = initialState;
|
||||
for (const message of quietLifecycleMessages) {
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: message.level, text: message.text } });
|
||||
}
|
||||
eq(s.items.filter((item) => item.kind === "notice").length, 0, "background lifecycle notices stay silent in the live transcript");
|
||||
eq(s.seq, 0, "silent lifecycle notices do not consume sequence ids");
|
||||
|
||||
const userActionFailure = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "mcp connect: no configured MCP server named \"fs\"" } });
|
||||
const visibleNotices = userActionFailure.items.filter((item) => item.kind === "notice");
|
||||
eq(visibleNotices.length, 1, "user-triggered MCP failures remain visible");
|
||||
eq(visibleNotices[0]?.kind === "notice" && visibleNotices[0].text, "mcp connect: no configured MCP server named \"fs\"", "visible MCP failure keeps its text");
|
||||
}
|
||||
|
||||
{
|
||||
const history: HistoryMessage[] = [
|
||||
{ role: "notice", level: "warn", content: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
|
||||
{ role: "notice", level: "warn", content: "repeated save conflicts were detected; saved the current conflict copy in place" },
|
||||
{ role: "notice", level: "info", content: "guardian enabled · model=guardian-test" },
|
||||
{ role: "notice", level: "warn", content: "1 MCP server(s) failed to start: fs — run /mcp for details" },
|
||||
{ role: "notice", level: "info", content: "settings applied: session refreshed after the lease was released" },
|
||||
{ role: "user", content: "continue" },
|
||||
];
|
||||
const hydrated = historyMessagesToItems(history, "h");
|
||||
const recoveryNotices = hydrated.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
|
||||
const lifecycleNotices = hydrated.items.filter((item) => item.kind === "notice");
|
||||
const users = hydrated.items.filter((item) => item.kind === "user");
|
||||
eq(recoveryNotices.length, 0, "recovery conflict notices stay silent when hydrating history");
|
||||
eq(lifecycleNotices.length, 0, "background lifecycle notices stay silent when hydrating history");
|
||||
eq(users[0]?.kind === "user" && users[0].id, "h0", "silent history notices keep later item ids compact");
|
||||
eq(hydrated.seq, 1, "silent history notices do not inflate the hydrated sequence");
|
||||
}
|
||||
|
||||
{
|
||||
const hydrated = historyMessagesToItems([{ role: "notice", level: "warn", content: "short notice", detail: "historical diagnostic" }], "h");
|
||||
const notice = hydrated.items.find((item) => item.kind === "notice" && item.text === "short notice");
|
||||
eq(notice?.kind === "notice" && notice.detail, "historical diagnostic", "history notices preserve expandable detail text");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -303,6 +408,12 @@ console.log("\nuse controller meta");
|
||||
eq(Boolean(notice), true, "turn_done checkpoint merge preserves runtime notices");
|
||||
}
|
||||
|
||||
{
|
||||
const s = reducer(initialState, { type: "event", e: { kind: "notice", level: "warn", text: "short notice", detail: "verbose diagnostic" } });
|
||||
const notice = s.items.find((item) => item.kind === "notice" && item.text === "short notice");
|
||||
eq(notice?.kind === "notice" && notice.detail, "verbose diagnostic", "runtime notices preserve expandable detail text");
|
||||
}
|
||||
|
||||
{
|
||||
let s = reducer(initialState, {
|
||||
type: "history_page",
|
||||
|
||||
@@ -2,9 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useT, type Translator } from "../lib/i18n";
|
||||
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
|
||||
import type { ComposerInsertRequest, DirEntry, ToolApprovalMode, WireApproval } from "../lib/types";
|
||||
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
import { DUR_FAST } from "../lib/gsapAnimations";
|
||||
import {
|
||||
FileReferenceMenu,
|
||||
@@ -31,7 +30,9 @@ function requiresFreshHumanApproval(tool: string): boolean {
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape";
|
||||
}
|
||||
|
||||
function approvalToolLabel(tool: string, t: Translator): string {
|
||||
const APPROVAL_MODE_RANK: Record<ToolApprovalMode, number> = { ask: 0, auto: 1, yolo: 2 };
|
||||
|
||||
export function approvalToolLabel(tool: string, t: Translator): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return t("approval.toolLabelBash");
|
||||
@@ -55,6 +56,8 @@ function approvalToolLabel(tool: string, t: Translator): string {
|
||||
return t("approval.toolLabelSandboxEscape");
|
||||
case "plan_mode_read_only_command":
|
||||
return t("approval.toolLabelPlanModeReadOnly");
|
||||
case "exit_plan_mode":
|
||||
return t("approval.toolLabelExitPlan");
|
||||
default:
|
||||
return tool;
|
||||
}
|
||||
@@ -122,6 +125,7 @@ export function ApprovalModal({
|
||||
cwd,
|
||||
insertRequest,
|
||||
onRevisionActiveChange,
|
||||
toolApprovalMode,
|
||||
}: {
|
||||
approval: WireApproval;
|
||||
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
|
||||
@@ -131,12 +135,22 @@ export function ApprovalModal({
|
||||
cwd?: string;
|
||||
insertRequest?: ComposerInsertRequest | null;
|
||||
onRevisionActiveChange?: (active: boolean) => void;
|
||||
toolApprovalMode?: ToolApprovalMode;
|
||||
}) {
|
||||
const t = useT();
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const toolLabel = approvalToolLabel(approval.tool, t);
|
||||
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
|
||||
const hasFreshSessionGrant = approval.tool === "sandbox_escape";
|
||||
// Switching the approval segmented control to a more permissive mode does not
|
||||
// resolve an already-pending request; say so on the card instead of leaving
|
||||
// the user to wonder why the switch "did nothing".
|
||||
const initialToolApprovalModeRef = useRef(toolApprovalMode);
|
||||
const approvalModeRelaxed =
|
||||
!isPlanApproval &&
|
||||
toolApprovalMode !== undefined &&
|
||||
initialToolApprovalModeRef.current !== undefined &&
|
||||
APPROVAL_MODE_RANK[toolApprovalMode] > APPROVAL_MODE_RANK[initialToolApprovalModeRef.current];
|
||||
const subject = localizeApprovalSubject(approval.tool, approval.subject, t);
|
||||
const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t);
|
||||
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
|
||||
@@ -147,6 +161,9 @@ export function ApprovalModal({
|
||||
const [revisionText, setRevisionText] = useState("");
|
||||
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
|
||||
// Action index currently hovered/focused; the consequence preview row
|
||||
// prefers it over the keyboard-selected action.
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -205,7 +222,7 @@ export function ApprovalModal({
|
||||
setRevisionText("");
|
||||
setDetailsOpen(showToolDetailsByDefault);
|
||||
setSelectedIndex(isPlanApproval ? 1 : 0);
|
||||
playAttentionChime();
|
||||
setHoverIndex(null);
|
||||
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
|
||||
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
|
||||
@@ -384,6 +401,26 @@ export function ApprovalModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Descriptor list mirrors the action buttons below; the consequence row
|
||||
// previews what the hovered (or keyboard-selected) action will actually do,
|
||||
// in the quick-pick style the ask card uses for option descriptions.
|
||||
const toolActions: { key: string; label: string; desc: string; run: () => void }[] = [
|
||||
{ key: "1", label: t("approval.allowOnce"), desc: t("approval.allowOnceDesc"), run: () => onAnswer(true, false, false) },
|
||||
...(isFreshHumanApproval
|
||||
? hasFreshSessionGrant
|
||||
? [
|
||||
{ key: "2", label: t("approval.allowSandboxEscapeSession"), desc: t("approval.allowSandboxEscapeSessionDesc"), run: () => onAnswer(true, true, false) },
|
||||
{ key: "3", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
|
||||
]
|
||||
: [{ key: "2", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) }]
|
||||
: [
|
||||
{ key: "2", label: t("approval.allowRuleSession"), desc: t("approval.allowRuleSessionDesc"), run: () => onAnswer(true, true, false) },
|
||||
{ key: "3", label: t("approval.allowRulePersistent"), desc: t("approval.allowRulePersistentDesc"), run: () => onAnswer(true, true, true) },
|
||||
{ key: "4", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
|
||||
]),
|
||||
];
|
||||
const previewAction = toolActions[hoverIndex ?? selectedIndex] ?? null;
|
||||
|
||||
return (
|
||||
<div ref={shelfRef}>
|
||||
<PromptShelf
|
||||
@@ -407,33 +444,46 @@ export function ApprovalModal({
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
|
||||
{isFreshHumanApproval ? (
|
||||
hasFreshSessionGrant ? (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowSandboxEscapeSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 2} />
|
||||
</>
|
||||
) : (
|
||||
<PromptAction keyLabel="2" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
</>
|
||||
)}
|
||||
{toolActions.map((action, index) => (
|
||||
<PromptAction
|
||||
key={action.key}
|
||||
keyLabel={action.key}
|
||||
label={action.label}
|
||||
onClick={() => answerWithExit(action.run)}
|
||||
selected={selectedIndex === index}
|
||||
title={action.desc}
|
||||
onHoverChange={(hovering) =>
|
||||
setHoverIndex((current) => (hovering ? index : current === index ? null : current))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
previewAction && (
|
||||
<div className="approval-consequence">
|
||||
<span className="approval-consequence__label">{previewAction.label}</span>
|
||||
<span className="approval-consequence__text">{previewAction.desc}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
{/* Guard the whole block: PromptShelf only renders its body when children
|
||||
are truthy, and a fragment of two false branches would still count. */}
|
||||
{(approvalModeRelaxed || detailsOpen) && (
|
||||
<>
|
||||
{approvalModeRelaxed && (
|
||||
<div className="approval-mode-hint">{t("approval.modeSwitchPendingHint")}</div>
|
||||
)}
|
||||
</div>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PromptShelf>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types";
|
||||
import { PromptAction, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
|
||||
// AskCard renders the `ask` tool as a compact prompt shelf near the composer. It
|
||||
// walks multi-question asks one at a time; single-select answers advance
|
||||
@@ -24,6 +23,8 @@ export function AskCard({
|
||||
const [custom, setCustom] = useState<Record<string, string>>({});
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
// Option label currently hovered/focused; drives the detail preview row.
|
||||
const [hoverLabel, setHoverLabel] = useState<string | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const customInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const advanceTimer = useRef<number | null>(null);
|
||||
@@ -40,8 +41,11 @@ export function AskCard({
|
||||
setCustom({});
|
||||
setCustomOpen(false);
|
||||
setActive(0);
|
||||
setHoverLabel(null);
|
||||
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
|
||||
playAttentionChime();
|
||||
// The attention chime plays from the global runtime event stream (App.tsx),
|
||||
// keyed by prompt id — not here. A mount-time chime would double-fire for
|
||||
// the active tab and stay silent for background tabs.
|
||||
}, [ask.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,6 +77,7 @@ export function AskCard({
|
||||
|
||||
useEffect(() => {
|
||||
setCustomOpen(false);
|
||||
setHoverLabel(null);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,6 +161,23 @@ export function AskCard({
|
||||
[active, custom, questions, sel],
|
||||
);
|
||||
|
||||
// Grid cells truncate descriptions to one line; this row previews the full
|
||||
// description of the hovered/focused option, falling back to the latest
|
||||
// selected option and then the first described option so it never blanks.
|
||||
const detailOption = useMemo(() => {
|
||||
if (!q) return null;
|
||||
const withDescription = (label?: string | null) =>
|
||||
label ? q.options.find((option) => option.label === label && option.description) : undefined;
|
||||
const hovered = withDescription(hoverLabel);
|
||||
if (hovered) return hovered;
|
||||
const selectedLabels = sel[q.id] ?? [];
|
||||
for (let i = selectedLabels.length - 1; i >= 0; i -= 1) {
|
||||
const chosen = withDescription(selectedLabels[i]);
|
||||
if (chosen) return chosen;
|
||||
}
|
||||
return q.options.find((option) => option.description) ?? null;
|
||||
}, [hoverLabel, q, sel]);
|
||||
|
||||
if (!q) return null;
|
||||
|
||||
return (
|
||||
@@ -196,11 +218,23 @@ export function AskCard({
|
||||
description={o.description}
|
||||
onClick={() => toggle(q, o.label)}
|
||||
selected={on}
|
||||
title={o.description || undefined}
|
||||
onHoverChange={(hovering) =>
|
||||
setHoverLabel((current) => (hovering ? o.label : current === o.label ? null : current))
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
detailOption && (
|
||||
<div className="ask-shelf__detail">
|
||||
<span className="ask-shelf__detail-label">{detailOption.label}</span>
|
||||
<span className="ask-shelf__detail-text">{detailOption.description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
quickActions={
|
||||
<>
|
||||
{active > 0 && (
|
||||
|
||||
@@ -51,6 +51,9 @@ const LONG_PASTE_MIN_CHARS = 2000;
|
||||
const LONG_PASTE_MIN_LINES = 20;
|
||||
const COMPOSER_MIN_HEIGHT = 104;
|
||||
const COMPOSER_MAX_HEIGHT = 360;
|
||||
// Height reserved for the in-card run strip while a turn runs; applied via a
|
||||
// CSS calc so --composer-height always stays in "logical height" space.
|
||||
const COMPOSER_RUN_STRIP_RESERVED = 30;
|
||||
const COMPOSER_MAX_VIEWPORT_RATIO = 0.4;
|
||||
const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
|
||||
const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
|
||||
@@ -255,6 +258,14 @@ function composerMaxHeight(): number {
|
||||
return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
|
||||
}
|
||||
|
||||
// The rendered card includes the run strip while a turn runs; subtract it to
|
||||
// recover the user's logical height when measuring from the DOM.
|
||||
function composerLogicalHeight(card: HTMLElement): number {
|
||||
const strip = card.querySelector(".composer-run-strip");
|
||||
const stripHeight = strip ? strip.getBoundingClientRect().height : 0;
|
||||
return card.getBoundingClientRect().height - stripHeight;
|
||||
}
|
||||
|
||||
function clampComposerHeight(height: number): number {
|
||||
return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
|
||||
}
|
||||
@@ -449,6 +460,8 @@ export function Composer({
|
||||
turnStartAt,
|
||||
turnTokens,
|
||||
retry,
|
||||
pendingApprovalLabel,
|
||||
pendingAsk = false,
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
fileRefRefreshKey,
|
||||
@@ -492,6 +505,11 @@ export function Composer({
|
||||
turnStartAt?: number;
|
||||
turnTokens?: number;
|
||||
retry?: { attempt: number; max: number };
|
||||
// Resolved label of the tool waiting for approval (null/undefined when none);
|
||||
// shifts the run strip into its waiting state so the ticking spinner does not
|
||||
// claim the model is working while a prompt is blocked on the user.
|
||||
pendingApprovalLabel?: string | null;
|
||||
pendingAsk?: boolean;
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
fileRefRefreshKey?: number | string;
|
||||
@@ -1536,7 +1554,19 @@ export function Composer({
|
||||
const handleCancel = () => {
|
||||
const restored = onCancel();
|
||||
if (goalModeOn && activeGoal) onClearGoal();
|
||||
if (typeof restored === "string") setTextCaretEnd(restored);
|
||||
// Queued guidance the model never consumed would otherwise vanish when the
|
||||
// cancelled turn ends (the running→false effect clears the shelf). Fold it
|
||||
// back into the draft: cancelling means "stop acting", not "discard what I
|
||||
// typed" — the same contract onCancel already honors for un-sent text.
|
||||
const queued = pendingGuidance.map((item) => item.text).filter((part) => part.trim() !== "");
|
||||
if (queued.length === 0) {
|
||||
if (typeof restored === "string") setTextCaretEnd(restored);
|
||||
return;
|
||||
}
|
||||
setPendingGuidance([]);
|
||||
setGuidanceExpanded(false);
|
||||
const base = typeof restored === "string" ? restored : text;
|
||||
setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n"));
|
||||
};
|
||||
|
||||
const pickCommand = (c: CommandInfo) => setTextCaretEnd("/" + c.name + " ");
|
||||
@@ -1637,7 +1667,7 @@ export function Composer({
|
||||
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = composerHeight ?? card.getBoundingClientRect().height;
|
||||
const startHeight = composerHeight ?? composerLogicalHeight(card);
|
||||
let nextHeight = clampComposerHeight(startHeight);
|
||||
let moved = false;
|
||||
card.style.setProperty("--composer-height", `${nextHeight}px`);
|
||||
@@ -1675,7 +1705,7 @@ export function Composer({
|
||||
|
||||
const onComposerResizeKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const card = composerCardRef.current;
|
||||
const current = composerHeight ?? card?.getBoundingClientRect().height ?? COMPOSER_MIN_HEIGHT;
|
||||
const current = composerHeight ?? (card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT);
|
||||
const step = e.shiftKey ? 32 : 16;
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowUp" || e.key === "PageUp") next = current + step;
|
||||
@@ -2006,7 +2036,18 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
|
||||
// When the run strip is visible inside a user-resized card, the card grows
|
||||
// by the strip's reserved height so the meta row stays fully visible.
|
||||
// --composer-height carries only the user's logical height; the reservation
|
||||
// is a separate variable consumed by the CSS calc, so the live resize drag
|
||||
// (which writes raw logical heights) stays consistent with this render path.
|
||||
const showRunStrip = Boolean(retry || running);
|
||||
const composerCardStyle = composerHeight === null
|
||||
? undefined
|
||||
: ({
|
||||
"--composer-height": `${composerHeight}px`,
|
||||
"--composer-run-strip-reserved": `${showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0}px`,
|
||||
} as CSSProperties);
|
||||
const textareaStyle = composerHeight === null && textareaAutoHeight !== null
|
||||
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
|
||||
: undefined;
|
||||
@@ -2054,17 +2095,45 @@ export function Composer({
|
||||
requestAnimationFrame(() => taRef.current?.focus());
|
||||
});
|
||||
};
|
||||
const runActivity = retry
|
||||
// Run-strip state machine: retry > waiting-approval > waiting-ask > streaming.
|
||||
// The waiting states replace the whimsical ticker — while a prompt is blocked
|
||||
// on the user, the strip must say so instead of counting "working" seconds.
|
||||
const waitingPrompt = pendingApprovalLabel ? "approval" : pendingAsk ? "ask" : null;
|
||||
// The pending approval itself disables the composer; keep the approval bar
|
||||
// usable in exactly that case so the mode can still be changed mid-prompt.
|
||||
const approvalBarDisabled = Boolean(disabled) && !pendingApprovalLabel;
|
||||
// Waiting on the user (approval/ask) is not model work: pause the ticker's
|
||||
// clock while a prompt is blocked so the elapsed time means "model time",
|
||||
// matching what the waiting strip promises. Retries keep counting — they are
|
||||
// system time, not user time. Accumulated in state via the effect cleanup so
|
||||
// leaving the waiting state re-renders with the corrected time immediately.
|
||||
const [waitAccumMs, setWaitAccumMs] = useState(0);
|
||||
useEffect(() => {
|
||||
setWaitAccumMs(0);
|
||||
}, [turnStartAt]);
|
||||
useEffect(() => {
|
||||
if (!waitingPrompt) return;
|
||||
const since = Date.now();
|
||||
return () => setWaitAccumMs((total) => total + (Date.now() - since));
|
||||
}, [waitingPrompt]);
|
||||
const runStateText = retry
|
||||
? t("status.retrying", { attempt: retry.attempt, max: retry.max })
|
||||
: running && turnStartAt
|
||||
? (() => {
|
||||
const elapsedMs = Math.max(0, now - turnStartAt);
|
||||
const words = SPINNER_WORDS[locale];
|
||||
const word = words[Math.floor(elapsedMs / 3000) % words.length];
|
||||
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
|
||||
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
|
||||
})()
|
||||
: null;
|
||||
: waitingPrompt === "approval"
|
||||
? t("composer.runWaitingApproval", { tool: pendingApprovalLabel ?? "" })
|
||||
: waitingPrompt === "ask"
|
||||
? t("composer.runWaitingAsk")
|
||||
: running
|
||||
? t("composer.runAnnounceRunning")
|
||||
: null;
|
||||
const runTicker = !retry && !waitingPrompt && running && turnStartAt
|
||||
? (() => {
|
||||
const elapsedMs = Math.max(0, now - turnStartAt - waitAccumMs);
|
||||
const words = SPINNER_WORDS[locale];
|
||||
const word = words[Math.floor(elapsedMs / 3000) % words.length];
|
||||
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
|
||||
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");
|
||||
@@ -2322,20 +2391,6 @@ export function Composer({
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{runActivity && (
|
||||
<div className="composer-toolbar composer-toolbar--status-only">
|
||||
<div className="composer-runstatus" role="status" aria-live="polite">
|
||||
<span className="composer-runstatus__dot" />
|
||||
<span className="composer-runstatus__text">{runActivity}</span>
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button className="composer-runstatus__stop" type="button" onClick={handleCancel}>
|
||||
<Square size={10} fill="currentColor" />
|
||||
<span>{t("composer.stopShort")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pendingGuidance.length > 0 && (
|
||||
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
|
||||
<div className="composer-guidance-head">
|
||||
@@ -2484,7 +2539,7 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? " composer-card--running" : ""}`}
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
|
||||
ref={composerCardRef}
|
||||
style={composerCardStyle}
|
||||
>
|
||||
@@ -2502,6 +2557,17 @@ export function Composer({
|
||||
onKeyDown={onComposerResizeKeyDown}
|
||||
onDoubleClick={resetComposerHeight}
|
||||
/>
|
||||
{runStateText && (
|
||||
<div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
|
||||
<span className="composer-run-strip__dot" aria-hidden="true" />
|
||||
{/* The ticker re-renders every second; keep it out of the accessibility
|
||||
tree and announce only the stable state text via the live region. */}
|
||||
<span className="composer-run-strip__text" aria-hidden={runTicker ? true : undefined}>
|
||||
{runTicker ?? runStateText}
|
||||
</span>
|
||||
<span className="sr-only" role="status">{runStateText}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`composer${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
|
||||
onDrop={onDrop}
|
||||
@@ -2543,6 +2609,18 @@ export function Composer({
|
||||
{composerPrompt}
|
||||
</span>
|
||||
)}
|
||||
{running && (
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button
|
||||
className="composer__btn composer__btn--stop"
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
aria-label={t("composer.stop")}
|
||||
>
|
||||
<Square size={12} fill="currentColor" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={submitTooltip}>
|
||||
<button
|
||||
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
|
||||
@@ -2550,7 +2628,7 @@ export function Composer({
|
||||
disabled={submitBlocked}
|
||||
aria-label={submitTooltip}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -2634,13 +2712,17 @@ export function Composer({
|
||||
)}
|
||||
</div>
|
||||
<div className="composer-meta__control composer-meta__control--approval">
|
||||
{/* A pending tool approval disables the composer, but the approval
|
||||
bar stays usable so mode changes remain possible mid-prompt;
|
||||
the approval card explains that the pending request still needs
|
||||
an explicit decision. */}
|
||||
<div className="composer-modebar composer-modebar--approval" data-mode={toolApprovalMode} title={t("composer.accessMenuTitle")}>
|
||||
<span className="composer-modebar__thumb" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--ask${toolApprovalMode === "ask" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("ask")}
|
||||
disabled={disabled}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "ask"}
|
||||
title={t("composer.accessAskTitle")}
|
||||
>
|
||||
@@ -2651,7 +2733,7 @@ export function Composer({
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--auto${toolApprovalMode === "auto" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("auto")}
|
||||
disabled={disabled}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "auto"}
|
||||
title={t("composer.accessAutoTitle")}
|
||||
>
|
||||
@@ -2662,7 +2744,7 @@ export function Composer({
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--yolo${toolApprovalMode === "yolo" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("yolo")}
|
||||
disabled={disabled}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "yolo"}
|
||||
title={t("composer.accessYoloTitle")}
|
||||
>
|
||||
|
||||
@@ -28,6 +28,7 @@ export function HistoryPanel({
|
||||
onRestore,
|
||||
onPurge,
|
||||
onPurgeAll,
|
||||
onDeleteMany,
|
||||
onClose,
|
||||
}: {
|
||||
kind?: "history" | "trash";
|
||||
@@ -40,6 +41,7 @@ export function HistoryPanel({
|
||||
onRestore?: (path: string) => void;
|
||||
onPurge?: (path: string) => void;
|
||||
onPurgeAll?: (paths: string[]) => void;
|
||||
onDeleteMany?: (paths: string[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const tr = useT();
|
||||
@@ -56,7 +58,7 @@ export function HistoryPanel({
|
||||
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [blankMenuPoint, setBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [menuConfirmTarget, setMenuConfirmTarget] = useState<
|
||||
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | null
|
||||
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | { kind: "clearRecovery" } | null
|
||||
>(null);
|
||||
const [preview, setPreview] = useState<{
|
||||
path: string;
|
||||
@@ -144,15 +146,33 @@ export function HistoryPanel({
|
||||
return [s.title, s.preview, s.path, s.topicTitle, s.workspaceRoot].some((part) => (part ?? "").toLowerCase().includes(q));
|
||||
});
|
||||
}, [dateFilter, isTrash, query, scopeFilter, sessions, statusFilter]);
|
||||
// Recovery copies are bulk-actionable in both views: trash purges them for
|
||||
// good, history sweeps them into the trash. The history sweep skips copies
|
||||
// that are current or open in a tab — an adopted copy is someone's live
|
||||
// conversation. Counting from `sessions` (not the filtered list) keeps the
|
||||
// sweep exhaustive even while a search or filter is active.
|
||||
const recoveryCopyPaths = useMemo(
|
||||
() => sessions.filter((s) => s.recovered && (isTrash || (!s.current && !s.open))).map((s) => s.path),
|
||||
[isTrash, sessions],
|
||||
);
|
||||
const recoveryCopyCount = recoveryCopyPaths.length;
|
||||
const displayedSessions = useMemo(
|
||||
() =>
|
||||
isTrash
|
||||
? filteredSessions
|
||||
: [...filteredSessions.filter((s) => !s.recovered), ...filteredSessions.filter((s) => s.recovered)],
|
||||
[filteredSessions, isTrash],
|
||||
);
|
||||
|
||||
// Sessions arrive newest-first; bucket consecutive ones under a day heading
|
||||
// (Today / Yesterday / a date) while preserving that order.
|
||||
const groups: { label: string; items: SessionMeta[] }[] = [];
|
||||
for (const s of filteredSessions) {
|
||||
const groups: { label: string; items: SessionMeta[]; recovered: boolean }[] = [];
|
||||
for (const s of displayedSessions) {
|
||||
const recovered = !isTrash && Boolean(s.recovered);
|
||||
const label = dayLabel(sessionTimeForGrouping(s, isTrash));
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.label === label) last.items.push(s);
|
||||
else groups.push({ label, items: [s] });
|
||||
if (last && last.label === label && last.recovered === recovered) last.items.push(s);
|
||||
else groups.push({ label, items: [s], recovered });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -168,19 +188,19 @@ export function HistoryPanel({
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(null);
|
||||
if (filteredSessions.length === 0) {
|
||||
if (displayedSessions.length === 0) {
|
||||
if (preview) setPreview(null);
|
||||
return;
|
||||
}
|
||||
if (preview && filteredSessions.some((s) => s.path === preview.path)) return;
|
||||
const first = filteredSessions.find((s) => !s.current) ?? filteredSessions[0];
|
||||
if (preview && displayedSessions.some((s) => s.path === preview.path)) return;
|
||||
const first = displayedSessions.find((s) => !s.current) ?? displayedSessions[0];
|
||||
void loadPreview(first);
|
||||
}, [filteredSessions, loadPreview, preview]);
|
||||
}, [displayedSessions, loadPreview, preview]);
|
||||
|
||||
const previewItems = useMemo(() => previewMessagesToItems(preview?.messages ?? []), [preview?.messages]);
|
||||
const selectedSession = useMemo(
|
||||
() => (preview ? filteredSessions.find((s) => s.path === preview.path) ?? null : null),
|
||||
[filteredSessions, preview],
|
||||
() => (preview ? displayedSessions.find((s) => s.path === preview.path) ?? null : null),
|
||||
[displayedSessions, preview],
|
||||
);
|
||||
const openSessionMenu = (event: ReactMouseEvent<HTMLElement>, s: SessionMeta) => {
|
||||
event.preventDefault();
|
||||
@@ -207,6 +227,13 @@ export function HistoryPanel({
|
||||
setBlankMenuPoint(null);
|
||||
setMenuConfirmTarget({ kind: "clear" });
|
||||
};
|
||||
const armClearRecoveryCopies = () => {
|
||||
if (recoveryCopyCount === 0 || (!isTrash && running)) return;
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
setBlankMenuPoint(null);
|
||||
setMenuConfirmTarget({ kind: "clearRecovery" });
|
||||
};
|
||||
const closeHistoryMenus = () => {
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
@@ -226,6 +253,12 @@ export function HistoryPanel({
|
||||
closeHistoryMenus();
|
||||
onPurgeAll?.(paths);
|
||||
};
|
||||
const clearRecoveryCopies = () => {
|
||||
const paths = recoveryCopyPaths;
|
||||
closeHistoryMenus();
|
||||
if (isTrash) onPurgeAll?.(paths);
|
||||
else onDeleteMany?.(paths);
|
||||
};
|
||||
const sessionMenuItems: ContextMenuItem[] = menuSession
|
||||
? isTrash
|
||||
? [
|
||||
@@ -292,30 +325,52 @@ export function HistoryPanel({
|
||||
]
|
||||
: [];
|
||||
const trashBlankMenuItems: ContextMenuItem[] =
|
||||
menuConfirmTarget?.kind === "clear"
|
||||
menuConfirmTarget?.kind === "clearRecovery"
|
||||
? [
|
||||
{
|
||||
key: "clear-trash-confirm",
|
||||
key: "clear-recovery-confirm",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.confirmClearTrash"),
|
||||
label: tr("history.confirmClearRecoveryCopies"),
|
||||
danger: true,
|
||||
onSelect: clearTrash,
|
||||
onSelect: clearRecoveryCopies,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "clear-trash",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearTrashMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
|
||||
},
|
||||
];
|
||||
: menuConfirmTarget?.kind === "clear"
|
||||
? [
|
||||
{
|
||||
key: "clear-trash-confirm",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.confirmClearTrash"),
|
||||
danger: true,
|
||||
onSelect: clearTrash,
|
||||
},
|
||||
]
|
||||
: [
|
||||
...(recoveryCopyCount > 0
|
||||
? [
|
||||
{
|
||||
key: "clear-recovery",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearRecoveryCopiesMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clearRecovery" }),
|
||||
} as ContextMenuItem,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "clear-trash",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearTrashMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
|
||||
},
|
||||
];
|
||||
const actionConfirmDelete =
|
||||
selectedSession && menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === selectedSession.path;
|
||||
const actionConfirmPurge =
|
||||
selectedSession && menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === selectedSession.path;
|
||||
const actionConfirmClearTrash = isTrash && menuConfirmTarget?.kind === "clear";
|
||||
const actionConfirmClearRecovery = menuConfirmTarget?.kind === "clearRecovery";
|
||||
|
||||
const openSelected = () => {
|
||||
if (!selectedSession || running || isTrash) return;
|
||||
@@ -356,6 +411,18 @@ export function HistoryPanel({
|
||||
{!isTrash && running && <div className="management-modal__summary history-modal__summary">{tr("history.readOnlyHint")}</div>}
|
||||
</div>
|
||||
<div className="management-modal__actions history-modal__actions">
|
||||
{recoveryCopyCount > 0 && (
|
||||
<button
|
||||
className={`chip history-clear${actionConfirmClearRecovery ? " history-clear--confirm" : ""}`}
|
||||
type="button"
|
||||
disabled={!isTrash && running}
|
||||
onClick={actionConfirmClearRecovery ? clearRecoveryCopies : armClearRecoveryCopies}
|
||||
>
|
||||
{isTrash
|
||||
? tr(actionConfirmClearRecovery ? "history.confirmClearRecoveryCopies" : "history.clearRecoveryCopies")
|
||||
: tr(actionConfirmClearRecovery ? "history.confirmTrashRecoveryCopies" : "history.trashRecoveryCopies")}
|
||||
</button>
|
||||
)}
|
||||
{isTrash && sessions.length > 0 && (
|
||||
<button
|
||||
className={`chip history-clear${actionConfirmClearTrash ? " history-clear--confirm" : ""}`}
|
||||
@@ -426,9 +493,9 @@ export function HistoryPanel({
|
||||
<div className="mem-empty">{tr("history.noResults")}</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<section className="mem-section" key={g.label}>
|
||||
<section className="mem-section" key={`${g.recovered ? "recovered" : "normal"}-${g.label}`}>
|
||||
<div className="mem-section__title hist-group__title">
|
||||
<span>{g.label}</span>
|
||||
<span>{g.recovered ? `${tr("history.recoveryCopiesGroup")} · ${g.label}` : g.label}</span>
|
||||
<span className="hist-group__count">{g.items.length}</span>
|
||||
</div>
|
||||
{g.items.map((s) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export function PromptShelf({
|
||||
actions,
|
||||
children,
|
||||
crumbs,
|
||||
note,
|
||||
quickActions,
|
||||
headerActions,
|
||||
barRef,
|
||||
@@ -24,6 +25,9 @@ export function PromptShelf({
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
crumbs?: ReactNode;
|
||||
// Rendered between the actions grid and the quick actions; used for
|
||||
// focus-following detail previews and similar footnotes.
|
||||
note?: ReactNode;
|
||||
quickActions?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
barRef?: RefObject<HTMLDivElement | null>;
|
||||
@@ -52,6 +56,7 @@ export function PromptShelf({
|
||||
{crumbs}
|
||||
{children && <div className="prompt-shelf__body">{children}</div>}
|
||||
{actions && <div className="prompt-shelf__actions">{actions}</div>}
|
||||
{note && <div className="prompt-shelf__footnote">{note}</div>}
|
||||
{quickActions && <div className="prompt-shelf__quick-actions">{quickActions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +97,8 @@ export function PromptAction({
|
||||
description,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
title,
|
||||
onHoverChange,
|
||||
primary = false,
|
||||
selected = false,
|
||||
quiet = false,
|
||||
@@ -102,6 +109,11 @@ export function PromptAction({
|
||||
description?: ReactNode;
|
||||
onClick: () => void;
|
||||
ariaLabel?: string;
|
||||
// Native tooltip fallback for truncated descriptions.
|
||||
title?: string;
|
||||
// Fires on mouse enter/focus (true) and mouse leave/blur (false) so the
|
||||
// parent can drive a focus-following detail preview.
|
||||
onHoverChange?: (hovering: boolean) => void;
|
||||
primary?: boolean;
|
||||
selected?: boolean;
|
||||
quiet?: boolean;
|
||||
@@ -121,6 +133,11 @@ export function PromptAction({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
|
||||
onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
|
||||
onFocus={onHoverChange ? () => onHoverChange(true) : undefined}
|
||||
onBlur={onHoverChange ? () => onHoverChange(false) : undefined}
|
||||
>
|
||||
{keyLabel && <span className="prompt-action__key">{keyLabel}</span>}
|
||||
{hasCopy && (
|
||||
|
||||
@@ -16,6 +16,8 @@ const SUBAGENT_TOOLS = new Set(["task", "run_skill", "explore", "research", "rev
|
||||
|
||||
/** Lines shown by default in a shell output block before the "show all" button. */
|
||||
const SHELL_PREVIEW_LINES = 10;
|
||||
const ERROR_SUMMARY_MAX_CHARS = 140;
|
||||
const ERROR_DETAILS_THRESHOLD = 220;
|
||||
|
||||
function pretty(json: string): string {
|
||||
try {
|
||||
@@ -30,6 +32,41 @@ function formatToolDuration(ms?: number): string {
|
||||
return `${Math.round(ms)} ms`;
|
||||
}
|
||||
|
||||
function normalizeErrorText(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").trim();
|
||||
}
|
||||
|
||||
function withoutErrorPrefix(text: string): string {
|
||||
return normalizeErrorText(text).replace(/^error:\s*/i, "");
|
||||
}
|
||||
|
||||
function toolOutputDuplicatesError(output: string | undefined, error: string | undefined): boolean {
|
||||
if (!output || !error) return false;
|
||||
const normalizedOutput = normalizeErrorText(output);
|
||||
const normalizedError = normalizeErrorText(error);
|
||||
if (!normalizedOutput || !normalizedError) return false;
|
||||
return normalizedOutput === normalizedError || withoutErrorPrefix(normalizedOutput) === withoutErrorPrefix(normalizedError);
|
||||
}
|
||||
|
||||
function summarizeToolError(error: string, receiptMismatchText: string): string {
|
||||
const text = withoutErrorPrefix(error);
|
||||
if (!text) return "";
|
||||
if (/has no matching successful receipt/i.test(text)) {
|
||||
return receiptMismatchText;
|
||||
}
|
||||
const firstLine = text.split("\n")[0]?.trim() ?? "";
|
||||
if (firstLine.length <= ERROR_SUMMARY_MAX_CHARS) return firstLine;
|
||||
return `${firstLine.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
|
||||
}
|
||||
|
||||
function errorNeedsDetails(error: string, summary: string): boolean {
|
||||
const normalizedError = withoutErrorPrefix(error);
|
||||
if (!normalizedError) return false;
|
||||
return normalizedError.includes("\n") ||
|
||||
normalizedError.length > ERROR_DETAILS_THRESHOLD ||
|
||||
(summary !== "" && normalizedError !== summary);
|
||||
}
|
||||
|
||||
/** Returns the first n lines of text and the total line count. */
|
||||
function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
|
||||
const lines = text.split("\n");
|
||||
@@ -60,12 +97,14 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [showErrorDetails, setShowErrorDetails] = useState(false);
|
||||
// Lazy-load full tool data from the backend when the card is expanded and
|
||||
// the in-memory copy was archived for memory efficiency.
|
||||
const [fullData, setFullData] = useState<{ args: string; output?: string } | null>(null);
|
||||
const archivedWithoutFullData = Boolean(item.dataArchived && !fullData);
|
||||
const effectiveArgs = archivedWithoutFullData ? "" : fullData?.args ?? item.args;
|
||||
const effectiveOutput = fullData?.output ?? item.output;
|
||||
const displayOutput = toolOutputDuplicatesError(effectiveOutput, item.error) ? undefined : effectiveOutput;
|
||||
const previewDiff = item.fileDiff?.diff ? item.fileDiff : undefined;
|
||||
const diffs = previewDiff || archivedWithoutFullData ? [] : diffsFor(item.name, effectiveArgs);
|
||||
const subject = fullData ? subjectOf(item.name, effectiveArgs) : item.subject || subjectOf(item.name, effectiveArgs);
|
||||
@@ -78,12 +117,15 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
// else folds its args/output away by default. Open while running so the
|
||||
// user sees progress; closed by default once settled.
|
||||
const hasArchivedOnDemandBody = Boolean(item.dataArchived && tabId);
|
||||
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!effectiveOutput || hasArchivedOnDemandBody);
|
||||
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!displayOutput || hasArchivedOnDemandBody);
|
||||
|
||||
// Shell output: split into preview + "show all" toggle.
|
||||
const shellOutput = item.isShell && effectiveOutput ? effectiveOutput : null;
|
||||
const shellOutput = item.isShell && displayOutput ? displayOutput : null;
|
||||
const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
|
||||
const hasBody = Boolean(previewDiff || diffs.length || hasNested || shellPreview || (!shellPreview && hasArgsOrOutput) || item.error);
|
||||
const errorText = item.error ? normalizeErrorText(item.error) : "";
|
||||
const errorSummary = errorText ? summarizeToolError(errorText, t("tool.errorReceiptMismatch")) : "";
|
||||
const hasErrorDetails = errorText ? errorNeedsDetails(errorText, errorSummary) : false;
|
||||
useEffect(() => {
|
||||
if (!open || !item.dataArchived || fullData || !tabId) return;
|
||||
let cancelled = false;
|
||||
@@ -111,7 +153,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
|
||||
|
||||
const duration = item.status === "running" ? "" : formatToolDuration(item.durationMs);
|
||||
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, effectiveOutput, item.error));
|
||||
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (item.error ? errorSummary : archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, displayOutput, item.error));
|
||||
|
||||
// GSAP-driven collapse/expand for tool body
|
||||
const toolBodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -207,16 +249,36 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
{!shellPreview && hasArgsOrOutput && (
|
||||
<>
|
||||
{effectiveArgs && <CodeViewer value={pretty(effectiveArgs)} language="json" maxHeight={180} />}
|
||||
{effectiveOutput && (
|
||||
{displayOutput && (
|
||||
<>
|
||||
<CodeViewer value={effectiveOutput} maxHeight={280} />
|
||||
<CodeViewer value={displayOutput} maxHeight={280} />
|
||||
{item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.error && <div className="tool__err">{item.error}</div>}
|
||||
{errorText && (
|
||||
<div className={`tool__err${hasErrorDetails ? " tool__err--compact" : ""}`}>
|
||||
{hasErrorDetails ? (
|
||||
<>
|
||||
<div className="tool__err-summary">{errorSummary || t("tool.error")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tool__err-toggle"
|
||||
onClick={() => setShowErrorDetails((value) => !value)}
|
||||
aria-expanded={showErrorDetails}
|
||||
>
|
||||
<ChevronRight className={`tool__err-toggle-icon${showErrorDetails ? " tool__err-toggle-icon--open" : ""}`} size={12} aria-hidden="true" />
|
||||
<span>{showErrorDetails ? t("tool.hideErrorDetails") : t("tool.showErrorDetails")}</span>
|
||||
</button>
|
||||
{showErrorDetails && <div className="tool__err-details">{errorText}</div>}
|
||||
</>
|
||||
) : (
|
||||
errorText
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -587,7 +587,7 @@ export function Transcript({
|
||||
out.push(<ToolCard key={it.id} item={it} subcalls={subcallsByParent.get(it.id)} tabId={tabId} />);
|
||||
break;
|
||||
case "phase": out.push(<PhaseCard key={it.id} text={it.text} />); break;
|
||||
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
|
||||
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
|
||||
case "compaction": out.push(<CompactionCard key={it.id} item={it} />); break;
|
||||
}
|
||||
}
|
||||
@@ -949,7 +949,7 @@ function WarmTurnItems({
|
||||
break;
|
||||
}
|
||||
case "phase": nodes.push(<PhaseCard key={it.id} text={it.text} />); break;
|
||||
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
|
||||
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
|
||||
case "compaction": nodes.push(<CompactionCard key={it.id} item={it} />); break;
|
||||
}
|
||||
}
|
||||
@@ -1268,11 +1268,20 @@ function PhaseCard({ text }: { text: string }) {
|
||||
return <div className="phase" data-entrance="true"><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
|
||||
}
|
||||
|
||||
function NoticeCard({ level, text }: { level: NoticeItem["level"]; text: string }) {
|
||||
function NoticeCard({ level, text, detail }: { level: NoticeItem["level"]; text: string; detail?: string }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className={`notice-line notice-line--${level}`} data-entrance="true">
|
||||
<span className="notice-line__icon">{level === "warn" ? "⚠ " : "ℹ "}</span>
|
||||
<span className="notice-line__text">{text}</span>
|
||||
<div className="notice-line__text">
|
||||
{text}
|
||||
{detail ? (
|
||||
<details className="notice-line__details">
|
||||
<summary>{t("notice.details")}</summary>
|
||||
<div>{detail}</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -574,6 +574,16 @@ export function onFilesDropped(cb: (paths: string[]) => void): () => void {
|
||||
|
||||
// onReady subscribes to the agent:ready event fired when boot.Build completes.
|
||||
// The frontend re-fetches Meta/Context/History when this lands.
|
||||
// onRuntimeRebuilt fires when a tab's controller is replaced in place
|
||||
// (model/effort/token-mode switch, clear-while-running). The rebuilt
|
||||
// controller restarts prompt ids, so per-tab id-keyed state must reset.
|
||||
export function onRuntimeRebuilt(cb: (tabId?: string) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("runtime:rebuilt", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function onReady(cb: (tabId?: string) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("agent:ready", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
|
||||
@@ -627,6 +637,11 @@ function bridgeBreadcrumb(method: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function elapsedMs(startedAt: number): number {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
return Math.max(0, Math.round(now - startedAt));
|
||||
}
|
||||
|
||||
export const app: AppBindings = new Proxy({} as AppBindings, {
|
||||
get(_t, prop) {
|
||||
const target = realApp() ?? getMock();
|
||||
@@ -635,18 +650,26 @@ export const app: AppBindings = new Proxy({} as AppBindings, {
|
||||
return (...args: unknown[]) => {
|
||||
const method = String(prop);
|
||||
const crumb = bridgeBreadcrumb(method);
|
||||
const startedAt = crumb ? (typeof performance !== "undefined" ? performance.now() : Date.now()) : 0;
|
||||
if (crumb) addBreadcrumb("bridge", crumb);
|
||||
try {
|
||||
const result = (v as (...a: unknown[]) => unknown).apply(target, args);
|
||||
if (result && typeof (result as Promise<unknown>).then === "function") {
|
||||
return (result as Promise<unknown>).catch((err) => {
|
||||
if (crumb) addBreadcrumb("bridge.error", method);
|
||||
throw err;
|
||||
});
|
||||
return (result as Promise<unknown>).then(
|
||||
(value) => {
|
||||
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
|
||||
return value;
|
||||
},
|
||||
(err) => {
|
||||
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (crumb) addBreadcrumb("bridge.error", method);
|
||||
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -719,13 +742,14 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
|
||||
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
|
||||
}
|
||||
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" {
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" | "notice" {
|
||||
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";
|
||||
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
|
||||
if (value === "notice" || value === "notices" || value === "notice-preview") return "notice";
|
||||
return "demo";
|
||||
}
|
||||
|
||||
@@ -820,8 +844,8 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.ai/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.ai/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.com/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.com/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
|
||||
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
|
||||
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
|
||||
@@ -874,6 +898,7 @@ function makeMockApp(): AppBindings {
|
||||
const guidanceMock = scenario === "guidance";
|
||||
const runningMock = scenario === "running" || guidanceMock;
|
||||
const sandboxEscapeMock = scenario === "sandbox_escape";
|
||||
const noticePreviewMock = scenario === "notice";
|
||||
const mockAttachmentDataURLs = new Map<string, string>();
|
||||
let cancelled = false;
|
||||
let pendingAskPreview = false;
|
||||
@@ -982,7 +1007,7 @@ function makeMockApp(): AppBindings {
|
||||
// Mutable so delete/rename are observable in browser dev.
|
||||
const sessions: SessionMeta[] = [
|
||||
{ path: "/mock/sessions/a.jsonl", preview: "fix the login bug in auth.go", turns: 12, createdAt: t0 - 2 * day, lastActivityAt: t0 - 3_600_000, modTime: t0 - 3_600_000, current: true, open: true },
|
||||
{ path: "/mock/sessions/b.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true },
|
||||
{ path: "/mock/sessions/b-recovery-0123456789abcdef.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true, recovered: true },
|
||||
{ path: "/mock/sessions/c.jsonl", preview: "write the README and badges", turns: 8, createdAt: t0 - 4 * day, lastActivityAt: t0 - day - 3_600_000, modTime: t0 - day - 3_600_000, current: false, open: false },
|
||||
{ path: "/mock/sessions/d.jsonl", preview: "explain the plugin host design", turns: 3, createdAt: t0 - 5 * day, lastActivityAt: t0 - 4 * day, modTime: t0 - 4 * day, current: false, open: false },
|
||||
];
|
||||
@@ -1033,6 +1058,7 @@ function makeMockApp(): AppBindings {
|
||||
scope: "global",
|
||||
topicId: "topic_product",
|
||||
topicTitle: t("mock.trashGlobalProductTitle"),
|
||||
recovered: true,
|
||||
},
|
||||
];
|
||||
if (freshMock) {
|
||||
@@ -1557,7 +1583,28 @@ function makeMockApp(): AppBindings {
|
||||
setMockTabRunning(currentMockTurnTabId(), false);
|
||||
emit({ kind: "turn_done" });
|
||||
};
|
||||
let mockTabs: TabMeta[] = freshMock ? [
|
||||
let mockTabs: TabMeta[] = noticePreviewMock ? [
|
||||
{
|
||||
id: "tab_notice_preview",
|
||||
scope: "project",
|
||||
workspaceRoot: "~/projects/reasonix",
|
||||
workspaceName: "reasonix",
|
||||
workspacePath: "~/projects/reasonix",
|
||||
gitBranch: "codex/compact-chat-notices-i18n",
|
||||
topicId: "topic_notice_preview",
|
||||
topicTitle: "Compact notice preview",
|
||||
projectColor: "green",
|
||||
label: "DeepSeek-R1",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "~/projects/reasonix",
|
||||
},
|
||||
] : freshMock ? [
|
||||
{
|
||||
id: "tab_global",
|
||||
scope: "global",
|
||||
|
||||
@@ -635,6 +635,7 @@ export function installPerformancePressureMonitor() {
|
||||
const isFocused = () => typeof document === "undefined" || document.hasFocus?.() !== false;
|
||||
let visibleSince = isHidden() ? Number.POSITIVE_INFINITY : startedAt;
|
||||
let expected = performance.now() + 1000;
|
||||
let eventLoopLagPrimed = false;
|
||||
|
||||
const pastGrace = () => performance.now() >= graceUntil;
|
||||
const inspectLongTasks = () => {
|
||||
@@ -651,6 +652,7 @@ export function installPerformancePressureMonitor() {
|
||||
longTasks.length = 0;
|
||||
lagSamples.length = 0;
|
||||
expected = performance.now() + 1000;
|
||||
eventLoopLagPrimed = false;
|
||||
if (!isHidden()) visibleSince = performance.now();
|
||||
});
|
||||
}
|
||||
@@ -673,9 +675,17 @@ export function installPerformancePressureMonitor() {
|
||||
|
||||
window.setInterval(() => {
|
||||
const now = performance.now();
|
||||
if (!pastGrace()) {
|
||||
expected = now + 1000;
|
||||
return;
|
||||
}
|
||||
if (!eventLoopLagPrimed) {
|
||||
expected = now + 1000;
|
||||
eventLoopLagPrimed = true;
|
||||
return;
|
||||
}
|
||||
const lagMs = Math.max(0, now - expected);
|
||||
expected = now + 1000;
|
||||
if (!pastGrace()) return;
|
||||
if (!shouldRecordEventLoopLagSample(isHidden(), now - visibleSince, isFocused())) return;
|
||||
lagSamples.push(lagMs);
|
||||
if (lagSamples.length > MAX_LAG_SAMPLES) lagSamples.shift();
|
||||
|
||||
@@ -114,7 +114,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
|
||||
}, [locale]);
|
||||
|
||||
// setPref updates only the live UI; persistence is handled by desktop config.
|
||||
|
||||
@@ -156,3 +156,56 @@ export function playAttentionChime(): void {
|
||||
void playWav(pref, 0.25, playSynthAttention);
|
||||
}
|
||||
}
|
||||
|
||||
export type AttentionChimeEvent = {
|
||||
kind?: string;
|
||||
tabId?: string;
|
||||
approval?: { id?: string };
|
||||
ask?: { id?: string };
|
||||
};
|
||||
|
||||
export function attentionChimeEventKey(event: AttentionChimeEvent): string | undefined {
|
||||
if (event.kind === "approval_request" && event.approval?.id) return `approval:${event.tabId ?? ""}:${event.approval.id}`;
|
||||
if (event.kind === "ask_request" && event.ask?.id) return `ask:${event.tabId ?? ""}:${event.ask.id}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// attentionChimeSeenCap bounds the dedupe set. Prompt ids are unique per
|
||||
// prompt, so the set only ever grows; past the cap the oldest half is dropped
|
||||
// (insertion order) — replay dedupe only needs to cover recently replayed
|
||||
// prompts, not the whole session history.
|
||||
const attentionChimeSeenCap = 512;
|
||||
|
||||
// clearAttentionChimeKeys drops dedupe keys after a runtime rebuild. Approval
|
||||
// and ask ids are per-controller counters starting at "1", so a rebuilt
|
||||
// controller (model/effort/settings switch) reissues ids an earlier prompt on
|
||||
// the same tab already used — without this, the first prompt after a rebuild
|
||||
// is misread as a replay and stays silent. A ready event without a tab id
|
||||
// (settings rebuilds emit tab-less ready) clears everything: over-clearing
|
||||
// only re-chimes a replayed pending prompt, which is a desirable reminder,
|
||||
// while under-clearing mutes a live prompt.
|
||||
export function clearAttentionChimeKeys(seen: Set<string>, tabId?: string): void {
|
||||
if (tabId === undefined || tabId === "") {
|
||||
seen.clear();
|
||||
return;
|
||||
}
|
||||
for (const key of [...seen]) {
|
||||
if (key.startsWith(`approval:${tabId}:`) || key.startsWith(`ask:${tabId}:`)) {
|
||||
seen.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldPlayAttentionChimeForEvent(event: AttentionChimeEvent, seen: Set<string>): boolean {
|
||||
const key = attentionChimeEventKey(event);
|
||||
if (!key || seen.has(key)) return false;
|
||||
if (seen.size >= attentionChimeSeenCap) {
|
||||
let drop = seen.size - attentionChimeSeenCap / 2;
|
||||
for (const k of seen) {
|
||||
if (drop-- <= 0) break;
|
||||
seen.delete(k);
|
||||
}
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
}
|
||||
@@ -155,6 +155,7 @@ export interface MemoryCompilerStats {
|
||||
export interface WireEvent {
|
||||
kind: EventKind;
|
||||
text?: string;
|
||||
detail?: string;
|
||||
reasoning?: string;
|
||||
memoryCitations?: MemoryCitation[];
|
||||
memoryCompiler?: MemoryCompilerStats;
|
||||
@@ -324,6 +325,7 @@ export interface ChangedFileInfo {
|
||||
export interface HistoryMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
detail?: string;
|
||||
submitText?: string;
|
||||
checkpointTurn?: number;
|
||||
createdAt?: number;
|
||||
|
||||
@@ -53,7 +53,7 @@ export type Item =
|
||||
| { kind: "user"; id: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
|
||||
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; memoryCitations?: MemoryCitation[] }
|
||||
| { kind: "phase"; id: string; text: string }
|
||||
| { kind: "notice"; id: string; level: "info" | "warn"; text: string }
|
||||
| { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string }
|
||||
| {
|
||||
kind: "compaction";
|
||||
id: string;
|
||||
@@ -244,6 +244,8 @@ export function sameMeta(a?: Meta, b?: Meta): boolean {
|
||||
|
||||
const STALE_TURN_RECONCILE_MS = 30_000;
|
||||
const CANCEL_RECONCILE_DELAYS_MS = [0, 100, 300, 1_000] as const;
|
||||
const STARTUP_READY_META_RECONCILE_MS = 250;
|
||||
const STARTUP_READY_META_RECONCILE_ATTEMPTS = 60;
|
||||
|
||||
export function shouldReconcileStaleTurn(
|
||||
state: Pick<State, "running" | "turnActive"> | undefined,
|
||||
@@ -379,7 +381,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
const positionalResults = positionalToolResults(messages);
|
||||
const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index));
|
||||
|
||||
const items: Item[] = [];
|
||||
let items: Item[] = [];
|
||||
let seq = startSeq;
|
||||
const consumedToolIDs = new Set<string>();
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
||||
@@ -394,8 +396,9 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
}
|
||||
if (m.role === "notice") {
|
||||
if (m.content.trim() !== "") {
|
||||
items.push({ kind: "notice", id: `${idPrefix}${seq}`, level: m.level === "warn" ? "warn" : "info", text: localizedBackendNoticeText(m.content) });
|
||||
seq++;
|
||||
const next = appendNoticeItem(items, seq, `${idPrefix}${seq}`, m.level === "warn" ? "warn" : "info", m.content, m.detail);
|
||||
items = next.items;
|
||||
seq = next.seq;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -725,7 +728,7 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, sessionTokens, sessionCost, sessionCurrency };
|
||||
}
|
||||
case "notice":
|
||||
return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: e.level ?? "info", text: localizedBackendNoticeText(e.text ?? "") }] };
|
||||
return appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail);
|
||||
case "phase":
|
||||
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
|
||||
case "compaction_started":
|
||||
@@ -1009,9 +1012,169 @@ export function localizedBackendNoticeText(text: string): string {
|
||||
if (modelFallback) {
|
||||
return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] });
|
||||
}
|
||||
const backgroundJob = /^background (.+) failed: needs attention$/s.exec(msg);
|
||||
if (backgroundJob) {
|
||||
return t("notice.backgroundJobFailed", { kind: backgroundJob[1] });
|
||||
}
|
||||
const canonicalNoticeKey = backendNoticeKey(msg);
|
||||
if (canonicalNoticeKey) {
|
||||
return t(canonicalNoticeKey);
|
||||
}
|
||||
if (
|
||||
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
|
||||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)
|
||||
) {
|
||||
return t("recovery.noticeSavedCopy");
|
||||
}
|
||||
if (
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
|
||||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)
|
||||
) {
|
||||
return t("recovery.noticeKeptCurrent");
|
||||
}
|
||||
if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) {
|
||||
return t("recovery.noticeAdoptedCovered");
|
||||
}
|
||||
if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) {
|
||||
return t("recovery.noticeAdopted");
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function backendNoticeKey(msg: string): DictKey | "" {
|
||||
switch (msg) {
|
||||
case "Task status needs one more check; asking the assistant to finish or explain what is blocking it.":
|
||||
return "notice.finalReadiness";
|
||||
case "No visible answer was produced; asking the assistant to respond again.":
|
||||
return "notice.emptyFinal";
|
||||
case "The assistant answered before taking action; asking it to use the required tools.":
|
||||
return "notice.executorHandoff";
|
||||
case "Tool round limit reached; asking the assistant to summarize progress.":
|
||||
return "notice.toolBudget";
|
||||
case "The assistant is stuck retrying a blocked action; asking it to change approach.":
|
||||
return "notice.loopGuard";
|
||||
case "Context is getting large; preserving cache until cleanup is needed.":
|
||||
return "notice.contextLarge";
|
||||
case "Context cleanup skipped for now.":
|
||||
return "notice.contextCleanupSkipped";
|
||||
case "Automatic context cleanup paused because the context window is too small.":
|
||||
return "notice.contextCleanupPaused";
|
||||
case "Context was compacted without a generated summary.":
|
||||
return "notice.compactionNoSummary";
|
||||
case "Planning mode enabled for this multi-step task.":
|
||||
return "notice.autoPlanEnabled";
|
||||
case "Plan detection requested a plan.":
|
||||
return "notice.autoPlanRequested";
|
||||
case "Plan detection was uncertain; using the fallback planner heuristic.":
|
||||
return "notice.autoPlanFallback";
|
||||
case "Goal is not ready to complete yet; continuing the remaining work.":
|
||||
return "notice.goalNotReady";
|
||||
case "Goal still has unfinished task state; continuing the remaining work.":
|
||||
return "notice.goalUnfinished";
|
||||
case "AutoResearch status update failed.":
|
||||
return "notice.autoresearchStatusFailed";
|
||||
case "AutoResearch task marked blocked.":
|
||||
return "notice.autoresearchBlocked";
|
||||
case "Job artifact migration failed.":
|
||||
return "notice.jobArtifactMigrationFailed";
|
||||
case "Background job teardown timed out.":
|
||||
return "notice.jobTeardownTimeout";
|
||||
case "Some plan-mode tool settings were ignored.":
|
||||
return "notice.planModeToolSettingsIgnored";
|
||||
case "Some plan-mode command settings were ignored.":
|
||||
return "notice.planModeCommandSettingsIgnored";
|
||||
case "Config migration did not complete.":
|
||||
return "notice.configMigrationIncomplete";
|
||||
case "Selected model is missing its API key.":
|
||||
return "notice.modelMissingApiKey";
|
||||
case "An MCP server failed to start.":
|
||||
return "notice.mcpServerFailed";
|
||||
case "Some MCP servers failed to start; run /mcp for details.":
|
||||
return "notice.mcpServersFailed";
|
||||
case "Guardian was disabled because its model was not found.":
|
||||
return "notice.guardianModelMissing";
|
||||
case "Guardian was disabled because it could not start.":
|
||||
return "notice.guardianStartFailed";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function recoveryNoticeDedupeKey(text: string): string {
|
||||
const msg = text.trim();
|
||||
if (
|
||||
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
|
||||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) ||
|
||||
msg === t("recovery.noticeSavedCopy")
|
||||
) {
|
||||
return "recovery:saved-copy";
|
||||
}
|
||||
if (
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
|
||||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) ||
|
||||
msg === t("recovery.noticeKeptCurrent")
|
||||
) {
|
||||
return "recovery:kept-current";
|
||||
}
|
||||
if (
|
||||
/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) ||
|
||||
msg === t("recovery.noticeAdoptedCovered")
|
||||
) {
|
||||
return "recovery:adopted-covered";
|
||||
}
|
||||
if (
|
||||
/^session changed on disk; adopted the newer transcript$/i.test(msg) ||
|
||||
msg === t("recovery.noticeAdopted")
|
||||
) {
|
||||
return "recovery:adopted";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function quietTranscriptNoticeKey(text: string): string {
|
||||
const recovery = recoveryNoticeDedupeKey(text);
|
||||
if (recovery) return recovery;
|
||||
|
||||
const msg = text.trim();
|
||||
if (/^guardian enabled · model=.+$/i.test(msg)) {
|
||||
return "startup:guardian-enabled";
|
||||
}
|
||||
if (/^\d+ MCP server\(s\) failed to start: .+ \u2014 run \/mcp for details$/i.test(msg)) {
|
||||
return "startup:mcp-failures";
|
||||
}
|
||||
const directMCPFailure = /^mcp\s+([A-Za-z0-9._-]+):\s+.+$/i.exec(msg);
|
||||
if (directMCPFailure) {
|
||||
const name = directMCPFailure[1].toLowerCase();
|
||||
if (!["add", "auth", "config", "connect", "import", "mode", "remove"].includes(name)) {
|
||||
return "startup:mcp-failure";
|
||||
}
|
||||
}
|
||||
if (/^plugin ".+" has been slow \d+ startups in a row \(last \d+ms, budget \d+ms\); demoting to background startup this session$/i.test(msg)) {
|
||||
return "startup:plugin-demote";
|
||||
}
|
||||
if (/^.+ applied: session refreshed after the lease was released$/i.test(msg)) {
|
||||
return "settings:deferred-refresh-applied";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string): { items: Item[]; seq: number } {
|
||||
if (quietTranscriptNoticeKey(rawText)) {
|
||||
return { items, seq };
|
||||
}
|
||||
const text = localizedBackendNoticeText(rawText);
|
||||
if (quietTranscriptNoticeKey(text)) {
|
||||
return { items, seq };
|
||||
}
|
||||
const trimmedDetail = detail?.trim();
|
||||
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}) }], seq: seq + 1 };
|
||||
}
|
||||
|
||||
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string): State {
|
||||
const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text, detail);
|
||||
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
|
||||
}
|
||||
|
||||
function localizedSessionAction(action: string): string {
|
||||
switch (action.trim()) {
|
||||
case "changing model":
|
||||
@@ -1075,6 +1238,16 @@ async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, acti
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMetaOnlyForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<Meta | undefined> {
|
||||
try {
|
||||
const meta = await app.MetaForTab(tabId);
|
||||
dispatchTo(tabId, { type: "meta", meta });
|
||||
return meta;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function replayPendingPromptsForActiveTab(activeTabId: string | undefined, replay: () => Promise<void> = () => app.ReplayPendingPrompts()): void {
|
||||
if (!activeTabId) return;
|
||||
void replay().catch(() => {});
|
||||
@@ -1096,6 +1269,8 @@ export function useController() {
|
||||
const stateRef = useRef(activeState);
|
||||
const backendActiveTabIdRef = useRef<string | undefined>(undefined);
|
||||
const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
|
||||
const readyMetaReconcileSeq = useRef(0);
|
||||
const readyMetaReconcileActive = useRef<{ tabId: string; seq: number } | undefined>(undefined);
|
||||
activeTabIdRef.current = activeTabId;
|
||||
stateRef.current = activeState;
|
||||
|
||||
@@ -1482,11 +1657,9 @@ export function useController() {
|
||||
addBreadcrumb("tab.hydrate", `ready ignored ${readyTabId}`);
|
||||
return;
|
||||
}
|
||||
if (activeId) {
|
||||
void loadSessionDataForTab(activeId, false, "startup", { preserveCachedHistory: true });
|
||||
return;
|
||||
}
|
||||
void syncActiveTabFromBackend(false, true);
|
||||
// A ready event can race the initial hydrate. Refresh the tab metadata
|
||||
// first so a stale ready=false snapshot does not keep the composer locked.
|
||||
void syncActiveTabFromBackend(false, true, { preserveCachedHistory: true });
|
||||
});
|
||||
|
||||
void syncActiveTabFromBackend(false, true);
|
||||
@@ -1507,6 +1680,51 @@ export function useController() {
|
||||
};
|
||||
}, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]);
|
||||
|
||||
// If the startup ready event is missed, keep the composer lock in sync with
|
||||
// the active tab's backend metadata without kicking off tab activation work.
|
||||
useEffect(() => {
|
||||
const tabId = activeTabId;
|
||||
const meta = activeState.meta;
|
||||
if (!tabId || !meta || meta.ready || meta.startupErr || activeState.backendActivationPending) {
|
||||
readyMetaReconcileSeq.current += 1;
|
||||
readyMetaReconcileActive.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
const seq = readyMetaReconcileSeq.current + 1;
|
||||
readyMetaReconcileSeq.current = seq;
|
||||
readyMetaReconcileActive.current = { tabId, seq };
|
||||
|
||||
const stillCurrent = () => {
|
||||
const active = readyMetaReconcileActive.current;
|
||||
return !cancelled && active?.tabId === tabId && active.seq === seq && activeTabIdRef.current === tabId;
|
||||
};
|
||||
|
||||
const schedule = (attempt: number) => {
|
||||
timer = window.setTimeout(() => {
|
||||
void tick(attempt);
|
||||
}, STARTUP_READY_META_RECONCILE_MS);
|
||||
};
|
||||
|
||||
const tick = async (attempt: number) => {
|
||||
if (!stillCurrent()) return;
|
||||
const current = statesRef.current.get(tabId);
|
||||
if (!current?.meta || current.meta.ready || current.meta.startupErr || current.backendActivationPending) return;
|
||||
const nextMeta = await refreshMetaOnlyForTab(tabId, dispatchTo);
|
||||
if (!stillCurrent()) return;
|
||||
if (nextMeta?.ready || nextMeta?.startupErr || attempt + 1 >= STARTUP_READY_META_RECONCILE_ATTEMPTS) return;
|
||||
schedule(attempt + 1);
|
||||
};
|
||||
|
||||
schedule(0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [activeTabId, activeState.meta?.ready, activeState.meta?.startupErr, activeState.backendActivationPending, dispatchTo]);
|
||||
|
||||
// Stale-turn watchdog: if the frontend thinks the agent is running but the
|
||||
// turn stream has gone quiet, reconcile with the backend. This catches cases
|
||||
// where the Wails event channel silently drops turn_done after the final
|
||||
|
||||
@@ -471,7 +471,10 @@ export const en = {
|
||||
"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.steerPlaceholder": "Running — type guidance, Enter adds it to the queue",
|
||||
"composer.runWaitingApproval": "Waiting for your approval — {tool}",
|
||||
"composer.runWaitingAsk": "Waiting for your answer",
|
||||
"composer.runAnnounceRunning": "Reasonix is working",
|
||||
"composer.guidanceQueue": "Queued guidance",
|
||||
"composer.guidanceCount": "Queued guidance {n}",
|
||||
"composer.guidanceRemaining": "{n} more queued",
|
||||
@@ -673,6 +676,13 @@ export const en = {
|
||||
"approval.sendRevision": "Send update",
|
||||
"approval.exitPlan": "Exit plan",
|
||||
"approval.toolPending": "Allow tool call",
|
||||
"approval.modeSwitchPendingHint": "Approval mode was relaxed — this pending request still needs your decision.",
|
||||
"approval.toolLabelExitPlan": "Plan approval",
|
||||
"approval.allowOnceDesc": "Allow this call only; the next one asks again.",
|
||||
"approval.allowRuleSessionDesc": "Allow matching calls until this session ends; resets on restart.",
|
||||
"approval.allowRulePersistentDesc": "Save a persistent allow rule; future sessions stop asking too.",
|
||||
"approval.denyDesc": "Reject this call; the model sees the refusal and continues.",
|
||||
"approval.allowSandboxEscapeSessionDesc": "Run follow-up commands in the real environment for the rest of this session.",
|
||||
"approval.details": "Details",
|
||||
"approval.hideDetails": "Hide",
|
||||
"approval.deny": "Deny",
|
||||
@@ -748,6 +758,12 @@ export const en = {
|
||||
"history.clearTrash": "Empty trash",
|
||||
"history.confirmClearTrash": "Confirm empty trash",
|
||||
"history.clearTrashMenu": "Empty trash…",
|
||||
"history.clearRecoveryCopies": "Clear copies",
|
||||
"history.confirmClearRecoveryCopies": "Confirm clear copies",
|
||||
"history.clearRecoveryCopiesMenu": "Clear recovery copies…",
|
||||
"history.trashRecoveryCopies": "Trash recovery copies",
|
||||
"history.confirmTrashRecoveryCopies": "Confirm trash copies",
|
||||
"history.recoveryCopiesGroup": "Recovery copies",
|
||||
"history.moveToTrash": "Move to trash",
|
||||
"history.confirmMoveToTrash": "Confirm move to trash",
|
||||
"history.restore": "Restore",
|
||||
@@ -788,14 +804,10 @@ export const en = {
|
||||
// session recovery
|
||||
"recovery.branch": "Conflict copy",
|
||||
"recovery.badge": "Copy",
|
||||
"recovery.open": "Open conflict copy",
|
||||
"recovery.toast": "A save conflict was detected, so a conflict copy was kept for {title}.",
|
||||
"recovery.failedLease": "The conflict copy is in use by another runtime, so this tab did not switch to it.",
|
||||
"recovery.failedUnavailable": "The conflict copy could not be opened, so this tab stayed on the original session.",
|
||||
"recovery.banner": "This session is a save-conflict copy. Compare it with the original before continuing.",
|
||||
"recovery.reason": "Reason: {reason}",
|
||||
"recovery.digest": "Digest: {digest}",
|
||||
"recovery.parent": "Parent: {parent}",
|
||||
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"recovery.noticeAdopted": "The session changed on disk, so Reasonix adopted the newer transcript.",
|
||||
"recovery.noticeAdoptedCovered": "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
|
||||
|
||||
// project tree
|
||||
"projectTree.workspaceTitle": "Projects",
|
||||
@@ -1873,6 +1885,34 @@ export const en = {
|
||||
"transcript.processedDuration": "Processed {s}s",
|
||||
"notice.info": "Notice",
|
||||
"notice.warning": "Warning",
|
||||
"notice.details": "Details",
|
||||
"notice.finalReadiness": "Task status needs one more check; asking the assistant to finish or explain what is blocking it.",
|
||||
"notice.emptyFinal": "No visible answer was produced; asking the assistant to respond again.",
|
||||
"notice.executorHandoff": "The assistant answered before taking action; asking it to use the required tools.",
|
||||
"notice.toolBudget": "Tool round limit reached; asking the assistant to summarize progress.",
|
||||
"notice.loopGuard": "The assistant is stuck retrying a blocked action; asking it to change approach.",
|
||||
"notice.contextLarge": "Context is getting large; preserving cache until cleanup is needed.",
|
||||
"notice.contextCleanupSkipped": "Context cleanup skipped for now.",
|
||||
"notice.contextCleanupPaused": "Automatic context cleanup paused because the context window is too small.",
|
||||
"notice.compactionNoSummary": "Context was compacted without a generated summary.",
|
||||
"notice.autoPlanEnabled": "Planning mode enabled for this multi-step task.",
|
||||
"notice.autoPlanRequested": "Plan detection requested a plan.",
|
||||
"notice.autoPlanFallback": "Plan detection was uncertain; using the fallback planner heuristic.",
|
||||
"notice.goalNotReady": "Goal is not ready to complete yet; continuing the remaining work.",
|
||||
"notice.goalUnfinished": "Goal still has unfinished task state; continuing the remaining work.",
|
||||
"notice.autoresearchStatusFailed": "AutoResearch status update failed.",
|
||||
"notice.autoresearchBlocked": "AutoResearch task marked blocked.",
|
||||
"notice.backgroundJobFailed": "Background {kind} needs attention.",
|
||||
"notice.jobArtifactMigrationFailed": "Job artifact migration failed.",
|
||||
"notice.jobTeardownTimeout": "Background job teardown timed out.",
|
||||
"notice.planModeToolSettingsIgnored": "Some plan-mode tool settings were ignored.",
|
||||
"notice.planModeCommandSettingsIgnored": "Some plan-mode command settings were ignored.",
|
||||
"notice.configMigrationIncomplete": "Config migration did not complete.",
|
||||
"notice.modelMissingApiKey": "Selected model is missing its API key.",
|
||||
"notice.mcpServerFailed": "An MCP server failed to start.",
|
||||
"notice.mcpServersFailed": "Some MCP servers failed to start; run /mcp for details.",
|
||||
"notice.guardianModelMissing": "Guardian was disabled because its model was not found.",
|
||||
"notice.guardianStartFailed": "Guardian was disabled because it could not start.",
|
||||
"questionNav.label": "Question navigation",
|
||||
"questionNav.progress": "Question {current} / {total}",
|
||||
"questionNav.jump": "Jump to question {n}",
|
||||
@@ -1920,8 +1960,12 @@ export const en = {
|
||||
// tool card summaries
|
||||
"tool.stepOne": "{n} step",
|
||||
"tool.stepOther": "{n} steps",
|
||||
"tool.error": "error",
|
||||
"tool.errorReceiptMismatch": "verification command has no matching successful receipt",
|
||||
"tool.truncated": "output truncated",
|
||||
"tool.showAllLines": "show all {n} lines",
|
||||
"tool.showErrorDetails": "show error details",
|
||||
"tool.hideErrorDetails": "hide error details",
|
||||
"tool.readCount": "Read {n} files",
|
||||
"tool.searchCount": "Search {n} files",
|
||||
"tool.otherReadCount": "{n} read calls",
|
||||
|
||||
@@ -350,7 +350,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.send": "傳送(Enter)",
|
||||
"composer.queueGuidance": "加入引導佇列(Enter)",
|
||||
"composer.steer": "追加到目前任務(Enter)",
|
||||
"composer.steerPlaceholder": "輸入要加入佇列的引導…",
|
||||
"composer.steerPlaceholder": "正在執行——輸入補充指示,Enter 加入佇列",
|
||||
"composer.runWaitingApproval": "等待你核准 — {tool}",
|
||||
"composer.runWaitingAsk": "等待你回答",
|
||||
"composer.runAnnounceRunning": "正在執行",
|
||||
"composer.guidanceQueue": "待處理引導",
|
||||
"composer.guidanceCount": "待處理引導 {n}",
|
||||
"composer.guidanceRemaining": "還有 {n} 條",
|
||||
@@ -520,6 +523,13 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"approval.sendRevision": "傳送修改意見",
|
||||
"approval.exitPlan": "退出計畫",
|
||||
"approval.toolPending": "允許工具呼叫",
|
||||
"approval.modeSwitchPendingHint": "核准檔位已放寬——這筆擱置的請求仍需你確認。",
|
||||
"approval.toolLabelExitPlan": "計畫確認",
|
||||
"approval.allowOnceDesc": "僅允許本次呼叫,下次仍會詢問。",
|
||||
"approval.allowRuleSessionDesc": "本會話內符合的呼叫不再詢問,重啟後失效。",
|
||||
"approval.allowRulePersistentDesc": "儲存為持久允許規則,之後的會話也不再詢問。",
|
||||
"approval.denyDesc": "拒絕本次呼叫,模型會收到拒絕結果並繼續。",
|
||||
"approval.allowSandboxEscapeSessionDesc": "本會話後續命令改在真實環境執行,跳過沙箱。",
|
||||
"approval.details": "詳情",
|
||||
"approval.hideDetails": "收起",
|
||||
"approval.deny": "拒絕",
|
||||
@@ -586,6 +596,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.clearTrash": "清空回收站",
|
||||
"history.confirmClearTrash": "確認清空回收站",
|
||||
"history.clearTrashMenu": "清空回收站…",
|
||||
"history.clearRecoveryCopies": "清理副本",
|
||||
"history.confirmClearRecoveryCopies": "確認清理副本",
|
||||
"history.clearRecoveryCopiesMenu": "清理恢復副本…",
|
||||
"history.trashRecoveryCopies": "副本移入回收站",
|
||||
"history.confirmTrashRecoveryCopies": "確認移入回收站",
|
||||
"history.recoveryCopiesGroup": "恢復副本",
|
||||
"history.moveToTrash": "移到回收站",
|
||||
"history.confirmMoveToTrash": "確認移入回收站",
|
||||
"history.restore": "還原",
|
||||
@@ -626,14 +642,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
// 會話恢復
|
||||
"recovery.branch": "衝突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.open": "開啟衝突副本",
|
||||
"recovery.toast": "偵測到儲存衝突,已為 {title} 保留一份衝突副本。",
|
||||
"recovery.failedLease": "衝突副本被另一個執行階段占用,未切換過去。",
|
||||
"recovery.failedUnavailable": "無法開啟衝突副本,目前標籤仍停留在原會話。",
|
||||
"recovery.banner": "目前會話是儲存衝突副本。繼續前建議與原會話對比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父會話:{parent}",
|
||||
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為衝突副本。",
|
||||
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前衝突副本已繼續儲存。",
|
||||
"recovery.noticeAdopted": "偵測到會話在磁碟上發生變化,已採用較新的記錄。",
|
||||
"recovery.noticeAdoptedCovered": "偵測到會話在磁碟上發生變化,已採用較新的記錄;本機改動已包含其中。",
|
||||
|
||||
// 專案樹
|
||||
"projectTree.workspaceTitle": "專案",
|
||||
@@ -1209,6 +1221,34 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"transcript.processedDuration": "已處理 {s}s",
|
||||
"notice.info": "提示",
|
||||
"notice.warning": "警告",
|
||||
"notice.details": "詳情",
|
||||
"notice.finalReadiness": "任務狀態還需要確認,已要求助手完成或說明阻塞原因。",
|
||||
"notice.emptyFinal": "沒有產生可見回覆,已要求助手重新作答。",
|
||||
"notice.executorHandoff": "助手尚未執行必要操作,已要求它使用相應工具。",
|
||||
"notice.toolBudget": "工具呼叫輪數已達上限,已要求助手總結目前進度。",
|
||||
"notice.loopGuard": "助手卡在重複受阻操作上,已要求它換一種處理方式。",
|
||||
"notice.contextLarge": "上下文正在變大,暫時保留快取,等需要時再清理。",
|
||||
"notice.contextCleanupSkipped": "本次暫未清理上下文。",
|
||||
"notice.contextCleanupPaused": "自動上下文清理已暫停:目前上下文視窗太小。",
|
||||
"notice.compactionNoSummary": "上下文已壓縮,但未產生摘要。",
|
||||
"notice.autoPlanEnabled": "已為這個多步驟任務開啟規劃模式。",
|
||||
"notice.autoPlanRequested": "任務判斷建議先制定計畫。",
|
||||
"notice.autoPlanFallback": "規劃判斷不確定,已使用備用規則。",
|
||||
"notice.goalNotReady": "目標暫未達到完成條件,繼續處理剩餘工作。",
|
||||
"notice.goalUnfinished": "目標還有未完成的任務狀態,繼續處理。",
|
||||
"notice.autoresearchStatusFailed": "AutoResearch 狀態更新失敗。",
|
||||
"notice.autoresearchBlocked": "AutoResearch 任務已標記為阻塞。",
|
||||
"notice.backgroundJobFailed": "背景 {kind} 需要處理。",
|
||||
"notice.jobArtifactMigrationFailed": "任務產物遷移失敗。",
|
||||
"notice.jobTeardownTimeout": "背景任務清理逾時。",
|
||||
"notice.planModeToolSettingsIgnored": "部分規劃模式工具設定已被忽略。",
|
||||
"notice.planModeCommandSettingsIgnored": "部分規劃模式指令設定已被忽略。",
|
||||
"notice.configMigrationIncomplete": "設定遷移未完成。",
|
||||
"notice.modelMissingApiKey": "目前模型缺少 API key。",
|
||||
"notice.mcpServerFailed": "一個 MCP 服務啟動失敗。",
|
||||
"notice.mcpServersFailed": "部分 MCP 服務啟動失敗,可執行 /mcp 查看詳情。",
|
||||
"notice.guardianModelMissing": "Guardian 已停用:未找到對應模型。",
|
||||
"notice.guardianStartFailed": "Guardian 啟動失敗,已停用。",
|
||||
"questionNav.label": "問題導航",
|
||||
"questionNav.progress": "問題 {current} / {total}",
|
||||
"questionNav.jump": "跳轉到問題 {n}",
|
||||
@@ -1256,8 +1296,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
// 工具卡片摘要
|
||||
"tool.stepOne": "{n} 步",
|
||||
"tool.stepOther": "{n} 步",
|
||||
"tool.error": "錯誤",
|
||||
"tool.errorReceiptMismatch": "證據命令沒有匹配的成功執行記錄",
|
||||
"tool.truncated": "輸出已截斷",
|
||||
"tool.showAllLines": "顯示全部 {n} 行",
|
||||
"tool.showErrorDetails": "顯示錯誤詳情",
|
||||
"tool.hideErrorDetails": "隱藏錯誤詳情",
|
||||
"tool.lineOne": "{n} 行",
|
||||
"tool.lineOther": "{n} 行",
|
||||
"tool.matchOne": "{n} 處匹配",
|
||||
|
||||
@@ -472,7 +472,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.send": "发送(Enter)",
|
||||
"composer.queueGuidance": "加入引导队列(Enter)",
|
||||
"composer.steer": "追加到当前任务(Enter)",
|
||||
"composer.steerPlaceholder": "输入要加入队列的引导…",
|
||||
"composer.steerPlaceholder": "正在运行——输入补充指示,回车加入队列",
|
||||
"composer.runWaitingApproval": "等待你批准 — {tool}",
|
||||
"composer.runWaitingAsk": "等待你回答",
|
||||
"composer.runAnnounceRunning": "正在运行",
|
||||
"composer.guidanceQueue": "待处理引导",
|
||||
"composer.guidanceCount": "待处理引导 {n}",
|
||||
"composer.guidanceRemaining": "还有 {n} 条",
|
||||
@@ -674,6 +677,13 @@ export const zh: Record<DictKey, string> = {
|
||||
"approval.sendRevision": "发送修改意见",
|
||||
"approval.exitPlan": "退出计划",
|
||||
"approval.toolPending": "允许工具调用",
|
||||
"approval.modeSwitchPendingHint": "审批档位已放宽——本次挂起的请求仍需你确认。",
|
||||
"approval.toolLabelExitPlan": "计划确认",
|
||||
"approval.allowOnceDesc": "仅允许本次调用,下次仍会询问。",
|
||||
"approval.allowRuleSessionDesc": "本会话内匹配的调用不再询问,重启后失效。",
|
||||
"approval.allowRulePersistentDesc": "保存为持久允许规则,之后的会话也不再询问。",
|
||||
"approval.denyDesc": "拒绝本次调用,模型会收到拒绝结果并继续。",
|
||||
"approval.allowSandboxEscapeSessionDesc": "本会话后续命令改在真实环境运行,跳过沙箱。",
|
||||
"approval.details": "详情",
|
||||
"approval.hideDetails": "收起",
|
||||
"approval.deny": "拒绝",
|
||||
@@ -749,6 +759,12 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.clearTrash": "清空回收站",
|
||||
"history.confirmClearTrash": "确认清空回收站",
|
||||
"history.clearTrashMenu": "清空回收站…",
|
||||
"history.clearRecoveryCopies": "清理副本",
|
||||
"history.confirmClearRecoveryCopies": "确认清理副本",
|
||||
"history.clearRecoveryCopiesMenu": "清理恢复副本…",
|
||||
"history.trashRecoveryCopies": "副本移入回收站",
|
||||
"history.confirmTrashRecoveryCopies": "确认移入回收站",
|
||||
"history.recoveryCopiesGroup": "恢复副本",
|
||||
"history.moveToTrash": "移到回收站",
|
||||
"history.confirmMoveToTrash": "确认移入回收站",
|
||||
"history.restore": "恢复",
|
||||
@@ -789,14 +805,10 @@ export const zh: Record<DictKey, string> = {
|
||||
// 会话恢复
|
||||
"recovery.branch": "冲突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.open": "打开冲突副本",
|
||||
"recovery.toast": "检测到保存冲突,已为 {title} 保留一份冲突副本。",
|
||||
"recovery.failedLease": "冲突副本被另一个运行时占用,未切换过去。",
|
||||
"recovery.failedUnavailable": "无法打开冲突副本,当前标签仍停留在原会话。",
|
||||
"recovery.banner": "当前会话是保存冲突副本。继续前建议与原会话对比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父会话:{parent}",
|
||||
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为冲突副本。",
|
||||
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前冲突副本已继续保存。",
|
||||
"recovery.noticeAdopted": "检测到会话在磁盘上发生变化,已采用较新的记录。",
|
||||
"recovery.noticeAdoptedCovered": "检测到会话在磁盘上发生变化,已采用较新的记录;本地改动已包含其中。",
|
||||
|
||||
// 项目树
|
||||
"projectTree.workspaceTitle": "项目",
|
||||
@@ -1875,6 +1887,34 @@ export const zh: Record<DictKey, string> = {
|
||||
"transcript.processedDuration": "已处理 {s}s",
|
||||
"notice.info": "提示",
|
||||
"notice.warning": "警告",
|
||||
"notice.details": "详情",
|
||||
"notice.finalReadiness": "任务状态还需要确认,已要求助手完成或说明阻塞原因。",
|
||||
"notice.emptyFinal": "没有生成可见回复,已要求助手重新作答。",
|
||||
"notice.executorHandoff": "助手尚未执行必要操作,已要求它使用相应工具。",
|
||||
"notice.toolBudget": "工具调用轮数已达上限,已要求助手总结当前进展。",
|
||||
"notice.loopGuard": "助手卡在重复受阻操作上,已要求它换一种处理方式。",
|
||||
"notice.contextLarge": "上下文正在变大,暂时保留缓存,等需要时再清理。",
|
||||
"notice.contextCleanupSkipped": "本次暂未清理上下文。",
|
||||
"notice.contextCleanupPaused": "自动上下文清理已暂停:当前上下文窗口太小。",
|
||||
"notice.compactionNoSummary": "上下文已压缩,但未生成摘要。",
|
||||
"notice.autoPlanEnabled": "已为这个多步骤任务开启规划模式。",
|
||||
"notice.autoPlanRequested": "任务判断建议先制定计划。",
|
||||
"notice.autoPlanFallback": "规划判断不确定,已使用备用规则。",
|
||||
"notice.goalNotReady": "目标暂未达到完成条件,继续处理剩余工作。",
|
||||
"notice.goalUnfinished": "目标还有未完成的任务状态,继续处理。",
|
||||
"notice.autoresearchStatusFailed": "AutoResearch 状态更新失败。",
|
||||
"notice.autoresearchBlocked": "AutoResearch 任务已标记为阻塞。",
|
||||
"notice.backgroundJobFailed": "后台 {kind} 需要处理。",
|
||||
"notice.jobArtifactMigrationFailed": "任务产物迁移失败。",
|
||||
"notice.jobTeardownTimeout": "后台任务清理超时。",
|
||||
"notice.planModeToolSettingsIgnored": "部分规划模式工具设置已被忽略。",
|
||||
"notice.planModeCommandSettingsIgnored": "部分规划模式命令设置已被忽略。",
|
||||
"notice.configMigrationIncomplete": "配置迁移未完成。",
|
||||
"notice.modelMissingApiKey": "当前模型缺少 API key。",
|
||||
"notice.mcpServerFailed": "一个 MCP 服务启动失败。",
|
||||
"notice.mcpServersFailed": "部分 MCP 服务启动失败,可运行 /mcp 查看详情。",
|
||||
"notice.guardianModelMissing": "Guardian 已停用:未找到对应模型。",
|
||||
"notice.guardianStartFailed": "Guardian 启动失败,已停用。",
|
||||
"questionNav.label": "问题导航",
|
||||
"questionNav.progress": "问题 {current} / {total}",
|
||||
"questionNav.jump": "跳转到问题 {n}",
|
||||
@@ -1922,8 +1962,12 @@ export const zh: Record<DictKey, string> = {
|
||||
// 工具卡片摘要
|
||||
"tool.stepOne": "{n} 步",
|
||||
"tool.stepOther": "{n} 步",
|
||||
"tool.error": "错误",
|
||||
"tool.errorReceiptMismatch": "证据命令没有匹配的成功执行记录",
|
||||
"tool.truncated": "输出已截断",
|
||||
"tool.showAllLines": "显示全部 {n} 行",
|
||||
"tool.showErrorDetails": "显示错误详情",
|
||||
"tool.hideErrorDetails": "隐藏错误详情",
|
||||
"tool.readCount": "已读 {n} 个文件",
|
||||
"tool.searchCount": "搜索 {n} 个文件",
|
||||
"tool.otherReadCount": "{n} 个只读调用",
|
||||
|
||||
+213
-173
@@ -2665,27 +2665,6 @@ a[href] {
|
||||
color: var(--ok);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.banner--recovery {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: color-mix(in srgb, var(--warn) 9%, var(--bg-elev));
|
||||
color: color-mix(in srgb, var(--warn) 72%, var(--fg));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--warn) 22%, var(--border-soft));
|
||||
}
|
||||
.banner__badge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--warn) 13%, transparent);
|
||||
color: color-mix(in srgb, var(--warn) 84%, var(--fg));
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
.banner--update {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3310,6 +3289,20 @@ a[href] {
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.notice-line__details {
|
||||
margin-top: 4px;
|
||||
color: var(--fg-faint);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.notice-line__details summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.notice-line__details div {
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── ReadOnly batch (parent fold for read/search tools) ────────────── */
|
||||
.readonly-batch {
|
||||
@@ -5283,6 +5276,46 @@ body > .mermaid-diagram--fullscreen {
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.tool__err--compact {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0 0 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tool__err-summary {
|
||||
min-width: 0;
|
||||
}
|
||||
.tool__err-toggle {
|
||||
justify-self: start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.82;
|
||||
}
|
||||
.tool__err-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.tool__err-toggle-icon {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.tool__err-toggle-icon--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.tool__err-details {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--err) 18%, transparent);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
@@ -5431,13 +5464,59 @@ body > .mermaid-diagram--fullscreen {
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.composer-card--resized {
|
||||
height: var(--composer-height);
|
||||
/* --composer-height is always the user's logical height; the run strip's
|
||||
reservation lives in this calc so the live resize drag (which writes the
|
||||
raw logical height) and the React render path can never disagree. */
|
||||
height: calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.composer-card:focus-within {
|
||||
border-color: var(--fg-faint);
|
||||
}
|
||||
/* Run strip: an in-flow bar at the top of the card. For user-resized cards
|
||||
(fixed --composer-height), the JS adds the strip's reserved height so the
|
||||
meta row stays fully visible. For auto-height cards the card grows
|
||||
naturally. */
|
||||
.composer-run-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
padding: 4px 14px 0;
|
||||
color: var(--accent);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
}
|
||||
.composer-card--waiting .composer-run-strip {
|
||||
/* inherited from the card border tint; no extra rule needed */
|
||||
}
|
||||
.composer-run-strip__dot {
|
||||
flex: 0 0 auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.composer-run-strip__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.composer-run-strip--waiting {
|
||||
color: color-mix(in srgb, var(--warn) 74%, var(--fg));
|
||||
}
|
||||
.composer-run-strip--waiting .composer-run-strip__dot {
|
||||
animation: none;
|
||||
}
|
||||
/* Attention handoff: while a prompt card is waiting on the user, the card
|
||||
drops the accent running tint and takes a calm warn edge instead. */
|
||||
.composer-card--waiting {
|
||||
border-color: color-mix(in srgb, var(--warn) 30%, var(--border));
|
||||
}
|
||||
.composer-resize-handle {
|
||||
--wails-draggable: no-drag;
|
||||
appearance: none;
|
||||
@@ -5691,6 +5770,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
.composer__btn--stop {
|
||||
margin-right: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
|
||||
color: var(--err);
|
||||
}
|
||||
.composer__btn--stop:hover,
|
||||
.composer__btn--stop:focus-visible {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
|
||||
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
|
||||
}
|
||||
.composer__btn--stop:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.composer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -7208,6 +7303,16 @@ body > .mermaid-diagram--fullscreen {
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.approval-mode-hint {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 30%, var(--border-soft));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--warning, #f59e0b) 8%, var(--bg-soft));
|
||||
color: color-mix(in srgb, var(--warning, #f59e0b) 62%, var(--fg));
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.approval-reason {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
@@ -7389,14 +7494,14 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
.prompt-shelf--ask .prompt-shelf__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
max-height: 154px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
max-height: min(220px, 30vh);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.prompt-shelf--ask .prompt-action {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
padding: 5px 8px;
|
||||
@@ -7412,6 +7517,69 @@ body > .mermaid-diagram--fullscreen {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prompt-shelf--ask .prompt-shelf__quick-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-top: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.prompt-shelf--ask .prompt-shelf__quick-actions > .prompt-action {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.prompt-shelf__footnote {
|
||||
min-width: 0;
|
||||
}
|
||||
.ask-shelf__detail {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ask-shelf__detail-label {
|
||||
flex: 0 0 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg);
|
||||
font-weight: 620;
|
||||
}
|
||||
.ask-shelf__detail-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Tool-approval consequence preview: same quick-pick pattern as the ask
|
||||
detail row — pill buttons stay compact, the row spells out what the
|
||||
hovered/selected action will do. */
|
||||
.approval-consequence {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.approval-consequence__label {
|
||||
flex: 0 0 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg);
|
||||
font-weight: 620;
|
||||
}
|
||||
.approval-consequence__text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.prompt-shelf--ask .ask-shelf__custom-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -17291,21 +17459,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
/* ── composer run status ──────────────────────────────────────────────────── */
|
||||
.composer-toolbar {
|
||||
--wails-draggable: no-drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.composer-toolbar--status-only {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.composer-guidance-shelf {
|
||||
--wails-draggable: no-drag;
|
||||
display: grid;
|
||||
@@ -17462,79 +17615,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
}
|
||||
|
||||
.composer-runstatus {
|
||||
--wails-draggable: no-drag;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 5px 0 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 9%, var(--bg-elev));
|
||||
color: var(--accent);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent);
|
||||
}
|
||||
|
||||
.composer-runstatus__dot {
|
||||
flex: 0 0 auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.composer-runstatus__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.composer-runstatus__stop {
|
||||
--wails-draggable: no-drag;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
|
||||
color: var(--err);
|
||||
font: inherit;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.composer-runstatus__stop:hover,
|
||||
.composer-runstatus__stop:focus-visible {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
|
||||
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
|
||||
}
|
||||
.composer-runstatus__stop:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
color: var(--fg-faint);
|
||||
background: var(--button-bg);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
.composer-runstatus__stop:disabled:hover,
|
||||
.composer-runstatus__stop:disabled:focus-visible {
|
||||
color: var(--fg-faint);
|
||||
background: var(--button-bg);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.composer-context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -22767,10 +22847,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
padding: 5px 10px 10px 30px;
|
||||
}
|
||||
|
||||
.composer-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.composer-modebar {
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-elev-2) 72%, transparent);
|
||||
@@ -26845,41 +26921,29 @@ body > .mermaid-diagram--fullscreen {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app--creation .composer-toolbar,
|
||||
:root[data-theme-style] .app--creation .composer-toolbar {
|
||||
max-width: 880px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.app--creation .composer-toolbar--status-only,
|
||||
:root[data-theme-style] .app--creation .composer-toolbar--status-only {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus {
|
||||
max-width: min(100%, 340px);
|
||||
height: 28px;
|
||||
gap: 7px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-color: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-elev) 24%, transparent);
|
||||
.app--creation .composer-run-strip,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip {
|
||||
color: color-mix(in srgb, var(--accent) 88%, var(--fg-muted));
|
||||
font-size: 11.5px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__dot,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__dot {
|
||||
.app--creation .composer-run-strip__dot,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 48%, transparent);
|
||||
animation: creation-runstatus-dot-breathe 1.65s ease-in-out infinite;
|
||||
animation: creation-run-strip-dot-breathe 1.65s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes creation-runstatus-dot-breathe {
|
||||
.app--creation .composer-run-strip--waiting .composer-run-strip__dot,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__dot {
|
||||
animation: none;
|
||||
background: currentColor;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes creation-run-strip-dot-breathe {
|
||||
0%, 100% {
|
||||
opacity: 0.42;
|
||||
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
@@ -26891,46 +26955,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app--creation .composer-runstatus__dot,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__dot {
|
||||
.app--creation .composer-run-strip__dot,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip__dot {
|
||||
animation: none;
|
||||
opacity: 0.78;
|
||||
}
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__text,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__text {
|
||||
.app--creation .composer-run-strip__text,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip__text {
|
||||
color: color-mix(in srgb, var(--fg-muted) 72%, var(--accent));
|
||||
font-weight: 540;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__stop,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop {
|
||||
height: 22px;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
border-color: color-mix(in srgb, var(--err) 24%, transparent);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--err) 76%, var(--fg-muted));
|
||||
font-size: 11.5px;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__stop svg,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop svg {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__stop:hover,
|
||||
.app--creation .composer-runstatus__stop:focus-visible,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop:hover,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--err) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--err) 9%, transparent);
|
||||
color: color-mix(in srgb, var(--err) 90%, var(--fg));
|
||||
.app--creation .composer-run-strip--waiting .composer-run-strip__text,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__text {
|
||||
color: color-mix(in srgb, var(--warn) 64%, var(--fg-muted));
|
||||
}
|
||||
|
||||
.app--creation .composer-card,
|
||||
|
||||
+46
-4
@@ -57,6 +57,7 @@ type heartbeatConfig struct {
|
||||
type HeartbeatEngine struct {
|
||||
mu sync.Mutex
|
||||
tasks []HeartbeatTask
|
||||
cfgMod time.Time // config-file mtime as of the engine's last read/write (external-edit probe)
|
||||
pendingTopics map[string]heartbeatPendingTopic // in-memory retry/in-flight safety for NewConversationEachRun
|
||||
done chan struct{}
|
||||
running bool
|
||||
@@ -99,6 +100,28 @@ func (e *HeartbeatEngine) loadTasks() []HeartbeatTask {
|
||||
return cfg.Tasks
|
||||
}
|
||||
|
||||
// noteConfigModLocked records the config file's current mtime so the tick
|
||||
// external-edit probe does not re-read a file the engine itself just wrote.
|
||||
func (e *HeartbeatEngine) noteConfigModLocked() {
|
||||
if info, err := os.Stat(e.configPath()); err == nil {
|
||||
e.cfgMod = info.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
// adoptExternalEditsLocked re-reads the config when its mtime moved since the
|
||||
// engine last touched the file: heartbeat-tasks.json is documented as human-
|
||||
// and AI-editable, so an external edit should be scheduled by the next tick
|
||||
// rather than waiting for a manual Refresh or an app restart.
|
||||
func (e *HeartbeatEngine) adoptExternalEditsLocked() {
|
||||
info, err := os.Stat(e.configPath())
|
||||
if err != nil || info.ModTime().Equal(e.cfgMod) {
|
||||
return
|
||||
}
|
||||
e.cfgMod = info.ModTime()
|
||||
e.tasks = e.loadTasks()
|
||||
e.prunePendingTopicsLocked(e.tasks)
|
||||
}
|
||||
|
||||
// saveTasks writes tasks to disk atomically.
|
||||
func (e *HeartbeatEngine) saveTasks(tasks []HeartbeatTask) error {
|
||||
if tasks == nil {
|
||||
@@ -129,6 +152,7 @@ func (e *HeartbeatEngine) Start() {
|
||||
return
|
||||
}
|
||||
e.tasks = e.loadTasks()
|
||||
e.noteConfigModLocked()
|
||||
e.running = true
|
||||
go e.loop()
|
||||
log.Printf("[heartbeat] engine started (%d tasks)", len(e.tasks))
|
||||
@@ -160,10 +184,12 @@ func (e *HeartbeatEngine) loop() {
|
||||
}
|
||||
|
||||
// tick checks every enabled task and runs those whose interval has elapsed.
|
||||
// It merges results (topicId, lastRunAt) rather than replacing the full list,
|
||||
// so concurrent HeartbeatSaveTasks edits are not lost.
|
||||
// It first adopts any external edit to the config file (human/AI-editable),
|
||||
// then merges results (topicId, lastRunAt) rather than replacing the full
|
||||
// list, so concurrent HeartbeatSaveTasks edits are not lost.
|
||||
func (e *HeartbeatEngine) tick() {
|
||||
e.mu.Lock()
|
||||
e.adoptExternalEditsLocked()
|
||||
tasks := append([]HeartbeatTask(nil), e.tasks...)
|
||||
e.mu.Unlock()
|
||||
|
||||
@@ -374,6 +400,7 @@ func (e *HeartbeatEngine) ReloadTasks() []HeartbeatTask {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.tasks = e.loadTasks()
|
||||
e.noteConfigModLocked()
|
||||
e.prunePendingTopicsLocked(e.tasks)
|
||||
out := make([]HeartbeatTask, len(e.tasks))
|
||||
copy(out, e.tasks)
|
||||
@@ -386,7 +413,9 @@ func (e *HeartbeatEngine) ReplaceTasks(tasks []HeartbeatTask) error {
|
||||
defer e.mu.Unlock()
|
||||
e.tasks = tasks
|
||||
e.prunePendingTopicsLocked(tasks)
|
||||
return e.saveTasks(tasks)
|
||||
err := e.saveTasks(tasks)
|
||||
e.noteConfigModLocked()
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) prunePendingTopicsLocked(tasks []HeartbeatTask) {
|
||||
@@ -431,7 +460,18 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
|
||||
if len(updates) == 0 {
|
||||
return
|
||||
}
|
||||
tasks := append([]HeartbeatTask(nil), e.tasks...)
|
||||
// Rebase onto the on-disk list before the full-list save: the config file
|
||||
// is documented as human- and AI-editable, so an external edit may have
|
||||
// landed after the in-memory snapshot this tick ran from. The engine owns
|
||||
// only the run-state fields (TopicID, LastRunAt, CreatedAt backfill);
|
||||
// task definitions added, edited, or deleted externally are adopted from
|
||||
// disk, so the save below can never silently roll an external edit back.
|
||||
tasks := e.loadTasks()
|
||||
if tasks == nil {
|
||||
// Missing or unreadable file: fall back to the in-memory list so the
|
||||
// run state still lands (the historical behavior).
|
||||
tasks = append([]HeartbeatTask(nil), e.tasks...)
|
||||
}
|
||||
for i := range tasks {
|
||||
update, ok := updates[tasks[i].ID]
|
||||
if !ok {
|
||||
@@ -448,7 +488,9 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
|
||||
}
|
||||
}
|
||||
e.tasks = tasks
|
||||
e.prunePendingTopicsLocked(tasks)
|
||||
_ = e.saveTasks(tasks)
|
||||
e.noteConfigModLocked()
|
||||
}
|
||||
|
||||
// parseInterval converts a string like "5m", "1h", "30s" to time.Duration.
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -362,3 +363,73 @@ func TestHeartbeatInactiveOpenDoesNotChangeActiveTab(t *testing.T) {
|
||||
t.Fatalf("inactive open meta = %+v, want heartbeat and inactive", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatMergeRunUpdatesAdoptsExternalFileEdits(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := &HeartbeatEngine{
|
||||
tasks: []HeartbeatTask{
|
||||
{ID: "a", Title: "stale title", Prompt: "stale", Interval: "1h", Enabled: true},
|
||||
},
|
||||
}
|
||||
// An external editor (the documented human/AI flow) rewrote the file after
|
||||
// the engine's in-memory snapshot: task a was edited and task b was added.
|
||||
external := []HeartbeatTask{
|
||||
{ID: "a", Title: "edited externally", Prompt: "new prompt", Interval: "2h", Enabled: true},
|
||||
{ID: "b", Title: "added externally", Prompt: "hello", Interval: "1h", Enabled: false},
|
||||
}
|
||||
if err := engine.saveTasks(external); err != nil {
|
||||
t.Fatalf("seed external file: %v", err)
|
||||
}
|
||||
|
||||
engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{
|
||||
"a": {ID: "a", TopicID: "topic-a", LastRunAt: 4242},
|
||||
})
|
||||
|
||||
if len(engine.tasks) != 2 {
|
||||
t.Fatalf("tasks len = %d, want 2 (external addition adopted): %+v", len(engine.tasks), engine.tasks)
|
||||
}
|
||||
got := engine.tasks[0]
|
||||
if got.Title != "edited externally" || got.Prompt != "new prompt" || got.Interval != "2h" {
|
||||
t.Fatalf("external edit was rolled back by the run-state save: %+v", got)
|
||||
}
|
||||
if got.TopicID != "topic-a" || got.LastRunAt != 4242 {
|
||||
t.Fatalf("run state was not merged onto the disk copy: %+v", got)
|
||||
}
|
||||
// The full-list save must have preserved the externally added task on disk.
|
||||
onDisk := engine.loadTasks()
|
||||
if len(onDisk) != 2 || onDisk[1].ID != "b" || onDisk[1].Title != "added externally" {
|
||||
t.Fatalf("externally added task was lost on save: %+v", onDisk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatTickAdoptsExternalFileEdits(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := newHeartbeatEngine(nil)
|
||||
if err := engine.saveTasks([]HeartbeatTask{{ID: "a", Title: "A", Interval: "1h", Enabled: false}}); err != nil {
|
||||
t.Fatalf("seed file: %v", err)
|
||||
}
|
||||
engine.mu.Lock()
|
||||
engine.tasks = engine.loadTasks()
|
||||
engine.noteConfigModLocked()
|
||||
engine.mu.Unlock()
|
||||
|
||||
// External edit lands after the engine last touched the file. Force the
|
||||
// mtime forward so coarse filesystem timestamps cannot make this flaky.
|
||||
if err := engine.saveTasks([]HeartbeatTask{
|
||||
{ID: "a", Title: "A", Interval: "1h", Enabled: false},
|
||||
{ID: "b", Title: "added externally", Interval: "1h", Enabled: false},
|
||||
}); err != nil {
|
||||
t.Fatalf("external edit: %v", err)
|
||||
}
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(engine.configPath(), future, future); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
|
||||
engine.tick() // disabled tasks only: adoption runs, nothing executes
|
||||
|
||||
tasks := engine.ListTasks()
|
||||
if len(tasks) != 2 || tasks[1].ID != "b" {
|
||||
t.Fatalf("tick did not adopt the external edit: %+v", tasks)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func (a *App) SaveHooksSettingsForRoot(scope, projectRoot string, hooks []HookCo
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
cmd = hook.NormalizeCommand(cmd)
|
||||
settings.Hooks[event] = append(settings.Hooks[event], hook.HookConfig{
|
||||
Match: strings.TrimSpace(h.Match),
|
||||
Command: cmd,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -23,6 +24,12 @@ func TestMain(m *testing.M) {
|
||||
os.Setenv("REASONIX_STATE_HOME", dir+"/state")
|
||||
os.Setenv("REASONIX_CACHE_HOME", dir+"/cache")
|
||||
os.Setenv("AppData", dir)
|
||||
// Neutralize the Wails runtime-event bridge for the whole test binary:
|
||||
// outside a running Wails app, runtime.EventsEmit log.Fatals on the plain
|
||||
// contexts tests use, killing the process from any emitting code path.
|
||||
// Tests that assert on runtime events install their own capture through
|
||||
// the per-instance runtimeEvents.emit hook, which takes precedence.
|
||||
runtimeEventsEmitFallback = func(context.Context, string, ...interface{}) {}
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -116,6 +117,9 @@ func (a *App) MemorySuggestionsForTab(tabID string) MemorySuggestionsView {
|
||||
|
||||
sessions := loadSuggestionSessions(sessionDir, suggestionSessionLimit)
|
||||
view.Memories = suggestMemories(set, sessions)
|
||||
// Stable Memory v5 execution learnings join the same candidate list and
|
||||
// the same explicit-confirmation flow as history-derived suggestions.
|
||||
view.Memories = append(view.Memories, suggestCompilerMemories(workspaceRoot, set, view.Memories)...)
|
||||
view.Skills = suggestSkills(workspaceRoot, ctrl.AllSkills(), sessions)
|
||||
return view
|
||||
}
|
||||
@@ -137,7 +141,7 @@ func (a *App) AcceptMemorySuggestionForTab(tabID string, in MemorySuggestion) (s
|
||||
if desc == "" || body == "" {
|
||||
return "", fmt.Errorf("memory suggestion requires description and body")
|
||||
}
|
||||
name := suggestionName(in.Name, desc, "memory-candidate")
|
||||
name := acceptedSuggestionName(in.Name, desc)
|
||||
return ctrl.SaveMemory(memory.Memory{
|
||||
Name: name,
|
||||
Title: oneLine(in.Title),
|
||||
@@ -229,7 +233,7 @@ func suggestMemories(set *memory.Set, sessions []suggestionSession) []MemorySugg
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
name := suggestionName("", statement, fmt.Sprintf("memory-candidate-%d", len(out)+1))
|
||||
name := stableSuggestionName(statement, "memory-candidate")
|
||||
title := suggestionTitle(statement, "Memory candidate")
|
||||
typ := inferMemoryType(statement)
|
||||
out = append(out, MemorySuggestion{
|
||||
@@ -504,6 +508,66 @@ func suggestionName(given, source, fallback string) string {
|
||||
return "candidate"
|
||||
}
|
||||
|
||||
// acceptedSuggestionName preserves the candidate name generated at suggestion
|
||||
// time. Re-running asciiSlug here would truncate back to 56 chars and strip
|
||||
// the uniqueness hash suffix, re-colliding long common-prefix candidates at
|
||||
// save time even though their generated Name/ID differed. A well-formed slug
|
||||
// is kept verbatim (memory.Store.Save's own slug pass cleans but never
|
||||
// truncates); anything else falls back to deriving from the description as
|
||||
// before.
|
||||
func acceptedSuggestionName(given, desc string) string {
|
||||
if isWellFormedSlug(given) {
|
||||
return given
|
||||
}
|
||||
return suggestionName("", desc, "memory-candidate")
|
||||
}
|
||||
|
||||
// isWellFormedSlug reports whether s already matches asciiSlug's output shape
|
||||
// (lowercase ASCII letters/digits separated by single dashes), possibly with a
|
||||
// hash suffix beyond asciiSlug's 56-char cap.
|
||||
func isWellFormedSlug(s string) bool {
|
||||
if s == "" || len(s) > 128 || s[0] == '-' || s[len(s)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
prevDash := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
prevDash = false
|
||||
case r == '-':
|
||||
if prevDash {
|
||||
return false
|
||||
}
|
||||
prevDash = true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stableSuggestionName returns a slug that is unique per source text and stable
|
||||
// across suggestion refreshes. asciiSlug drops non-ASCII runes and truncates to
|
||||
// 56 chars, so two CJK-only statements (or long English statements sharing a
|
||||
// prefix) can collide — colliding Names make Store.Save overwrite the earlier
|
||||
// memory, and colliding IDs cross-wire the frontend's accepted-state map.
|
||||
//
|
||||
// When the ASCII slug is short enough that truncation cannot have caused a
|
||||
// collision, it is returned as-is for backward compatibility with old-version
|
||||
// candidate names. The hash suffix is only appended when the slug fell back to
|
||||
// the fallback (non-ASCII source) or when the slug hit the 56-char truncation
|
||||
// boundary.
|
||||
func stableSuggestionName(source, fallback string) string {
|
||||
slug := asciiSlug(source)
|
||||
if slug != "" && len(slug) < 56 {
|
||||
return slug
|
||||
}
|
||||
base := suggestionName("", source, fallback)
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(source))
|
||||
return fmt.Sprintf("%s-%08x", base, h.Sum32())
|
||||
}
|
||||
|
||||
func asciiSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/memorycompiler"
|
||||
)
|
||||
|
||||
const (
|
||||
// compilerSuggestionLimit bounds Memory v5 candidates separately from the
|
||||
// history-derived ones so a busy history cannot crowd them out.
|
||||
compilerSuggestionLimit = 3
|
||||
// compilerSuggestionMinCount requires a pattern to recur across at least
|
||||
// this many separate turns before it is stable enough to suggest.
|
||||
compilerSuggestionMinCount = 2
|
||||
)
|
||||
|
||||
// suggestCompilerMemories converts stable Memory v5 execution learnings into
|
||||
// memory candidates for the settings Memory page. Read-only: candidates are
|
||||
// only persisted through the regular AcceptMemorySuggestion confirmation flow.
|
||||
func suggestCompilerMemories(workspaceRoot string, set *memory.Set, already []MemorySuggestion) []MemorySuggestion {
|
||||
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
|
||||
if rt == nil || set == nil {
|
||||
return nil
|
||||
}
|
||||
existing := existingMemoryText(set)
|
||||
seen := map[string]bool{}
|
||||
for _, s := range already {
|
||||
seen[normalizeSuggestionKey(s.Description)] = true
|
||||
}
|
||||
var out []MemorySuggestion
|
||||
for _, p := range rt.StableNoisePatterns(compilerSuggestionMinCount, compilerSuggestionLimit*2) {
|
||||
statement := compilerPatternStatement(p.Pattern)
|
||||
key := normalizeSuggestionKey(statement)
|
||||
if key == "" || seen[key] || existingCovers(existing, key) {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
name := compilerCandidateName(p.Pattern)
|
||||
out = append(out, MemorySuggestion{
|
||||
ID: "memory-" + name,
|
||||
Name: name,
|
||||
Title: suggestionTitle(statement, "Memory v5 pattern"),
|
||||
Description: oneLine(statement),
|
||||
Type: string(memory.TypeProject),
|
||||
Body: compilerCandidateBody(statement, p.Count),
|
||||
Reason: fmt.Sprintf("Memory v5 observed this failure pattern in %d separate turns", p.Count),
|
||||
Evidence: []string{fmt.Sprintf("memory-v5 execution traces: %s (x%d)", truncateRunes(p.Pattern, 160), p.Count)},
|
||||
})
|
||||
if len(out) >= compilerSuggestionLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compilerCandidateName always appends a hash: compiler patterns are
|
||||
// inherently homogeneous (same tool + similar error prefix) so the ASCII
|
||||
// slug alone cannot distinguish them even when it is non-empty.
|
||||
func compilerCandidateName(pattern string) string {
|
||||
base := suggestionName("", "memory-v5 "+pattern, "memory-v5-pattern")
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(pattern))
|
||||
return fmt.Sprintf("%s-%08x", base, h.Sum32())
|
||||
}
|
||||
|
||||
func compilerPatternStatement(pattern string) string {
|
||||
pattern = oneLine(pattern)
|
||||
if pattern == "" {
|
||||
return ""
|
||||
}
|
||||
return "Known repeated failure in this workspace: " + pattern + "."
|
||||
}
|
||||
|
||||
func compilerCandidateBody(statement string, count int) string {
|
||||
return statement + "\n\n" +
|
||||
fmt.Sprintf("**Why:** Memory v5 recorded this failure pattern in %d separate execution traces for this workspace.\n", count) +
|
||||
"**How to apply:** Address the known cause before retrying the same command; drop this memory once the failure stops reproducing.\n"
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/memorycompiler"
|
||||
)
|
||||
|
||||
func seedCompilerFailures(t *testing.T, workspaceRoot string, turns int, errText string) {
|
||||
t.Helper()
|
||||
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
|
||||
if rt == nil {
|
||||
t.Fatal("memory compiler dir did not resolve under isolated dirs")
|
||||
}
|
||||
for i := 0; i < turns; i++ {
|
||||
_, turn := rt.StartTurn(context.Background(), "fix a bug", nil)
|
||||
turn.RecordToolResults([]memorycompiler.ToolRecord{
|
||||
{Name: "bash", Error: errText},
|
||||
{Name: "bash", Error: errText},
|
||||
})
|
||||
turn.Finish(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySuggestionsIncludeCompilerCandidates(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
var candidate MemorySuggestion
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
candidate = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if candidate.ID == "" {
|
||||
t.Fatalf("memories = %+v, want a memory-v5 candidate", view.Memories)
|
||||
}
|
||||
if candidate.Type != string(memory.TypeProject) {
|
||||
t.Fatalf("candidate type = %q, want project", candidate.Type)
|
||||
}
|
||||
if !strings.Contains(candidate.Description, "cannot find module") {
|
||||
t.Fatalf("candidate description missing pattern: %q", candidate.Description)
|
||||
}
|
||||
if !strings.Contains(candidate.Reason, "Memory v5") {
|
||||
t.Fatalf("candidate reason missing provenance: %q", candidate.Reason)
|
||||
}
|
||||
|
||||
path, err := app.AcceptMemorySuggestion(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion: %v", err)
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatal("AcceptMemorySuggestion returned empty path")
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != 1 || !strings.Contains(saved[0].Body, "cannot find module") {
|
||||
t.Fatalf("saved memories = %+v, want compiler candidate body", saved)
|
||||
}
|
||||
|
||||
// Once accepted, the same pattern is covered by an existing memory and
|
||||
// must not be suggested again.
|
||||
again := app.MemorySuggestions()
|
||||
for _, item := range again.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
t.Fatalf("accepted pattern suggested again: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilerCandidatesRequireStablePattern(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
// A pattern seen in only one turn is not stable enough to suggest.
|
||||
seedCompilerFailures(t, cwd, 1, "cannot find module providing package foo")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
t.Fatalf("unstable pattern was suggested: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompilerCandidateNamesUniqueForSimilarPatterns covers the review
|
||||
// finding: asciiSlug drops non-ASCII runes and truncates to 56 chars, so
|
||||
// patterns differing only in CJK text (or sharing a long English prefix) used
|
||||
// to collide on Name/ID — colliding IDs cross-wire the frontend's accepted
|
||||
// state and colliding Names make Store.Save overwrite the earlier memory.
|
||||
func TestCompilerCandidateNamesUniqueForSimilarPatterns(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
// Same tool, errors differ only in CJK text: identical after asciiSlug.
|
||||
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块甲")
|
||||
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块乙")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
var candidates []MemorySuggestion
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
candidates = append(candidates, item)
|
||||
}
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates = %+v, want both CJK-only-diff patterns", candidates)
|
||||
}
|
||||
if candidates[0].Name == candidates[1].Name || candidates[0].ID == candidates[1].ID {
|
||||
t.Fatalf("similar patterns collided on Name/ID: %q vs %q", candidates[0].Name, candidates[1].Name)
|
||||
}
|
||||
|
||||
// Accepting both must persist two distinct memories, not overwrite one.
|
||||
for _, c := range candidates {
|
||||
if _, err := app.AcceptMemorySuggestion(c); err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
|
||||
}
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != 2 {
|
||||
t.Fatalf("saved memories = %d (%+v), want 2 distinct files", len(saved), saved)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompilerCandidateNamesStableAcrossRefreshes: the hash suffix must be
|
||||
// derived from the pattern, not from ordering or randomness, so a refresh
|
||||
// keeps the same ID and the frontend's accepted-state map stays valid.
|
||||
func TestCompilerCandidateNamesStableAcrossRefreshes(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
first := app.MemorySuggestions()
|
||||
second := app.MemorySuggestions()
|
||||
firstIDs := compilerCandidateIDs(first.Memories)
|
||||
secondIDs := compilerCandidateIDs(second.Memories)
|
||||
if len(firstIDs) == 0 || strings.Join(firstIDs, ",") != strings.Join(secondIDs, ",") {
|
||||
t.Fatalf("candidate IDs changed across refreshes: %v vs %v", firstIDs, secondIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func compilerCandidateIDs(memories []MemorySuggestion) []string {
|
||||
var out []string
|
||||
for _, item := range memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
out = append(out, item.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestCompilerCandidateLongPrefixNamesPersistDistinctly reproduces the review
|
||||
// finding: two patterns sharing a >56-char ASCII prefix get distinct generated
|
||||
// Name/ID (hash suffix), but the accept path used to re-run asciiSlug, which
|
||||
// truncates to 56 chars and strips the hash — so both saved to one file.
|
||||
func TestCompilerCandidateLongPrefixNamesPersistDistinctly(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
prefix := "cannot find module providing package example dot com slash very long prefix "
|
||||
seedCompilerFailures(t, cwd, 2, prefix+"alpha")
|
||||
seedCompilerFailures(t, cwd, 2, prefix+"beta")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
var candidates []MemorySuggestion
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
candidates = append(candidates, item)
|
||||
}
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates = %+v, want both long-prefix patterns", candidates)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := app.AcceptMemorySuggestion(c); err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
|
||||
}
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != 2 {
|
||||
t.Fatalf("saved memories = %d, want 2 distinct files (accept path truncated the hash suffix)", len(saved))
|
||||
}
|
||||
if saved[0].Name == saved[1].Name {
|
||||
t.Fatalf("saved names collided: %q", saved[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -210,3 +210,129 @@ func writeSuggestionSession(t *testing.T, dir, name string, messages ...provider
|
||||
t.Fatalf("save session %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryEnglishCandidateNameBackwardCompat: an English statement whose
|
||||
// asciiSlug is short (<56 chars) must produce the same Name as old code
|
||||
// (plain slug, no hash suffix), so an already-accepted memory under the old
|
||||
// Name is not duplicated after upgrade.
|
||||
func TestHistoryEnglishCandidateNameBackwardCompat(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
writeSuggestionSession(t, sessionDir, "en.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "Always prefer English for code comments."},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "Got it."},
|
||||
)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
if len(view.Memories) == 0 {
|
||||
t.Fatalf("no candidates")
|
||||
}
|
||||
// Old code: suggestionName("", statement, "memory-candidate-1") = asciiSlug(statement)
|
||||
oldName := asciiSlug("Always prefer English for code comments.")
|
||||
if view.Memories[0].Name != oldName {
|
||||
t.Fatalf("Name = %q, want old-compatible %q (no hash suffix for short ASCII slugs)", view.Memories[0].Name, oldName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryMemoryCandidateNamesUniqueForCJK: two pure-CJK statements that
|
||||
// differ in content but produce the same empty asciiSlug must still get
|
||||
// distinct Name/ID. Without the hash suffix they would both fall back to
|
||||
// "memory-candidate-<ordinal>" — but ordinals depend on iteration order and
|
||||
// wouldn't survive refresh, and Store.Save overwrites by name.
|
||||
func TestHistoryMemoryCandidateNamesUniqueForCJK(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
// Two pure-CJK "always" statements that pass extractMemoryStatement but
|
||||
// share the exact same empty asciiSlug.
|
||||
writeSuggestionSession(t, sessionDir, "zh-a.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "以后始终使用甲方案处理合并冲突。"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
|
||||
)
|
||||
writeSuggestionSession(t, sessionDir, "zh-b.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "以后始终使用乙方案处理部署回滚。"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
|
||||
)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
if len(view.Memories) < 2 {
|
||||
t.Fatalf("memories = %+v, want at least 2 CJK candidates", view.Memories)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
ids := map[string]bool{}
|
||||
for _, m := range view.Memories {
|
||||
if names[m.Name] {
|
||||
t.Fatalf("duplicate Name %q among history candidates", m.Name)
|
||||
}
|
||||
if ids[m.ID] {
|
||||
t.Fatalf("duplicate ID %q among history candidates", m.ID)
|
||||
}
|
||||
names[m.Name] = true
|
||||
ids[m.ID] = true
|
||||
}
|
||||
|
||||
// Accept both → two distinct persisted memories.
|
||||
for _, c := range view.Memories {
|
||||
if _, err := app.AcceptMemorySuggestion(c); err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
|
||||
}
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != len(view.Memories) {
|
||||
t.Fatalf("saved %d memories, want %d (Name collision caused overwrite)", len(saved), len(view.Memories))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryMemoryCandidateNamesStableAcrossRefreshes: the hash suffix must
|
||||
// be derived from the statement, not from iteration order or random state, so
|
||||
// a refresh keeps the same ID and the frontend's accepted-state map stays valid.
|
||||
func TestHistoryMemoryCandidateNamesStableAcrossRefreshes(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
writeSuggestionSession(t, sessionDir, "pref.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复,除非我明确要求英文。"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
|
||||
)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
first := app.MemorySuggestions()
|
||||
second := app.MemorySuggestions()
|
||||
if len(first.Memories) == 0 || len(first.Memories) != len(second.Memories) {
|
||||
t.Fatalf("memories counts differ across refreshes: %d vs %d", len(first.Memories), len(second.Memories))
|
||||
}
|
||||
for i := range first.Memories {
|
||||
if first.Memories[i].ID != second.Memories[i].ID || first.Memories[i].Name != second.Memories[i].Name {
|
||||
t.Fatalf("refresh changed candidate #%d: %q/%q → %q/%q",
|
||||
i, first.Memories[i].Name, first.Memories[i].ID,
|
||||
second.Memories[i].Name, second.Memories[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func (m *metricsAggregator) observe(e event.Event) {
|
||||
case event.MemoryCompilerStatsEvent:
|
||||
m.observeMemoryCompilerStats(e.MemoryCompiler)
|
||||
case event.Notice:
|
||||
if strings.HasPrefix(e.Text, "empty final answer blocked") {
|
||||
if e.Text == "No visible answer was produced; asking the assistant to respond again." || strings.HasPrefix(e.Detail, "empty final answer blocked") {
|
||||
m.inc("empty_final", "total")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestObserveClassifiesEvents(t *testing.T) {
|
||||
StrategyCount: 3,
|
||||
LearningCount: 6,
|
||||
}},
|
||||
{Kind: event.Notice, Text: "empty final answer blocked: model returned no visible answer text; retrying"},
|
||||
{Kind: event.Notice, Text: "No visible answer was produced; asking the assistant to respond again.", Detail: "empty final answer blocked: model returned no visible answer text; retrying"},
|
||||
{Kind: event.TurnDone, Err: errors.New("deepseek-flash: status 429: rate limited")},
|
||||
{Kind: event.TurnDone},
|
||||
}
|
||||
|
||||
@@ -86,10 +86,11 @@ func recoveryGCDirs() []string {
|
||||
seen := map[string]bool{}
|
||||
var dirs []string
|
||||
add := func(dir string) {
|
||||
if dir == "" || seen[dir] {
|
||||
key := projectRootKey(dir)
|
||||
if dir == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[dir] = true
|
||||
seen[key] = true
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
add(desktopSessionDir(globalWorkspaceRoot()))
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// TestRuntimeRebuildsEmitRuntimeRebuiltForTab pins the frontend contract the
|
||||
// prompt-chime dedupe depends on: every in-place controller replacement must
|
||||
// announce itself. Model, effort, and token-mode switches rebuild the tab's
|
||||
// controller WITHOUT an agent:ready — the rebuilt controller restarts its
|
||||
// approval/ask id counter at "1", so without runtime:rebuilt the frontend's
|
||||
// id-keyed chime dedupe mutes the first prompt after a switch.
|
||||
func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old", "new"}
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
|
||||
{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "deepseek-v4-pro", APIKeyEnv: "NEW_MODEL_KEY"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
sess := agent.NewSession("sys")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
||||
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
|
||||
path := filepath.Join(dir, "rebuild-events.jsonl")
|
||||
ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
// emitReady calls the Wails runtime directly; the ready hook keeps the
|
||||
// workspace-reconcile path (which SetEffortForTab can take) off the real
|
||||
// event bridge, which log.Fatals on a plain Background context.
|
||||
app.readyHook = func() {}
|
||||
|
||||
var mu sync.Mutex
|
||||
var rebuilt []string
|
||||
// The App-level queue must stay silent: ordering against the tab's agent
|
||||
// events only holds when the notice rides the tab sink's own queue, so a
|
||||
// notice showing up here means the routing regressed to the fallback.
|
||||
app.runtimeEvents.emit = func(_ context.Context, name string, _ ...interface{}) {
|
||||
if name == "runtime:rebuilt" {
|
||||
mu.Lock()
|
||||
rebuilt = append(rebuilt, "VIA-APP-QUEUE")
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
sinkEmit := func(_ context.Context, name string, payload ...interface{}) {
|
||||
if name != "runtime:rebuilt" {
|
||||
return
|
||||
}
|
||||
tabID := ""
|
||||
if len(payload) > 0 {
|
||||
tabID, _ = payload[0].(string)
|
||||
}
|
||||
mu.Lock()
|
||||
rebuilt = append(rebuilt, tabID)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_rebuild_events",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalTabWorkspaceRoot(),
|
||||
Ready: true,
|
||||
model: "old/old-model",
|
||||
Ctrl: ctrl,
|
||||
sink: &tabEventSink{tabID: "tab_rebuild_events", app: app, ctx: context.Background()},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink.runtimeEvents.emit = sinkEmit
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
})
|
||||
|
||||
waitCount := func(want int, step string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(rebuilt)
|
||||
mu.Unlock()
|
||||
if n >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
t.Fatalf("after %s: runtime:rebuilt events = %v, want %d", step, rebuilt, want)
|
||||
}
|
||||
|
||||
if err := app.SetModelForTab(tab.ID, "new/deepseek-v4-pro"); err != nil {
|
||||
t.Fatalf("SetModelForTab: %v", err)
|
||||
}
|
||||
waitCount(1, "model switch")
|
||||
|
||||
if err := app.SetEffortForTab(tab.ID, "high"); err != nil {
|
||||
t.Fatalf("SetEffortForTab: %v", err)
|
||||
}
|
||||
waitCount(2, "effort switch")
|
||||
|
||||
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
waitCount(3, "token-mode switch")
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, id := range rebuilt {
|
||||
if id == "VIA-APP-QUEUE" {
|
||||
t.Fatalf("event %d took the App-level fallback queue; it must ride the tab sink queue so it orders before the rebuilt controller's agent events (full: %v)", i, rebuilt)
|
||||
}
|
||||
if id != tab.ID {
|
||||
t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,7 @@ func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact {
|
||||
{src: store.SessionGoalState(sessionPath), name: stem + ".goal-state.json"},
|
||||
{src: store.SessionEventLog(sessionPath), name: stem + ".events.jsonl"},
|
||||
{src: store.SessionEventIndex(sessionPath), name: stem + ".event-index.json"},
|
||||
{src: store.SessionConflictLog(sessionPath), name: stem + ".conflicts.jsonl"},
|
||||
{src: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"},
|
||||
{src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"},
|
||||
{src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"},
|
||||
|
||||
@@ -170,6 +170,8 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
os.WriteFile(eventLogPath, []byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"event"}]}`+"\n"), 0o644)
|
||||
eventIndexPath := store.SessionEventIndex(sessionPath)
|
||||
os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644)
|
||||
conflictLogPath := store.SessionConflictLog(sessionPath)
|
||||
os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644)
|
||||
telemetryPath := sessionTelemetryPath(sessionPath)
|
||||
os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644)
|
||||
lockPath := store.SessionLockFile(sessionPath)
|
||||
@@ -205,6 +207,7 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
trashGoalPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.goal-state.json")
|
||||
trashEventLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.events.jsonl")
|
||||
trashEventIndexPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.event-index.json")
|
||||
trashConflictLogPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.conflicts.jsonl")
|
||||
trashTelemetryPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl.telemetry.json")
|
||||
trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
|
||||
trashJobsDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jobs")
|
||||
@@ -225,6 +228,9 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(eventIndexPath); !os.IsNotExist(err) {
|
||||
t.Error("session event index should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(conflictLogPath); !os.IsNotExist(err) {
|
||||
t.Error("session conflict log should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(telemetryPath); !os.IsNotExist(err) {
|
||||
t.Error("session telemetry should be removed from active sessions")
|
||||
}
|
||||
@@ -258,6 +264,9 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(trashEventIndexPath); err != nil {
|
||||
t.Fatalf("session event index should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashConflictLogPath); err != nil {
|
||||
t.Fatalf("session conflict log should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashTelemetryPath); err != nil {
|
||||
t.Fatalf("session telemetry should be in trash: %v", err)
|
||||
}
|
||||
@@ -493,6 +502,10 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
|
||||
if err := os.WriteFile(eventIndexPath, []byte(`{"schema_version":1,"message_count":1}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conflictLogPath := store.SessionConflictLog(sessionPath)
|
||||
if err := os.WriteFile(conflictLogPath, []byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
telemetryPath := sessionTelemetryPath(sessionPath)
|
||||
if err := os.WriteFile(telemetryPath, []byte(`{"version":2,"readFiles":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -538,6 +551,9 @@ func TestRestoreTrashedSessionFile(t *testing.T) {
|
||||
if _, err := os.Stat(eventIndexPath); err != nil {
|
||||
t.Fatalf("session event index should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(conflictLogPath); err != nil {
|
||||
t.Fatalf("session conflict log should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(telemetryPath); err != nil {
|
||||
t.Fatalf("session telemetry should be restored: %v", err)
|
||||
}
|
||||
@@ -835,6 +851,7 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
store.SessionGoalState(sessionPath),
|
||||
store.SessionEventLog(sessionPath),
|
||||
store.SessionEventIndex(sessionPath),
|
||||
store.SessionConflictLog(sessionPath),
|
||||
sessionTelemetryPath(sessionPath),
|
||||
store.SessionLockFile(sessionPath),
|
||||
store.SessionLeaseLock(sessionPath),
|
||||
@@ -872,6 +889,7 @@ func TestRemoveDesktopSessionArtifactsRemovesOwnedSidecars(t *testing.T) {
|
||||
store.SessionGoalState(sessionPath),
|
||||
store.SessionEventLog(sessionPath),
|
||||
store.SessionEventIndex(sessionPath),
|
||||
store.SessionConflictLog(sessionPath),
|
||||
sessionTelemetryPath(sessionPath),
|
||||
store.SessionLockFile(sessionPath),
|
||||
store.SessionLeaseLock(sessionPath),
|
||||
|
||||
@@ -1563,10 +1563,14 @@ func (a *App) rebuildSettingLocked(setting string) error {
|
||||
})
|
||||
if err != nil {
|
||||
if oldCtrl == nil {
|
||||
leaseHeld := false
|
||||
a.mu.Lock()
|
||||
tab.StartupErr = err.Error()
|
||||
leaseHeld = setTabStartupError(tab, err)
|
||||
tab.Ready = true
|
||||
a.mu.Unlock()
|
||||
if leaseHeld {
|
||||
a.scheduleDeferredStartupBuild(tab.ID)
|
||||
}
|
||||
a.emitReady(a.ctx)
|
||||
}
|
||||
return err
|
||||
@@ -1601,7 +1605,7 @@ func (a *App) rebuildSettingLocked(setting string) error {
|
||||
tab.Ctrl = ctrl
|
||||
tab.model = model
|
||||
tab.Label = ctrl.Label()
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = true
|
||||
// Supersede any in-flight startup build: it would otherwise finish later,
|
||||
// pass its generation check, and overwrite the controller just installed.
|
||||
@@ -2379,7 +2383,7 @@ func (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
tab.model = fallbackRef
|
||||
tab.Label = fallbackRef
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = a.ctx == nil
|
||||
if a.ctx != nil {
|
||||
rebuildTabs = append(rebuildTabs, tab)
|
||||
@@ -2498,7 +2502,7 @@ func (a *App) deleteProviderAndRetargetTabs(name string) error {
|
||||
a.supersedeTabBuildLocked(tab)
|
||||
tab.model = fallbackRef
|
||||
tab.Label = fallbackRef
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
tab.Ready = a.ctx == nil
|
||||
if a.ctx != nil {
|
||||
rebuildTabs = append(rebuildTabs, tab)
|
||||
|
||||
@@ -1137,6 +1137,30 @@ func TestSaveHooksSettingsPreservesUnknownSettingsKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveHooksSettingsNormalizesQuotedNodeEvalHookCommand(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
|
||||
bad := `node -e "\"` + script + `\""`
|
||||
want := hook.NormalizeCommand(bad)
|
||||
if want == bad {
|
||||
t.Fatal("test command did not normalize")
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
if err := app.SaveHooksSettings("global", []HookConfigView{{
|
||||
Event: string(hook.PreToolUse),
|
||||
Match: "bash",
|
||||
Command: bad,
|
||||
}}); err != nil {
|
||||
t.Fatalf("SaveHooksSettings: %v", err)
|
||||
}
|
||||
|
||||
view := app.HooksSettings("global")
|
||||
if len(view.Hooks) != 1 || view.Hooks[0].Command != want {
|
||||
t.Fatalf("HooksSettings = %+v, want normalized command %q", view.Hooks, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectHooksSettingsUseActiveWorkspaceRootAndTrust(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
project := t.TempDir()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// TestRestoredTabGoalFollowsSessionGoalState pins the restart half of the
|
||||
// goal-reset contract: a typed /new or /clear rotates the session through the
|
||||
// controller (never touching App.NewSession/ClearSession), so the goal saved
|
||||
// in desktop-tabs.json can be stale. Startup restore must validate it against
|
||||
// the session's goal-state sidecar — which the rotation wrote as stopped on
|
||||
// the fresh path — instead of re-seeding the cleared goal via SetGoal.
|
||||
func TestRestoredTabGoalFollowsSessionGoalState(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "session.jsonl")
|
||||
|
||||
exec := agent.New(stubProvider{}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: oldPath, Label: "test", Sink: event.Discard})
|
||||
ctrl.SetGoal("finish the migration")
|
||||
|
||||
// Typed /new: controller-side rotation only.
|
||||
if err := ctrl.NewSession(); err != nil {
|
||||
t.Fatalf("NewSession: %v", err)
|
||||
}
|
||||
newPath := ctrl.SessionPath()
|
||||
ctrl.Close()
|
||||
|
||||
// desktop-tabs.json still carries the pre-rotation goal with the rotated
|
||||
// path (the tab profile was never told about the /new). Restore must not
|
||||
// resurrect it: the rotated session's sidecar says stopped.
|
||||
if got := runningTabSessionGoal(newPath, "finish the migration"); got != "" {
|
||||
t.Fatalf("restored goal for rotated session = %q, want empty", got)
|
||||
}
|
||||
|
||||
// The OLD session's sidecar still says running: restoring a tab that
|
||||
// points at the old session keeps its goal (resume semantics).
|
||||
if got := runningTabSessionGoal(oldPath, "finish the migration"); got != "finish the migration" {
|
||||
t.Fatalf("restored goal for old session = %q, want preserved", got)
|
||||
}
|
||||
|
||||
// A session without a goal-state sidecar keeps the persisted goal
|
||||
// (legacy sessions predating the sidecar).
|
||||
bare := filepath.Join(dir, "bare.jsonl")
|
||||
if err := os.WriteFile(bare, []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("write bare session: %v", err)
|
||||
}
|
||||
if got := runningTabSessionGoal(bare, "legacy goal"); got != "legacy goal" {
|
||||
t.Fatalf("restored goal without sidecar = %q, want fallback preserved", got)
|
||||
}
|
||||
}
|
||||
+297
-112
@@ -39,25 +39,26 @@ import (
|
||||
// memory, permissions) scoped to a workspace root, so multiple projects and
|
||||
// topics can be active concurrently without interfering.
|
||||
type WorkspaceTab struct {
|
||||
ID string // stable random id
|
||||
Scope string // "project" | "global"
|
||||
WorkspaceRoot string // project root dir (empty for global)
|
||||
SharedHostKey string // opaque key for the shared plugin host (set by buildTabController)
|
||||
TopicID string // topic within the project
|
||||
TopicTitle string // display title
|
||||
SessionPath string // exact .jsonl file this tab continues
|
||||
ReadOnly bool // true for external channel transcripts opened for browsing
|
||||
Ctrl control.SessionAPI // nil while booting / on error
|
||||
Label string // model label (for the tab badge)
|
||||
Ready bool // true once boot.Build completes
|
||||
StartupErr string // build error, surfaced to the frontend
|
||||
sessionLease *agent.SessionLease
|
||||
sessionLeaseMu sync.Mutex
|
||||
sink *tabEventSink // routes events with this tab's ID
|
||||
buildCancel context.CancelFunc // cancels in-flight boot for tabs removed before Ready
|
||||
buildGeneration uint64 // identifies the current in-flight build
|
||||
removed bool // set when the visible tab is pruned/closed before build completes
|
||||
reconcileMu sync.Mutex // serializes stale controller workspace repair for this tab
|
||||
ID string // stable random id
|
||||
Scope string // "project" | "global"
|
||||
WorkspaceRoot string // project root dir (empty for global)
|
||||
SharedHostKey string // opaque key for the shared plugin host (set by buildTabController)
|
||||
TopicID string // topic within the project
|
||||
TopicTitle string // display title
|
||||
SessionPath string // exact .jsonl file this tab continues
|
||||
ReadOnly bool // true for external channel transcripts opened for browsing
|
||||
Ctrl control.SessionAPI // nil while booting / on error
|
||||
Label string // model label (for the tab badge)
|
||||
Ready bool // true once boot.Build completes
|
||||
StartupErr string // build error, surfaced to the frontend
|
||||
StartupErrLeaseHeld bool // true when StartupErr can be retried after a session lease releases
|
||||
sessionLease *agent.SessionLease
|
||||
sessionLeaseMu sync.Mutex
|
||||
sink *tabEventSink // routes events with this tab's ID
|
||||
buildCancel context.CancelFunc // cancels in-flight boot for tabs removed before Ready
|
||||
buildGeneration uint64 // identifies the current in-flight build
|
||||
removed bool // set when the visible tab is pruned/closed before build completes
|
||||
reconcileMu sync.Mutex // serializes stale controller workspace repair for this tab
|
||||
|
||||
ActivityStatus string // transient project-tree status for the in-flight turn
|
||||
|
||||
@@ -479,6 +480,7 @@ func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab
|
||||
Label: tab.Label,
|
||||
Ready: tab.Ready,
|
||||
StartupErr: tab.StartupErr,
|
||||
StartupErrLeaseHeld: tab.StartupErrLeaseHeld,
|
||||
sink: tab.sink,
|
||||
ActivityStatus: tab.ActivityStatus,
|
||||
readTelemetry: readTelemetry,
|
||||
@@ -569,7 +571,7 @@ func applyRuntimeTab(target, source *WorkspaceTab, path string, wailsCtx context
|
||||
target.SharedHostKey = source.SharedHostKey
|
||||
target.Label = source.Label
|
||||
target.Ready = true
|
||||
target.StartupErr = ""
|
||||
clearTabStartupError(target)
|
||||
target.ActivityStatus = source.ActivityStatus
|
||||
target.model = source.model
|
||||
target.effort = cloneStringPtr(source.effort)
|
||||
@@ -864,7 +866,7 @@ func (t *WorkspaceTab) recordPlannerDisplayEvent(e event.Event) {
|
||||
if e.Level == event.LevelWarn {
|
||||
level = "warn"
|
||||
}
|
||||
t.plannerDisplay = append(t.plannerDisplay, HistoryMessage{Role: "notice", Level: level, Content: e.Text})
|
||||
t.plannerDisplay = append(t.plannerDisplay, HistoryMessage{Role: "notice", Level: level, Content: e.Text, Detail: e.Detail})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1057,6 +1059,13 @@ type runtimeEventEnvelope struct {
|
||||
// emission. runtime.EventsEmit can block when the single webview event channel
|
||||
// backs up; callers enqueue in-order work and return without holding the
|
||||
// agent's event.Sync lock.
|
||||
// runtimeEventsEmitFallback is the emit used when no per-instance override is
|
||||
// installed. Production keeps the real Wails bridge; the test binary swaps in
|
||||
// a no-op via TestMain, because Wails EventsEmit log.Fatals outside a running
|
||||
// Wails app and would kill the whole test process from any code path that
|
||||
// emits a runtime event with a plain Background context.
|
||||
var runtimeEventsEmitFallback runtimeEventEmitFunc = runtime.EventsEmit
|
||||
|
||||
type asyncRuntimeEmitter struct {
|
||||
mu sync.Mutex
|
||||
emit runtimeEventEmitFunc
|
||||
@@ -1112,7 +1121,7 @@ func (e *asyncRuntimeEmitter) run() {
|
||||
}
|
||||
emit := e.emit
|
||||
if emit == nil {
|
||||
emit = runtime.EventsEmit
|
||||
emit = runtimeEventsEmitFallback
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
@@ -1155,6 +1164,35 @@ func isBackgroundJobLifecycleNotice(e event.Event) bool {
|
||||
strings.Contains(text, " killed: "))
|
||||
}
|
||||
|
||||
// notifyTabRuntimeRebuilt tells the frontend a tab's controller was replaced
|
||||
// in place (model/effort/token-mode switch, clear-while-running). A rebuilt
|
||||
// controller restarts its approval/ask id counter at "1", so tab-scoped
|
||||
// frontend state keyed by prompt id (the attention-chime dedupe) must reset —
|
||||
// unlike agent:ready, this event carries no reload semantics, so emitting it
|
||||
// on every swap adds no hydration churn.
|
||||
//
|
||||
// Ordering matters: the reset must reach the frontend BEFORE the rebuilt
|
||||
// controller's first approval/ask event, or the stale key still mutes it. The
|
||||
// tab's agent events ride the tab sink's own async queue, so the notice goes
|
||||
// through THAT queue — same lane, FIFO, guaranteed to arrive first. The
|
||||
// App-level queue is only the fallback when the sink cannot deliver (no sink,
|
||||
// or its webview context is cleared); it cannot order against sink traffic,
|
||||
// but an unordered notice still beats none.
|
||||
func (a *App) notifyTabRuntimeRebuilt(tab *WorkspaceTab) {
|
||||
if tab == nil {
|
||||
return
|
||||
}
|
||||
a.mu.RLock()
|
||||
sink := tab.sink
|
||||
tabID := tab.ID
|
||||
a.mu.RUnlock()
|
||||
if sink != nil && sink.context() != nil {
|
||||
sink.emitRuntimeEvent("runtime:rebuilt", tabID)
|
||||
return
|
||||
}
|
||||
a.emitRuntimeEvent("runtime:rebuilt", tabID)
|
||||
}
|
||||
|
||||
func (a *App) emitReady(ctx context.Context, tabID ...string) {
|
||||
a.mu.RLock()
|
||||
hook := a.readyHook
|
||||
@@ -1513,6 +1551,42 @@ func (a *App) ListTabs() []TabMeta {
|
||||
return enrichTabMetas(out)
|
||||
}
|
||||
|
||||
// syncTabWorkspaceRootSpellings repoints open project tabs at the registry's
|
||||
// canonical root spelling after a registry write may have rewritten it
|
||||
// (addProject and friends adopt the caller's spelling). Tabs, the project
|
||||
// tree, and persisted tab state then agree on a single string form of each
|
||||
// root, which the frontend compares exactly. Callers must not hold a.mu.
|
||||
func (a *App) syncTabWorkspaceRootSpellings() {
|
||||
projects := loadProjectsFile().Projects
|
||||
a.mu.Lock()
|
||||
changed := false
|
||||
for _, tab := range a.tabs {
|
||||
if tab == nil || tab.Scope != "project" {
|
||||
continue
|
||||
}
|
||||
i := projectIndexByRoot(projects, tab.WorkspaceRoot)
|
||||
if i < 0 || tab.WorkspaceRoot == projects[i].Root {
|
||||
continue
|
||||
}
|
||||
tab.WorkspaceRoot = projects[i].Root
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
a.saveTabsLocked()
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if changed {
|
||||
a.emitProjectTreeChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// registerProjectRoot indexes workspaceRoot in the project registry and
|
||||
// realigns open tabs when the registry adopted a new spelling of the root.
|
||||
func (a *App) registerProjectRoot(workspaceRoot string) {
|
||||
_ = addProject(workspaceRoot, "")
|
||||
a.syncTabWorkspaceRootSpellings()
|
||||
}
|
||||
|
||||
// OpenProjectTab builds a controller scoped to workspaceRoot and opens the
|
||||
// session selected by the given topic. Topic selection resolves to a concrete
|
||||
// session path first; the visible tab is then attached to that session runtime.
|
||||
@@ -1524,7 +1598,7 @@ func (a *App) OpenProjectTab(workspaceRoot, topicID string) (TabMeta, error) {
|
||||
workspaceRoot = abs
|
||||
}
|
||||
saveWorkspace(workspaceRoot)
|
||||
_ = addProject(workspaceRoot, "")
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
|
||||
sessionPath, _ := a.findTopicSessionForTarget("project", workspaceRoot, topicID)
|
||||
return a.openTopicTab("project", workspaceRoot, topicID, sessionPath)
|
||||
@@ -1541,7 +1615,7 @@ func (a *App) openProjectTabInactive(workspaceRoot, topicID string) (TabMeta, er
|
||||
if abs, err := filepath.Abs(workspaceRoot); err == nil {
|
||||
workspaceRoot = abs
|
||||
}
|
||||
_ = addProject(workspaceRoot, "")
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
|
||||
sessionPath, _ := a.findTopicSessionForTarget("project", workspaceRoot, topicID)
|
||||
return a.openTopicTabWithActivation("project", workspaceRoot, topicID, sessionPath, false)
|
||||
@@ -1675,7 +1749,7 @@ func (a *App) OpenTopicSession(scope, workspaceRoot, topicID, sessionPath string
|
||||
return TabMeta{}, fmt.Errorf("workspaceRoot is required")
|
||||
}
|
||||
saveWorkspace(workspaceRoot)
|
||||
_ = addProject(workspaceRoot, "")
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
}
|
||||
_, validPath, err := a.sessionDirForPath(sessionPath)
|
||||
if err != nil {
|
||||
@@ -1728,13 +1802,13 @@ func tabMatchesTopicTarget(tab *WorkspaceTab, scope, workspaceRoot, topicID stri
|
||||
if scope == "global" {
|
||||
return true
|
||||
}
|
||||
return normalizeProjectRoot(tab.WorkspaceRoot) == normalizeProjectRoot(workspaceRoot)
|
||||
return sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
|
||||
}
|
||||
|
||||
func tabInWorkspace(tab *WorkspaceTab, workspaceRoot string) bool {
|
||||
return tab != nil &&
|
||||
tab.Scope == "project" &&
|
||||
normalizeProjectRoot(tab.WorkspaceRoot) == normalizeProjectRoot(workspaceRoot)
|
||||
sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
|
||||
}
|
||||
|
||||
// EnsureBlankTab activates the existing blank tab for the target scope, or
|
||||
@@ -1756,7 +1830,7 @@ func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
|
||||
workspaceRoot = abs
|
||||
}
|
||||
saveWorkspace(workspaceRoot)
|
||||
_ = addProject(workspaceRoot, "")
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
} else {
|
||||
workspaceRoot = ""
|
||||
globalRoot = globalWorkspaceRoot()
|
||||
@@ -1911,7 +1985,7 @@ func (a *App) blankTabMatchesTargetLocked(tab *WorkspaceTab, scope, workspaceRoo
|
||||
if tab == nil || tab.Scope != scope {
|
||||
return false
|
||||
}
|
||||
if scope == "project" && tab.WorkspaceRoot != workspaceRoot {
|
||||
if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
|
||||
return false
|
||||
}
|
||||
if tab.Ctrl == nil {
|
||||
@@ -2013,13 +2087,8 @@ func (a *App) indexedBlankTopicIDLocked(scope, workspaceRoot string) string {
|
||||
var topicIDs []string
|
||||
if scope == "global" {
|
||||
topicIDs = orderedTopicIDs(f.GlobalTopics, titles)
|
||||
} else {
|
||||
for _, project := range f.Projects {
|
||||
if project.Root == workspaceRoot {
|
||||
topicIDs = orderedTopicIDs(project.Topics, titles)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if i := projectIndexByRoot(f.Projects, workspaceRoot); i >= 0 {
|
||||
topicIDs = orderedTopicIDs(f.Projects[i].Topics, titles)
|
||||
}
|
||||
if len(topicIDs) == 0 {
|
||||
return ""
|
||||
@@ -2039,7 +2108,7 @@ func (a *App) indexedBlankTopicIDLocked(scope, workspaceRoot string) string {
|
||||
if tab == nil || tab.Scope != scope || strings.TrimSpace(tab.TopicID) == "" {
|
||||
continue
|
||||
}
|
||||
if scope == "project" && tab.WorkspaceRoot != workspaceRoot {
|
||||
if scope == "project" && !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) {
|
||||
continue
|
||||
}
|
||||
openTopics[tab.TopicID] = true
|
||||
@@ -2604,6 +2673,23 @@ func (a *App) desktopControllerSink(inner event.Sink, cfg config.NotificationsCo
|
||||
return notify.NewSink(inner, sender, cfg)
|
||||
}
|
||||
|
||||
func setTabStartupError(tab *WorkspaceTab, err error) bool {
|
||||
if tab == nil {
|
||||
return false
|
||||
}
|
||||
tab.StartupErr = userFacingSessionLeaseError("", err).Error()
|
||||
tab.StartupErrLeaseHeld = errors.Is(err, agent.ErrSessionLeaseHeld)
|
||||
return tab.StartupErrLeaseHeld
|
||||
}
|
||||
|
||||
func clearTabStartupError(tab *WorkspaceTab) {
|
||||
if tab == nil {
|
||||
return
|
||||
}
|
||||
tab.StartupErr = ""
|
||||
tab.StartupErrLeaseHeld = false
|
||||
}
|
||||
|
||||
func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) {
|
||||
defer a.recoverToPending("buildTabController")
|
||||
keepBuildContext := false
|
||||
@@ -2640,15 +2726,19 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
|
||||
_ = config.MigrateLegacyCredentialsForRoot(root)
|
||||
cfg, err := config.LoadForRoot(root)
|
||||
if err != nil {
|
||||
leaseHeld := false
|
||||
a.mu.Lock()
|
||||
if a.tabBuildSupersededLocked(tab, buildGeneration) {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
tab.StartupErr = userFacingSessionLeaseError("", err).Error()
|
||||
leaseHeld = setTabStartupError(tab, err)
|
||||
tab.Ready = true
|
||||
tab.releaseSessionLease()
|
||||
a.mu.Unlock()
|
||||
if leaseHeld {
|
||||
a.scheduleDeferredStartupBuild(tab.ID)
|
||||
}
|
||||
a.emitReady(wailsCtx, tab.ID)
|
||||
return
|
||||
}
|
||||
@@ -2765,13 +2855,14 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
|
||||
OnSessionRecovered: a.handleTabSessionRecovered(tab),
|
||||
})
|
||||
if err != nil {
|
||||
leaseHeld := false
|
||||
a.mu.Lock()
|
||||
if a.tabBuildSupersededLocked(tab, buildGeneration) {
|
||||
a.mu.Unlock()
|
||||
a.abandonSupersededBuild(tab, nil, rootKey, "")
|
||||
return
|
||||
}
|
||||
tab.StartupErr = userFacingSessionLeaseError("", err).Error()
|
||||
leaseHeld = setTabStartupError(tab, err)
|
||||
tab.Ready = true
|
||||
hostKey := takeTabSharedHostKey(tab)
|
||||
tab.releaseSessionLease()
|
||||
@@ -2779,6 +2870,9 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
|
||||
if hostKey != "" {
|
||||
a.releaseSharedHost(hostKey)
|
||||
}
|
||||
if leaseHeld {
|
||||
a.scheduleDeferredStartupBuild(tab.ID)
|
||||
}
|
||||
a.emitReady(wailsCtx, tab.ID)
|
||||
return
|
||||
}
|
||||
@@ -2850,13 +2944,14 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
|
||||
}
|
||||
preLeaseKey := tab.sessionLeaseRuntimeKey()
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
leaseHeld := false
|
||||
a.mu.Lock()
|
||||
if a.tabBuildSupersededLocked(tab, buildGeneration) {
|
||||
a.mu.Unlock()
|
||||
a.abandonSupersededBuild(tab, ctrl, rootKey, "")
|
||||
return
|
||||
}
|
||||
tab.StartupErr = userFacingSessionLeaseError("", err).Error()
|
||||
leaseHeld = setTabStartupError(tab, err)
|
||||
tab.Ready = true
|
||||
hostKey := takeTabSharedHostKey(tab)
|
||||
// Release only a lease bound to THIS build's session: a failed
|
||||
@@ -2868,6 +2963,9 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
|
||||
if hostKey != "" {
|
||||
a.releaseSharedHost(hostKey)
|
||||
}
|
||||
if leaseHeld {
|
||||
a.scheduleDeferredStartupBuild(tab.ID)
|
||||
}
|
||||
a.emitReady(wailsCtx, tab.ID)
|
||||
return
|
||||
}
|
||||
@@ -2929,7 +3027,7 @@ func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loa
|
||||
tab.Ctrl = ctrl
|
||||
tab.Label = ctrl.Label()
|
||||
tab.Ready = true
|
||||
tab.StartupErr = ""
|
||||
clearTabStartupError(tab)
|
||||
keepBuildContext = true
|
||||
a.mu.Unlock()
|
||||
a.emitReady(wailsCtx, tab.ID)
|
||||
@@ -2967,7 +3065,7 @@ func (a *App) reconcileTabWithPinnedSessionMeta(tab *WorkspaceTab) (string, bool
|
||||
return "", false
|
||||
}
|
||||
if tab.Scope == "project" && binding.scope != "project" && normalizeProjectRoot(tab.WorkspaceRoot) != "" {
|
||||
if root, ok := safeControllerWorkspaceRoot(tab.Ctrl); ok && normalizeProjectRoot(root) == normalizeProjectRoot(tab.WorkspaceRoot) {
|
||||
if root, ok := safeControllerWorkspaceRoot(tab.Ctrl); ok && sameProjectRoot(root, tab.WorkspaceRoot) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -3001,7 +3099,7 @@ func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding
|
||||
if workspaceRoot == "" {
|
||||
return
|
||||
}
|
||||
_ = addProject(workspaceRoot, "")
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
} else {
|
||||
scope = "global"
|
||||
workspaceRoot = globalTabWorkspaceRoot()
|
||||
@@ -3023,7 +3121,9 @@ func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding
|
||||
changed := tab.Scope != scope ||
|
||||
tab.WorkspaceRoot != workspaceRoot ||
|
||||
canonicalTabSessionPath(tab.SessionPath) != canonicalTabSessionPath(binding.path)
|
||||
workspaceChanged := tab.Scope != scope || tab.WorkspaceRoot != workspaceRoot
|
||||
// Spelling-only root updates still persist above, but an equivalent root is
|
||||
// the same workspace — do not warn the user about a switch.
|
||||
workspaceChanged := tab.Scope != scope || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot)
|
||||
tab.Scope = scope
|
||||
tab.WorkspaceRoot = workspaceRoot
|
||||
tab.SessionPath = canonicalTabSessionPath(binding.path)
|
||||
@@ -3808,10 +3908,11 @@ func desktopMCPMigrationRoots(tabs desktopTabsFile) []string {
|
||||
var roots []string
|
||||
add := func(root string) {
|
||||
root = normalizeProjectRoot(root)
|
||||
if root == "" || seen[root] {
|
||||
key := projectRootKey(root)
|
||||
if root == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[root] = true
|
||||
seen[key] = true
|
||||
roots = append(roots, root)
|
||||
}
|
||||
if cur := loadWorkspace(); cur != "" {
|
||||
@@ -3843,16 +3944,17 @@ func recoverLegacyProjectSidebarRoots(tabs desktopTabsFile) (bool, error) {
|
||||
for _, project := range f.Projects {
|
||||
root := normalizeProjectRoot(project.Root)
|
||||
if root != "" {
|
||||
seen[root] = true
|
||||
seen[projectRootKey(root)] = true
|
||||
}
|
||||
}
|
||||
|
||||
add := func(root string) {
|
||||
root = normalizeProjectRoot(root)
|
||||
if root == "" || seen[root] || !existingDirectory(root) {
|
||||
key := projectRootKey(root)
|
||||
if root == "" || seen[key] || !existingDirectory(root) {
|
||||
return
|
||||
}
|
||||
seen[root] = true
|
||||
seen[key] = true
|
||||
f.Projects = append(f.Projects, desktopProject{Root: root})
|
||||
changed = true
|
||||
}
|
||||
@@ -3985,7 +4087,7 @@ func prependTopicsInProjectsFileOpts(workspaceRoot string, topicIDs []string, en
|
||||
return true, nil
|
||||
}
|
||||
for i, p := range f.Projects {
|
||||
if p.Root != workspaceRoot {
|
||||
if !sameProjectRoot(p.Root, workspaceRoot) {
|
||||
continue
|
||||
}
|
||||
next := uniqueStrings(append(append([]string(nil), live...), p.Topics...))
|
||||
@@ -4047,6 +4149,36 @@ func normalizeProjectRoot(root string) string {
|
||||
return root
|
||||
}
|
||||
|
||||
func sameProjectRoot(a, b string) bool {
|
||||
return sameDesktopPath(normalizeProjectRoot(a), normalizeProjectRoot(b))
|
||||
}
|
||||
|
||||
func projectIndexByRoot(projects []desktopProject, root string) int {
|
||||
root = normalizeProjectRoot(root)
|
||||
if root == "" {
|
||||
return -1
|
||||
}
|
||||
for i, project := range projects {
|
||||
if sameProjectRoot(project.Root, root) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func projectRootInList(roots []string, root string) bool {
|
||||
root = normalizeProjectRoot(root)
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range roots {
|
||||
if sameProjectRoot(candidate, root) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
|
||||
out := desktopProjectFile{
|
||||
GlobalTitle: strings.TrimSpace(f.GlobalTitle),
|
||||
@@ -4055,7 +4187,6 @@ func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
|
||||
GlobalPinnedTopics: uniqueStrings(f.GlobalPinnedTopics),
|
||||
DeletedTopics: uniqueStrings(f.DeletedTopics),
|
||||
}
|
||||
index := map[string]int{}
|
||||
for _, p := range f.Projects {
|
||||
root := normalizeProjectRoot(p.Root)
|
||||
if root == "" {
|
||||
@@ -4066,7 +4197,7 @@ func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
|
||||
p.Color = normalizeProjectColor(p.Color)
|
||||
p.Topics = uniqueStrings(p.Topics)
|
||||
p.PinnedTopics = uniqueStrings(p.PinnedTopics)
|
||||
if i, ok := index[root]; ok {
|
||||
if i := projectIndexByRoot(out.Projects, root); i >= 0 {
|
||||
if out.Projects[i].Title == "" && p.Title != "" {
|
||||
out.Projects[i].Title = p.Title
|
||||
}
|
||||
@@ -4077,17 +4208,12 @@ func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
|
||||
out.Projects[i].PinnedTopics = uniqueStrings(append(out.Projects[i].PinnedTopics, p.PinnedTopics...))
|
||||
continue
|
||||
}
|
||||
index[root] = len(out.Projects)
|
||||
out.Projects = append(out.Projects, p)
|
||||
}
|
||||
projectRoots := make(map[string]bool, len(out.Projects))
|
||||
for _, project := range out.Projects {
|
||||
projectRoots[project.Root] = true
|
||||
}
|
||||
for _, root := range uniqueStrings(f.PinnedProjects) {
|
||||
root = normalizeProjectRoot(root)
|
||||
if root != "" && projectRoots[root] && !containsDesktopString(out.PinnedProjects, root) {
|
||||
out.PinnedProjects = append(out.PinnedProjects, root)
|
||||
if i := projectIndexByRoot(out.Projects, root); i >= 0 && !projectRootInList(out.PinnedProjects, out.Projects[i].Root) {
|
||||
out.PinnedProjects = append(out.PinnedProjects, out.Projects[i].Root)
|
||||
}
|
||||
}
|
||||
out.SidebarOrder = normalizeSidebarOrder(f.SidebarOrder, out.Projects)
|
||||
@@ -4095,28 +4221,30 @@ func normalizeProjectsFile(f desktopProjectFile) desktopProjectFile {
|
||||
}
|
||||
|
||||
func normalizeSidebarOrder(order []string, projects []desktopProject) []string {
|
||||
projectRoots := make(map[string]bool, len(projects))
|
||||
for _, project := range projects {
|
||||
if project.Root != "" {
|
||||
projectRoots[project.Root] = true
|
||||
}
|
||||
}
|
||||
seen := make(map[string]bool, len(order))
|
||||
seenGlobal := false
|
||||
// Dedupe roots against a roots-only list: out also holds the global order
|
||||
// token, which must never be path-compared against project roots.
|
||||
var seenRoots []string
|
||||
out := make([]string, 0, len(order))
|
||||
for _, value := range order {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == desktopGlobalOrderToken {
|
||||
if !seen[value] {
|
||||
seen[value] = true
|
||||
if !seenGlobal {
|
||||
seenGlobal = true
|
||||
out = append(out, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
root := normalizeProjectRoot(value)
|
||||
if root == "" || !projectRoots[root] || seen[root] {
|
||||
i := projectIndexByRoot(projects, root)
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
seen[root] = true
|
||||
root = projects[i].Root
|
||||
if projectRootInList(seenRoots, root) {
|
||||
continue
|
||||
}
|
||||
seenRoots = append(seenRoots, root)
|
||||
out = append(out, root)
|
||||
}
|
||||
return out
|
||||
@@ -4333,7 +4461,7 @@ func projectColor(root string) string {
|
||||
return globalProjectColor()
|
||||
}
|
||||
for _, p := range loadProjectsFile().Projects {
|
||||
if p.Root == root {
|
||||
if sameProjectRoot(p.Root, root) {
|
||||
return normalizeProjectColor(p.Color)
|
||||
}
|
||||
}
|
||||
@@ -4359,11 +4487,19 @@ func addProject(root, title string) error {
|
||||
title = strings.TrimSpace(title)
|
||||
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
for i, p := range f.Projects {
|
||||
if p.Root == root {
|
||||
if title == "" || f.Projects[i].Title == title {
|
||||
if sameProjectRoot(p.Root, root) {
|
||||
changed := false
|
||||
if f.Projects[i].Root != root {
|
||||
f.Projects[i].Root = root
|
||||
changed = true
|
||||
}
|
||||
if title != "" && f.Projects[i].Title != title {
|
||||
f.Projects[i].Title = title
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
f.Projects[i].Title = title
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
@@ -4384,10 +4520,11 @@ func renameProject(root, title string) error {
|
||||
return true, nil
|
||||
}
|
||||
for i, p := range f.Projects {
|
||||
if p.Root == root {
|
||||
if f.Projects[i].Title == title {
|
||||
if sameProjectRoot(p.Root, root) {
|
||||
if f.Projects[i].Root == root && f.Projects[i].Title == title {
|
||||
return false, nil
|
||||
}
|
||||
f.Projects[i].Root = root
|
||||
f.Projects[i].Title = title
|
||||
return true, nil
|
||||
}
|
||||
@@ -4409,10 +4546,11 @@ func setProjectColor(root, color string) error {
|
||||
return true, nil
|
||||
}
|
||||
for i, p := range f.Projects {
|
||||
if p.Root == root {
|
||||
if f.Projects[i].Color == color {
|
||||
if sameProjectRoot(p.Root, root) {
|
||||
if f.Projects[i].Root == root && f.Projects[i].Color == color {
|
||||
return false, nil
|
||||
}
|
||||
f.Projects[i].Root = root
|
||||
f.Projects[i].Color = color
|
||||
return true, nil
|
||||
}
|
||||
@@ -4427,7 +4565,7 @@ func removeProject(root string) error {
|
||||
return updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
projects := make([]desktopProject, 0, len(f.Projects))
|
||||
for _, p := range f.Projects {
|
||||
if p.Root != root {
|
||||
if !sameProjectRoot(p.Root, root) {
|
||||
projects = append(projects, p)
|
||||
}
|
||||
}
|
||||
@@ -5252,6 +5390,9 @@ func migrateLegacySessionsIntoGlobalTopics(dir string) []string {
|
||||
// unmarked so the next render retries instead of the gate hiding it forever.
|
||||
deferred := false
|
||||
for _, info := range infos {
|
||||
if sessionOrderInfoIsAutomaticRecovery(info) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(info.TopicID) != "" {
|
||||
continue
|
||||
}
|
||||
@@ -5415,11 +5556,8 @@ func repairIndexedSessionTopics(dir string) []string {
|
||||
indexedTopics := projects.GlobalTopics
|
||||
if scope == "project" {
|
||||
indexedTopics = nil
|
||||
for _, p := range projects.Projects {
|
||||
if p.Root == workspaceRoot {
|
||||
indexedTopics = p.Topics
|
||||
break
|
||||
}
|
||||
if i := projectIndexByRoot(projects.Projects, workspaceRoot); i >= 0 {
|
||||
indexedTopics = projects.Projects[i].Topics
|
||||
}
|
||||
}
|
||||
indexedSet := make(map[string]bool, len(indexedTopics))
|
||||
@@ -5432,6 +5570,9 @@ func repairIndexedSessionTopics(dir string) []string {
|
||||
sourcesChanged := false
|
||||
deferred := false
|
||||
for _, info := range infos {
|
||||
if sessionOrderInfoIsAutomaticRecovery(info) {
|
||||
continue
|
||||
}
|
||||
topicID := strings.TrimSpace(info.TopicID)
|
||||
if topicID == "" {
|
||||
continue
|
||||
@@ -5528,6 +5669,37 @@ func indexedSessionTopicTitle(sessionTitles map[string]string, info agent.Sessio
|
||||
return topicTitleFromText(info.Preview)
|
||||
}
|
||||
|
||||
func sessionOrderInfoIsAutomaticRecovery(info agent.SessionOrderInfo) bool {
|
||||
return info.Recovered ||
|
||||
strings.TrimSpace(info.RecoveryDigest) != "" ||
|
||||
isAutomaticRecoverySessionPath(info.Path)
|
||||
}
|
||||
|
||||
func sessionInfoIsAutomaticRecovery(info agent.SessionInfo) bool {
|
||||
return info.Recovered ||
|
||||
strings.TrimSpace(info.RecoveryDigest) != "" ||
|
||||
isAutomaticRecoverySessionPath(info.Path)
|
||||
}
|
||||
|
||||
func isAutomaticRecoverySessionPath(path string) bool {
|
||||
id := agent.BranchID(path)
|
||||
idx := strings.LastIndex(id, "-recovery-")
|
||||
if idx <= 0 {
|
||||
return false
|
||||
}
|
||||
suffix := id[idx+len("-recovery-"):]
|
||||
if len(suffix) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, r := range suffix {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func legacyMigrationTargetForDir(dir string) (scope, workspaceRoot, topicTitleRoot string, ok bool) {
|
||||
dir = cleanDesktopPath(dir)
|
||||
if dir == "" {
|
||||
@@ -5558,9 +5730,9 @@ func legacySessionScopeMatchesMigrationTarget(meta agent.BranchMeta, scope, work
|
||||
}
|
||||
metaRoot := normalizeProjectRoot(meta.WorkspaceRoot)
|
||||
if scope == "project" {
|
||||
return metaRoot == "" || normalizeProjectRoot(workspaceRoot) == metaRoot
|
||||
return metaRoot == "" || sameProjectRoot(workspaceRoot, metaRoot)
|
||||
}
|
||||
return metaRoot == "" || normalizeProjectRoot(globalWorkspaceRoot()) == metaRoot
|
||||
return metaRoot == "" || sameProjectRoot(globalWorkspaceRoot(), metaRoot)
|
||||
}
|
||||
|
||||
func cleanDesktopPath(path string) string {
|
||||
@@ -5586,6 +5758,17 @@ func sameDesktopPath(a, b string) bool {
|
||||
return a == b
|
||||
}
|
||||
|
||||
// projectRootKey is the map-key form of a project root: cleaned, absolute,
|
||||
// and case-folded on Windows — the same key form agent.CanonicalSessionPath
|
||||
// uses for session paths, so equivalent spellings never split lookups.
|
||||
func projectRootKey(root string) string {
|
||||
root = cleanDesktopPath(root)
|
||||
if os.PathSeparator == '\\' {
|
||||
return strings.ToLower(root)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func restoreSessionTopicIndex(dir, sessionPath string) error {
|
||||
sessionPath = strings.TrimSpace(sessionPath)
|
||||
if sessionPath == "" {
|
||||
@@ -5754,6 +5937,7 @@ func (a *App) RenameProject(workspaceRoot, title string) error {
|
||||
if err := renameProject(workspaceRoot, title); err != nil {
|
||||
return err
|
||||
}
|
||||
a.syncTabWorkspaceRootSpellings()
|
||||
a.emitProjectTreeChanged()
|
||||
return nil
|
||||
}
|
||||
@@ -5764,6 +5948,7 @@ func (a *App) SetProjectColor(workspaceRoot, color string) error {
|
||||
if err := setProjectColor(workspaceRoot, color); err != nil {
|
||||
return err
|
||||
}
|
||||
a.syncTabWorkspaceRootSpellings()
|
||||
a.emitProjectTreeChanged()
|
||||
return nil
|
||||
}
|
||||
@@ -5776,19 +5961,19 @@ func (a *App) SetProjectPinned(workspaceRoot string, pinned bool) error {
|
||||
return fmt.Errorf("workspaceRoot is required")
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
found := false
|
||||
for _, project := range f.Projects {
|
||||
if project.Root == root {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
i := projectIndexByRoot(f.Projects, root)
|
||||
if i < 0 {
|
||||
return false, fmt.Errorf("project %q not found", root)
|
||||
}
|
||||
next := removeString(f.PinnedProjects, root)
|
||||
root = f.Projects[i].Root
|
||||
next := make([]string, 0, len(f.PinnedProjects))
|
||||
for _, pinnedRoot := range f.PinnedProjects {
|
||||
if !sameProjectRoot(pinnedRoot, root) {
|
||||
next = append(next, pinnedRoot)
|
||||
}
|
||||
}
|
||||
if pinned {
|
||||
next = prependUniqueString(f.PinnedProjects, root)
|
||||
next = prependUniqueString(next, root)
|
||||
}
|
||||
if sameStringList(next, f.PinnedProjects) {
|
||||
return false, nil
|
||||
@@ -5806,36 +5991,32 @@ func (a *App) SetProjectPinned(workspaceRoot string, pinned bool) error {
|
||||
// when present, the virtual Global sidebar section.
|
||||
func (a *App) ReorderProjects(workspaceRoots []string) error {
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
byRoot := make(map[string]desktopProject, len(f.Projects))
|
||||
for _, project := range f.Projects {
|
||||
byRoot[project.Root] = project
|
||||
}
|
||||
seen := make(map[string]bool, len(workspaceRoots))
|
||||
var seenProjects []string
|
||||
next := make([]desktopProject, 0, len(workspaceRoots))
|
||||
sidebarOrder := make([]string, 0, len(workspaceRoots))
|
||||
hasGlobalOrder := false
|
||||
for _, root := range workspaceRoots {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == desktopGlobalOrderToken {
|
||||
if seen[root] {
|
||||
if hasGlobalOrder {
|
||||
return false, fmt.Errorf("duplicate global section")
|
||||
}
|
||||
seen[root] = true
|
||||
hasGlobalOrder = true
|
||||
sidebarOrder = append(sidebarOrder, root)
|
||||
continue
|
||||
}
|
||||
root = normalizeProjectRoot(root)
|
||||
project, ok := byRoot[root]
|
||||
if !ok {
|
||||
i := projectIndexByRoot(f.Projects, root)
|
||||
if i < 0 {
|
||||
return false, fmt.Errorf("project %q not found", root)
|
||||
}
|
||||
if seen[root] {
|
||||
project := f.Projects[i]
|
||||
if projectRootInList(seenProjects, project.Root) {
|
||||
return false, fmt.Errorf("duplicate project %q", root)
|
||||
}
|
||||
seen[root] = true
|
||||
seenProjects = append(seenProjects, project.Root)
|
||||
next = append(next, project)
|
||||
sidebarOrder = append(sidebarOrder, root)
|
||||
sidebarOrder = append(sidebarOrder, project.Root)
|
||||
}
|
||||
if len(next) != len(f.Projects) {
|
||||
return false, fmt.Errorf("project order length mismatch")
|
||||
@@ -6403,6 +6584,7 @@ func (a *App) ListProjectTree() []ProjectNode {
|
||||
}
|
||||
seenRuntimePaths[sessionPath] = true
|
||||
info := sessionInfos[sessionPath]
|
||||
recovered := sessionInfoIsAutomaticRecovery(info) || isAutomaticRecoverySessionPath(sessionPath)
|
||||
label := runtimeSessionTreeLabel(tab, info, sessionTitles[sessionPath])
|
||||
status := activityStatusForTab(tab)
|
||||
runtimeStatus := control.RuntimeStatus{}
|
||||
@@ -6419,7 +6601,7 @@ func (a *App) ListProjectTree() []ProjectNode {
|
||||
open: open,
|
||||
running: running,
|
||||
status: status,
|
||||
recovered: info.Recovered,
|
||||
recovered: recovered,
|
||||
recoveryReason: info.RecoveryReason,
|
||||
recoveryDigest: info.RecoveryDigest,
|
||||
recoveryParentID: string(info.ParentID),
|
||||
@@ -6619,7 +6801,10 @@ func topicSummaryKey(scope, workspaceRoot, topicID string) string {
|
||||
if scope == "global" {
|
||||
return "global::" + topicID
|
||||
}
|
||||
return "project:" + workspaceRoot + ":" + topicID
|
||||
// Producers key by the live tab's root spelling while the sidebar keys by
|
||||
// the registry's canonical spelling; fold both so runtime status never
|
||||
// splits across equivalent roots.
|
||||
return "project:" + projectRootKey(workspaceRoot) + ":" + topicID
|
||||
}
|
||||
|
||||
func projectSessionNodeKey(scope, sessionPath string) string {
|
||||
@@ -7533,7 +7718,7 @@ func (a *App) knownSessionDirs() []string {
|
||||
|
||||
func topicSessionMatchMatchesTarget(match topicSessionMatch, scope, workspaceRoot string) bool {
|
||||
if scope == "project" {
|
||||
return match.scope == "project" && normalizeProjectRoot(match.workspaceRoot) == normalizeProjectRoot(workspaceRoot)
|
||||
return match.scope == "project" && sameProjectRoot(match.workspaceRoot, workspaceRoot)
|
||||
}
|
||||
return match.scope == "" || match.scope == "global"
|
||||
}
|
||||
@@ -7660,7 +7845,7 @@ func mergeSessionInfos(dir string, infos []agent.SessionInfo, titles map[string]
|
||||
key := topicSummaryKey(info.Scope, info.WorkspaceRoot, info.TopicID)
|
||||
summary := topicSummaries[key]
|
||||
lastActivityAt := info.LastActivityAt.UnixMilli()
|
||||
if info.Recovered {
|
||||
if sessionInfoIsAutomaticRecovery(info) {
|
||||
// A conflict copy duplicates its original, so its turns would
|
||||
// double-count — but its activity is real: once a tab adopts the
|
||||
// copy as its live transcript, all new work lands here, and
|
||||
|
||||
@@ -32,6 +32,18 @@ func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
|
||||
return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
|
||||
}
|
||||
|
||||
func installNoopRuntimeEvents(app *App, sinks ...*tabEventSink) {
|
||||
emit := func(context.Context, string, ...interface{}) {}
|
||||
if app != nil {
|
||||
app.runtimeEvents.emit = emit
|
||||
}
|
||||
for _, sink := range sinks {
|
||||
if sink != nil {
|
||||
sink.runtimeEvents.emit = emit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tabIDs(tabs []TabMeta) []string {
|
||||
ids := make([]string, 0, len(tabs))
|
||||
for _, tab := range tabs {
|
||||
@@ -921,6 +933,142 @@ func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredStartupRetryBuildsAfterLeaseRelease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
prevInterval := deferredRebuildRetryInterval
|
||||
deferredRebuildRetryInterval = 20 * time.Millisecond
|
||||
t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
|
||||
|
||||
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "startup-retry.jsonl")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
released := false
|
||||
t.Cleanup(func() {
|
||||
if !released {
|
||||
lease.Release()
|
||||
}
|
||||
})
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
app.enableDeferredRebuildRetry()
|
||||
t.Cleanup(app.stopDeferredRebuildRetry)
|
||||
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_retry")
|
||||
tab.SessionPath = path
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
installNoopRuntimeEvents(app, tab.sink)
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if ctrl := app.controllerForTab(tab); ctrl != nil {
|
||||
ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
|
||||
app.buildTabController(tab)
|
||||
if tab.Ctrl != nil {
|
||||
t.Fatalf("controller = %T, want nil while external lease is held", tab.Ctrl)
|
||||
}
|
||||
if !tab.StartupErrLeaseHeld {
|
||||
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
|
||||
}
|
||||
if !app.deferredRebuildPending(tab.ID) {
|
||||
t.Fatal("startup retry was not scheduled while the lease was held")
|
||||
}
|
||||
|
||||
lease.Release()
|
||||
released = true
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if app.deferredRebuildPending(tab.ID) {
|
||||
t.Fatal("startup retry is still pending after the lease was released")
|
||||
}
|
||||
if ctrl := app.controllerForTab(tab); ctrl == nil {
|
||||
t.Fatal("controller was not rebuilt after the lease was released")
|
||||
}
|
||||
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
|
||||
t.Fatalf("startup error after retry = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabAndCtrlByIDRecoversStartupLeaseBeforeAction(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "startup-before-action.jsonl")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatalf("write placeholder session: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
released := false
|
||||
t.Cleanup(func() {
|
||||
if !released {
|
||||
lease.Release()
|
||||
}
|
||||
})
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_action")
|
||||
tab.SessionPath = path
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
installNoopRuntimeEvents(app, tab.sink)
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if ctrl := app.controllerForTab(tab); ctrl != nil {
|
||||
ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
|
||||
app.buildTabController(tab)
|
||||
if !tab.StartupErrLeaseHeld {
|
||||
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
|
||||
}
|
||||
lease.Release()
|
||||
released = true
|
||||
|
||||
gotTab, ctrl := app.tabAndCtrlByID(tab.ID)
|
||||
if gotTab != tab {
|
||||
t.Fatalf("tabAndCtrlByID tab = %p, want %p", gotTab, tab)
|
||||
}
|
||||
if ctrl == nil {
|
||||
t.Fatal("tabAndCtrlByID did not rebuild the controller before returning")
|
||||
}
|
||||
if app.deferredRebuildPending(tab.ID) {
|
||||
t.Fatal("startup retry remained pending after synchronous recovery")
|
||||
}
|
||||
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
|
||||
t.Fatalf("startup error after synchronous recovery = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenGlobalTabResolvesTopicToLatestSessionRuntime(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// Planner notices are persisted through plannerDisplay into session history;
|
||||
// dropping Detail here would make the expandable diagnostics disappear after
|
||||
// a reload even though the live transcript showed them.
|
||||
func TestRecordPlannerDisplayEventKeepsNoticeDetail(t *testing.T) {
|
||||
tab := &WorkspaceTab{}
|
||||
tab.recordPlannerDisplayEvent(event.Event{
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Source: event.UsageSourcePlanner,
|
||||
Text: "An MCP server failed to start.",
|
||||
Detail: `mcp server "github" failed to start: command not found`,
|
||||
})
|
||||
if len(tab.plannerDisplay) != 1 {
|
||||
t.Fatalf("plannerDisplay len = %d, want 1", len(tab.plannerDisplay))
|
||||
}
|
||||
got := tab.plannerDisplay[0]
|
||||
if got.Role != "notice" || got.Level != "warn" {
|
||||
t.Fatalf("unexpected notice message: %+v", got)
|
||||
}
|
||||
if got.Content != "An MCP server failed to start." {
|
||||
t.Fatalf("Content = %q", got.Content)
|
||||
}
|
||||
if got.Detail != `mcp server "github" failed to start: command not found` {
|
||||
t.Fatalf("Detail = %q, want diagnostic detail preserved", got.Detail)
|
||||
}
|
||||
}
|
||||
@@ -462,6 +462,122 @@ func TestLegacySessionsMigrateIntoGlobalTopics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRecoverySessionsDoNotMigrateIntoTopics(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
normal := writeLegacySession(t, dir, "normal.jsonl", "normal imported prompt", time.Now().Add(-2*time.Hour))
|
||||
recovery := writeLegacySession(t, dir, "normal-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
||||
t.Fatalf("project tree = %#v, want global folder", nodes)
|
||||
}
|
||||
if got := len(nodes[0].Children); got != 1 {
|
||||
t.Fatalf("global migrated topics = %d, want only the normal session: %#v", got, nodes[0].Children)
|
||||
}
|
||||
if got, want := nodes[0].Children[0].TopicID, legacySessionTopicID(normal); got != want {
|
||||
t.Fatalf("migrated topic = %q, want normal session topic %q", got, want)
|
||||
}
|
||||
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil {
|
||||
t.Fatalf("load recovery meta: %v", err)
|
||||
} else if ok && strings.TrimSpace(meta.TopicID) != "" {
|
||||
t.Fatalf("legacy recovery branch was migrated into topic %q", meta.TopicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: recovery, Label: "test"})
|
||||
defer ctrl.Close()
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "")
|
||||
|
||||
sessions := app.ListSessions()
|
||||
for _, session := range sessions {
|
||||
if filepath.Clean(session.Path) != filepath.Clean(recovery) {
|
||||
continue
|
||||
}
|
||||
if !session.Recovered {
|
||||
t.Fatalf("history session recovered flag = false, want true: %+v", session)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("history sessions = %#v, want recovery session %q", sessions, recovery)
|
||||
}
|
||||
|
||||
func TestTrashMarksLegacyRecoverySessionsAsRecovered(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now())
|
||||
if err := deleteSessionFile(dir, recovery); err != nil {
|
||||
t.Fatalf("delete recovery session: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(recovery), filepath.Base(recovery))
|
||||
|
||||
sessions := NewApp().ListTrashedSessions()
|
||||
if len(sessions) != 1 || filepath.Clean(sessions[0].Path) != filepath.Clean(trashPath) {
|
||||
t.Fatalf("trashed sessions = %#v, want %q", sessions, trashPath)
|
||||
}
|
||||
if !sessions[0].Recovered {
|
||||
t.Fatalf("trashed recovery session recovered flag = false, want true: %+v", sessions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTreeHidesAlreadyMigratedLegacyRecoveryTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
recovery := writeLegacySession(t, dir, "desktop-recovery-0123456789abcdef.jsonl", "legacy recovery prompt", time.Now().Add(-time.Hour))
|
||||
topicID := legacySessionTopicID(recovery)
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(recovery, agent.BranchMeta{
|
||||
ID: agent.BranchID(recovery),
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-time.Hour),
|
||||
Scope: "global",
|
||||
TopicID: topicID,
|
||||
TopicTitle: "恢复分支",
|
||||
Turns: 1,
|
||||
Preview: "legacy recovery prompt",
|
||||
}); err != nil {
|
||||
t.Fatalf("save migrated recovery meta: %v", err)
|
||||
}
|
||||
if err := setTopicTitle("", topicID, "恢复分支"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
if err := prependTopicsInProjectsFile("", []string{topicID}, false); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.TopicID == topicID {
|
||||
t.Fatalf("already-migrated recovery topic should be hidden, got node %+v", node)
|
||||
}
|
||||
walk(node.Children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
}
|
||||
|
||||
func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/checkpoint"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
@@ -251,6 +252,264 @@ func TestProjectFileUpdatesSerializeReadModifyWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProjectsFileMergesEquivalentProjectRoots(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
// A textually different spelling of the same folder. filepath.Join would
|
||||
// clean the dot segment away and hand back the identical string, so build
|
||||
// the spelling by hand.
|
||||
equivalentRoot := projectRoot + string(filepath.Separator) + "."
|
||||
|
||||
f := normalizeProjectsFile(desktopProjectFile{
|
||||
Projects: []desktopProject{
|
||||
{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
|
||||
{Root: equivalentRoot, Color: "blue", Topics: []string{"topic_b"}, PinnedTopics: []string{"topic_b"}},
|
||||
},
|
||||
PinnedProjects: []string{equivalentRoot},
|
||||
SidebarOrder: []string{equivalentRoot, projectRoot},
|
||||
})
|
||||
|
||||
if len(f.Projects) != 1 {
|
||||
t.Fatalf("projects = %+v, want one merged project", f.Projects)
|
||||
}
|
||||
if f.Projects[0].Root != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("merged root = %q, want %q", f.Projects[0].Root, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
|
||||
t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
|
||||
}
|
||||
if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
|
||||
t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
|
||||
}
|
||||
if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != f.Projects[0].Root {
|
||||
t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, f.Projects[0].Root)
|
||||
}
|
||||
if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != f.Projects[0].Root {
|
||||
t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, f.Projects[0].Root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchWorkspaceReaddsRemovedProject(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := removeProject(projectRoot); err != nil {
|
||||
t.Fatalf("remove project: %v", err)
|
||||
}
|
||||
if got := loadProjectsFile().Projects; len(got) != 0 {
|
||||
t.Fatalf("projects after remove = %+v, want none", got)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
installNoopRuntimeEvents(app)
|
||||
if got, err := app.SwitchWorkspace(projectRoot + string(filepath.Separator) + "."); err != nil {
|
||||
t.Fatalf("switch workspace: %v", err)
|
||||
} else if got != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("SwitchWorkspace root = %q, want %q", got, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
|
||||
projects := loadProjectsFile().Projects
|
||||
if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("projects after re-add = %+v, want %q", projects, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
if got := loadWorkspace(); got != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("active workspace = %q, want %q", got, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
}
|
||||
|
||||
// flipPathASCIICase returns the path with the case of every ASCII letter
|
||||
// swapped — on Windows an equivalent spelling of the same folder that
|
||||
// normalizeProjectRoot cannot fold away.
|
||||
func flipPathASCIICase(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
flipped := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return r - 'a' + 'A'
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r - 'A' + 'a'
|
||||
}
|
||||
return r
|
||||
}, path)
|
||||
if flipped == path {
|
||||
t.Skipf("path %q contains no ASCII letters to flip", path)
|
||||
}
|
||||
return flipped
|
||||
}
|
||||
|
||||
func TestNormalizeProjectsFileFoldsRootCaseOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case-insensitive root matching only applies to Windows paths")
|
||||
}
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
flipped := flipPathASCIICase(t, projectRoot)
|
||||
|
||||
f := normalizeProjectsFile(desktopProjectFile{
|
||||
Projects: []desktopProject{
|
||||
{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
|
||||
{Root: flipped, Color: "blue", Topics: []string{"topic_b"}},
|
||||
},
|
||||
PinnedProjects: []string{flipped},
|
||||
SidebarOrder: []string{flipped, projectRoot},
|
||||
})
|
||||
|
||||
if len(f.Projects) != 1 {
|
||||
t.Fatalf("projects = %+v, want case-equivalent roots merged", f.Projects)
|
||||
}
|
||||
canonical := f.Projects[0].Root
|
||||
if canonical != normalizeProjectRoot(projectRoot) {
|
||||
t.Fatalf("merged root = %q, want first spelling %q", canonical, normalizeProjectRoot(projectRoot))
|
||||
}
|
||||
if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
|
||||
t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
|
||||
}
|
||||
if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
|
||||
t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
|
||||
}
|
||||
if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != canonical {
|
||||
t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, canonical)
|
||||
}
|
||||
if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != canonical {
|
||||
t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRootOpsFoldCaseOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case-insensitive root matching only applies to Windows paths")
|
||||
}
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
flipped := flipPathASCIICase(t, projectRoot)
|
||||
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := addProject(flipped, ""); err != nil {
|
||||
t.Fatalf("re-add project under flipped case: %v", err)
|
||||
}
|
||||
projects := loadProjectsFile().Projects
|
||||
if len(projects) != 1 {
|
||||
t.Fatalf("projects = %+v, want re-add under equivalent spelling to update in place", projects)
|
||||
}
|
||||
if projects[0].Root != normalizeProjectRoot(flipped) {
|
||||
t.Fatalf("root = %q, want self-healed to latest spelling %q", projects[0].Root, normalizeProjectRoot(flipped))
|
||||
}
|
||||
if projects[0].Title != "Project" {
|
||||
t.Fatalf("title = %q, want preserved across re-add", projects[0].Title)
|
||||
}
|
||||
|
||||
if err := prependTopicInProjectsFile(projectRoot, "topic_a", true); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
projects = loadProjectsFile().Projects
|
||||
if len(projects) != 1 {
|
||||
t.Fatalf("projects = %+v, want topic prepend to reuse the case-equivalent entry", projects)
|
||||
}
|
||||
if got := projects[0].Topics; len(got) != 1 || got[0] != "topic_a" {
|
||||
t.Fatalf("topics = %v, want [topic_a] on the merged entry", got)
|
||||
}
|
||||
|
||||
if err := removeProject(projectRoot); err != nil {
|
||||
t.Fatalf("remove project via original spelling: %v", err)
|
||||
}
|
||||
if got := loadProjectsFile().Projects; len(got) != 0 {
|
||||
t.Fatalf("projects after remove = %+v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTabWorkspaceRootSpellingsOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case-insensitive root matching only applies to Windows paths")
|
||||
}
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
flipped := flipPathASCIICase(t, projectRoot)
|
||||
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
installNoopRuntimeEvents(app)
|
||||
app.tabs["tab_case"] = &WorkspaceTab{
|
||||
ID: "tab_case",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: normalizeProjectRoot(projectRoot),
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabOrder = []string{"tab_case"}
|
||||
|
||||
// Re-registering under the flipped spelling self-heals the registry root;
|
||||
// open tabs must follow so the frontend keeps comparing one string form.
|
||||
app.registerProjectRoot(flipped)
|
||||
|
||||
projects := loadProjectsFile().Projects
|
||||
if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(flipped) {
|
||||
t.Fatalf("registry projects = %+v, want single root %q", projects, normalizeProjectRoot(flipped))
|
||||
}
|
||||
if got := app.tabs["tab_case"].WorkspaceRoot; got != projects[0].Root {
|
||||
t.Fatalf("tab root = %q, want registry spelling %q", got, projects[0].Root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTopicSessionAfterCaseFlippedReaddOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("case-insensitive root matching only applies to Windows paths")
|
||||
}
|
||||
isolateDesktopUserDirs(t)
|
||||
projectRoot := t.TempDir()
|
||||
flipped := flipPathASCIICase(t, projectRoot)
|
||||
|
||||
// Register under original spelling.
|
||||
if err := addProject(projectRoot, "Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := prependTopicInProjectsFile(projectRoot, "topic_case", true); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
|
||||
// Write a session file with the original root spelling in its meta.
|
||||
sessionDir := desktopSessionDir(projectRoot)
|
||||
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(sessionDir, "topic-case.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write session file: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
|
||||
TopicID: "topic_case",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
|
||||
// Re-add under the flipped-case spelling — simulates Windows Explorer
|
||||
// or a different shell returning the same folder with different case.
|
||||
app := NewApp()
|
||||
installNoopRuntimeEvents(app)
|
||||
app.registerProjectRoot(flipped)
|
||||
|
||||
// findTopicSessionForTarget must match the session whose meta carries
|
||||
// the original case spelling against the registry's new (flipped) root.
|
||||
path, _ := app.findTopicSessionForTarget("project", normalizeProjectRoot(flipped), "topic_case")
|
||||
if path == "" {
|
||||
t.Fatal("findTopicSessionForTarget returned empty path; session with old-case root should still match")
|
||||
}
|
||||
if path != sessionPath {
|
||||
t.Fatalf("findTopicSessionForTarget = %q, want %q", path, sessionPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogDefaultDirectoryFallsBackFromMissingWorkspace(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
missing := filepath.Join(parent, "deleted", "project")
|
||||
|
||||
+9
-10
@@ -11,7 +11,7 @@ changed and how to move over.
|
||||
| Language | TypeScript / Node | Go |
|
||||
| Branch | [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) (maintenance only) | `main-v2` (default, active) |
|
||||
| Versions | `0.x` (up to v0.54.x) | `1.0.0`+ |
|
||||
| Install | `npm i -g reasonix` (the `latest` tag, stays on `0.x`) | `npm i -g reasonix@next` — `latest` deliberately stays on `0.x`; or a release archive / `go build` |
|
||||
| Install | `npm i -g reasonix@0.53.2` (pin a `0.x` version) | `npm i -g reasonix` — `latest` points at the current `1.x` stable; or a release archive / `go build` |
|
||||
| Code intelligence | embedding semantic search + tree-sitter symbols | LSP-assisted code reading plus grep/read_file/glob; semantic index is not yet ported |
|
||||
|
||||
"v1" and "v2" are **codebase generations**, not semver: the v1 line never reached
|
||||
@@ -23,19 +23,18 @@ changed and how to move over.
|
||||
same way esbuild/biome ship native binaries via npm). The binary itself is a
|
||||
standalone Go executable; npm is only the installer, not a runtime dependency.
|
||||
|
||||
**`npm i -g reasonix` deliberately still installs `0.x`.** A bare install — and
|
||||
`npx reasonix`, and 0.53's own `update` — follows npm's `latest` tag, which we
|
||||
keep pinned to the `0.x` line so existing users aren't pulled into the rewrite
|
||||
without asking. v1.x (Go) ships under the `next` tag; opt in explicitly:
|
||||
**`npm i -g reasonix` installs the current `1.x` stable.** npm's `latest` tag
|
||||
moved to the Go line with `1.17.5` — the earlier "`latest` stays pinned to
|
||||
`0.x`" migration guard silently downgraded `npm update -g` users once 1.x went
|
||||
stable (#5822), so it was retired. Release candidates still ship under the
|
||||
`next` tag; `0.x` stays installable by pinning:
|
||||
|
||||
```sh
|
||||
npm i -g reasonix@next # or pin a version: reasonix@1.1.0
|
||||
reasonix
|
||||
npm i -g reasonix # current 1.x stable
|
||||
npm i -g reasonix@next # release candidate, when one is ahead of stable
|
||||
npm i -g reasonix@0.53.2 # pin the legacy TS build
|
||||
```
|
||||
|
||||
`latest` will stay on `0.x` for the foreseeable future, so installing or
|
||||
updating v2 always means `@next` (or a pinned `1.x`).
|
||||
|
||||
Prebuilt archives (`reasonix-<os>-<arch>.tar.gz` / `.zip`) and the desktop
|
||||
installer are attached to each GitHub release. These are a **separate channel**
|
||||
from npm: the installer drops a standalone desktop/binary build and does not
|
||||
|
||||
+13
-6
@@ -17,8 +17,8 @@ directory.
|
||||
`https://github.com/obra/superpowers`.
|
||||
- A GitHub branch or subdirectory URL, such as
|
||||
`https://github.com/owner/repo/tree/main/path/to/plugin`.
|
||||
- A local directory that contains `reasonix-plugin.json` or
|
||||
`.codex-plugin/plugin.json`.
|
||||
- A local directory that contains `reasonix-plugin.json`,
|
||||
`.codex-plugin/plugin.json`, or `.claude-plugin/plugin.json`.
|
||||
|
||||
Preview the install plan without writing files:
|
||||
|
||||
@@ -222,12 +222,19 @@ Reasonix plugins can declare `reasonix-plugin.json` at the plugin root:
|
||||
Relative paths are resolved inside the plugin root. Reasonix does not run
|
||||
third-party install scripts during plugin installation.
|
||||
|
||||
## Codex Compatibility
|
||||
## Codex & Claude Compatibility
|
||||
|
||||
Reasonix also reads Codex plugin manifests at `.codex-plugin/plugin.json`.
|
||||
For packages such as Superpowers and Claude-style skill packs, Reasonix maps:
|
||||
Reasonix also reads Codex plugin manifests at `.codex-plugin/plugin.json` and
|
||||
Claude Marketplace manifests at `.claude-plugin/plugin.json`. Claude plugin
|
||||
capabilities Reasonix does not map yet (`commands/`, `agents/`,
|
||||
`hooks/hooks.json`, `.mcp.json`) surface as install warnings instead of being
|
||||
silently dropped; multi-plugin `marketplace.json` indexes are not supported —
|
||||
install each plugin directory individually. For packages such
|
||||
as Superpowers and Claude-style skill packs, Reasonix maps:
|
||||
|
||||
- `skills` to Reasonix skill roots.
|
||||
- `skills` to Reasonix skill roots. A Claude manifest that declares no
|
||||
`skills` field falls back to the conventional `skills/` (or `.claude/skills/`)
|
||||
directory, matching Claude's own auto-discovery.
|
||||
- `hooks/session-start-codex` to the Reasonix `SessionStart` hook when present.
|
||||
- A plugin-root `CLAUDE.md` file to a built-in `SessionStart` context hook. The
|
||||
file is read directly by Reasonix, without spawning a shell command.
|
||||
|
||||
@@ -15,8 +15,8 @@ Reasonix 插件包把 skills、hooks 和 MCP servers 组织成一个可安装单
|
||||
`https://github.com/obra/superpowers`。
|
||||
- GitHub 分支或子目录 URL,例如
|
||||
`https://github.com/owner/repo/tree/main/path/to/plugin`。
|
||||
- 本地目录,目录内需要包含 `reasonix-plugin.json` 或
|
||||
`.codex-plugin/plugin.json`。
|
||||
- 本地目录,目录内需要包含 `reasonix-plugin.json`、
|
||||
`.codex-plugin/plugin.json` 或 `.claude-plugin/plugin.json`。
|
||||
|
||||
只预览安装计划,不写文件:
|
||||
|
||||
@@ -204,12 +204,16 @@ Reasonix 原生插件在根目录声明 `reasonix-plugin.json`:
|
||||
|
||||
相对路径都按插件根目录解析。Reasonix 安装插件时不会执行第三方安装脚本。
|
||||
|
||||
## Codex 兼容
|
||||
## Codex 与 Claude 兼容
|
||||
|
||||
Reasonix 也会读取 `.codex-plugin/plugin.json`。对于 Superpowers 和 Claude 风格
|
||||
skill 包,Reasonix 会映射:
|
||||
Reasonix 也会读取 `.codex-plugin/plugin.json` 和 `.claude-plugin/plugin.json`。
|
||||
Reasonix 尚未映射的 Claude 插件能力(`commands/`、`agents/`、`hooks/hooks.json`、
|
||||
`.mcp.json`)会以安装警告的形式提示,而不是被静默丢弃;多插件的
|
||||
`marketplace.json` 索引暂不支持——请逐个安装插件目录。
|
||||
对于 Superpowers 和 Claude 风格 skill 包,Reasonix 会映射:
|
||||
|
||||
- `skills` 到 Reasonix skill root。
|
||||
- `skills` 到 Reasonix skill root。Claude 清单若未声明 `skills` 字段,会回退到
|
||||
约定目录 `skills/`(或 `.claude/skills/`),与 Claude 自身的自动发现一致。
|
||||
- 如果存在 `hooks/session-start-codex`,映射为 Reasonix `SessionStart` hook。
|
||||
- 插件根目录的 `CLAUDE.md` 会映射为内置的 `SessionStart` 上下文 hook。
|
||||
Reasonix 会直接读取该文件,不通过 shell 命令。
|
||||
|
||||
+10
-7
@@ -17,7 +17,7 @@ provides the pre-release buffer instead of a long-lived branch.
|
||||
|
||||
| Surface | Stable | Pre-release buffer |
|
||||
|---|---|---|
|
||||
| npm | `latest` (0.x), `next` (1.x) | `canary` (`npm i reasonix@canary`) |
|
||||
| npm | `latest` (current 1.x stable) | `next` (rc), `canary` (`npm i reasonix@canary`) |
|
||||
| Desktop | R2 `latest/` pointer + release gateway | R2 `canary/` pointer + release gateway proxy (never on the GitHub releases page) |
|
||||
|
||||
A canary build is isolated: it **never** moves `latest` / `next` / desktop `latest/`.
|
||||
@@ -53,15 +53,18 @@ the `release` environment deployment.
|
||||
5. **Ship stable** when the canary is clean — push the three tags:
|
||||
```sh
|
||||
git tag v1.4.0 && git push origin v1.4.0 # CLI binaries + Homebrew
|
||||
git tag npm-v1.4.0 && git push origin npm-v1.4.0 # npm -> next
|
||||
git tag npm-v1.4.0 && git push origin npm-v1.4.0 # npm -> latest
|
||||
git tag desktop-v1.4.0 && git push origin desktop-v1.4.0 # desktop -> R2 latest/
|
||||
```
|
||||
Each stable run **waits for esengine to approve the `release` environment** before publishing.
|
||||
6. **Promote to default install** (optional, when 1.x should become the bare `npm i` target):
|
||||
```sh
|
||||
npm dist-tag add reasonix@1.4.0 latest
|
||||
```
|
||||
7. **Next cycle** — the canary rolls on toward `1.5.0`.
|
||||
A stable `npm-v*` publish moves the `latest` dist-tag automatically (build.mjs)
|
||||
and release-npm.yml verifies it landed. **Do not skip the npm tag**: the stable
|
||||
CLI release (release.yml) fails when the matching `npm-v*` tag was never pushed
|
||||
— that guard exists because 1.0.0–1.17.5 shipped without stable npm tags and
|
||||
`npm update -g` silently downgraded users to 0.53.2 (#5822). A pushed tag whose
|
||||
publish is still awaiting approval only warns; release-npm.yml's verify step
|
||||
owns asserting the dist-tag lands.
|
||||
6. **Next cycle** — the canary rolls on toward `1.5.0`.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ func TestDeleteSessionFilesSweepsEventLogAndSidecars(t *testing.T) {
|
||||
if err := os.WriteFile(acpMetaPath(path), []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("write acp meta: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(store.SessionConflictLog(path), []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("write conflict log: %v", err)
|
||||
}
|
||||
|
||||
if err := deleteSessionFiles(path); err != nil {
|
||||
t.Fatalf("deleteSessionFiles: %v", err)
|
||||
@@ -35,6 +38,7 @@ func TestDeleteSessionFilesSweepsEventLogAndSidecars(t *testing.T) {
|
||||
store.SessionMeta(path),
|
||||
store.SessionEventLog(path),
|
||||
store.SessionEventIndex(path),
|
||||
store.SessionConflictLog(path),
|
||||
} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("artifact survived delete: %s (err=%v)", p, err)
|
||||
|
||||
+74
-10
@@ -24,6 +24,7 @@ import (
|
||||
"reasonix/internal/planmode"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/secrets"
|
||||
"reasonix/internal/shellparse"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
@@ -343,6 +344,13 @@ type Agent struct {
|
||||
// provider-visible execution contract. compact preserves the old injection.
|
||||
memoryCompilerVerbosity string
|
||||
compilerTurn *memorycompiler.Turn
|
||||
// lastCompilerOutcome is the previous finished turn's persisted outcome.
|
||||
// The immediately following user message may retroactively downgrade it
|
||||
// when it reports the result wrong. Every non-synthetic turn start
|
||||
// consumes it (one-shot) — even while the runtime is nil — so a ref can
|
||||
// never survive intervening turns and be replayed after Memory v5 is
|
||||
// re-enabled. Session switches clear it. Guarded by memoryCompilerMu.
|
||||
lastCompilerOutcome *memorycompiler.OutcomeRef
|
||||
|
||||
// compilerInjectionMu bounds how often Memory v5 may replace a visible user
|
||||
// turn with an execution contract. The runtime can still observe throttled
|
||||
@@ -531,6 +539,25 @@ func (a *Agent) clearClassifierCache() {
|
||||
}
|
||||
}
|
||||
|
||||
// reviseMemoryCompilerOutcomeForFeedback retroactively downgrades the previous
|
||||
// turn's recorded success when the user's immediate follow-up reports the
|
||||
// result wrong. The ref is consumed unconditionally so it can never outlive
|
||||
// the turn that follows it; the revision itself additionally requires a live
|
||||
// runtime and corrective feedback.
|
||||
func (a *Agent) reviseMemoryCompilerOutcomeForFeedback(rt *memorycompiler.Runtime, input string) {
|
||||
a.memoryCompilerMu.Lock()
|
||||
ref := a.lastCompilerOutcome
|
||||
a.lastCompilerOutcome = nil
|
||||
a.memoryCompilerMu.Unlock()
|
||||
if ref == nil || rt == nil {
|
||||
return
|
||||
}
|
||||
if !memorycompiler.IsCorrectiveFeedback(input) {
|
||||
return
|
||||
}
|
||||
rt.ReviseOutcomeFromFeedback(*ref, input)
|
||||
}
|
||||
|
||||
func shouldStartMemoryCompiler(input string) bool {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
@@ -682,6 +709,12 @@ func (a *Agent) SetSession(s *Session) {
|
||||
a.rebuildTodoState(s.Snapshot())
|
||||
}
|
||||
a.resetMemoryCompilerInjectionGate()
|
||||
// A session switch breaks the "immediately preceding turn" relationship:
|
||||
// the next input belongs to a different conversation, so the pending
|
||||
// outcome ref must not be revisable from it.
|
||||
a.memoryCompilerMu.Lock()
|
||||
a.lastCompilerOutcome = nil
|
||||
a.memoryCompilerMu.Unlock()
|
||||
// 清除分类缓存(会话边界)
|
||||
a.clearClassifierCache()
|
||||
}
|
||||
@@ -990,6 +1023,12 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||||
memoryCompilerInput = sourceInput
|
||||
}
|
||||
input = a.withTurnPreferences(rawInput)
|
||||
// Consume the previous turn's outcome ref on every non-synthetic turn,
|
||||
// even while the runtime is nil (/memory-v5 off): revision must only ever
|
||||
// target the immediately preceding turn.
|
||||
if !MemoryCompilerSkipFromContext(ctx) {
|
||||
a.reviseMemoryCompilerOutcomeForFeedback(a.memoryCompilerRuntime(), memoryCompilerInput)
|
||||
}
|
||||
if memCompiler := a.memoryCompilerRuntime(); memCompiler != nil && !MemoryCompilerSkipFromContext(ctx) && shouldStartMemoryCompiler(memoryCompilerInput) {
|
||||
// 使用分类器判断是否为任务
|
||||
isTask := true // 默认为任务
|
||||
@@ -1015,6 +1054,10 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||||
a.emitMemoryCompilerStats(turn)
|
||||
defer func() {
|
||||
turn.Finish(runErr)
|
||||
ref := turn.OutcomeRef()
|
||||
a.memoryCompilerMu.Lock()
|
||||
a.lastCompilerOutcome = &ref
|
||||
a.memoryCompilerMu.Unlock()
|
||||
if a.compilerTurn == turn {
|
||||
a.compilerTurn = nil
|
||||
}
|
||||
@@ -1112,7 +1155,7 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||||
return fmt.Errorf("final-answer readiness failed %d times: %s", finalReadinessBlocks, readiness.reason)
|
||||
}
|
||||
event.RecordReadinessAudit(a.sink, readiness.audit(result, false))
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "final-answer readiness blocked: " + readiness.reason})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: finalReadinessNoticeText(), Detail: readiness.reason})
|
||||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(finalReadinessRetryMessage(readiness.reason))})
|
||||
a.maybeCompact(ctx, usage)
|
||||
continue
|
||||
@@ -1122,14 +1165,14 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||||
if emptyFinalBlocks >= maxEmptyFinalBlocks {
|
||||
return fmt.Errorf("model finished without a visible final answer %d times", emptyFinalBlocks)
|
||||
}
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: emptyFinalNotice(a.prov.Name(), usage, len(reasoning))})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: emptyFinalNotice(), Detail: emptyFinalNoticeDetail(a.prov.Name(), usage, len(reasoning))})
|
||||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(emptyFinalRetryMessage())})
|
||||
a.maybeCompact(ctx, usage)
|
||||
continue
|
||||
}
|
||||
if executorHandoff && !usedAnyTool && handoffNudges < maxExecutorHandoffNudges && shouldNudgeExecutorHandoff(input, text) {
|
||||
handoffNudges++
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "executor answered without taking any action; nudging it to use its tools"})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: executorHandoffNoticeText(), Detail: "executor answered without taking any action; nudging it to use its tools"})
|
||||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(executorHandoffRetryMessage())})
|
||||
a.maybeCompact(ctx, usage)
|
||||
continue
|
||||
@@ -1180,7 +1223,7 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||||
graceRound = true
|
||||
nudge := fmt.Sprintf("Do not call any more tools — your tool-call round limit (%s) has been reached. Instead, synthesize a final answer from all the work already completed: summarize what was accomplished, what remains to be done, and any decisions the user should make. The user can increase %s or continue in the next turn if more work is needed.", a.maxStepsKey, a.maxStepsKey)
|
||||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("budget (%s=%d) exhausted: one grace round to finalize", a.maxStepsKey, a.maxSteps)})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: toolBudgetNoticeText(), Detail: fmt.Sprintf("budget (%s=%d) exhausted: one grace round to finalize", a.maxStepsKey, a.maxSteps)})
|
||||
}
|
||||
}
|
||||
// Only reached when a positive maxSteps guard is configured. The work so far
|
||||
@@ -1334,6 +1377,10 @@ func finalReadinessIncompleteTodos(items []evidence.TodoStepMatch) string {
|
||||
return "latest successful todo_write still has incomplete items: " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func finalReadinessNoticeText() string {
|
||||
return "Task status needs one more check; asking the assistant to finish or explain what is blocking it."
|
||||
}
|
||||
|
||||
func (a *Agent) setTodoState(todos []evidence.TodoItem) {
|
||||
a.todoMu.Lock()
|
||||
a.todoState = append([]evidence.TodoItem(nil), todos...)
|
||||
@@ -1681,7 +1728,11 @@ func emptyFinalRetryMessage() string {
|
||||
return "The previous assistant response finished without any visible answer text. Continue the same task now and provide a concise visible answer to the user. Do not send reasoning only."
|
||||
}
|
||||
|
||||
func emptyFinalNotice(prov string, u *provider.Usage, reasoningLen int) string {
|
||||
func emptyFinalNotice() string {
|
||||
return "No visible answer was produced; asking the assistant to respond again."
|
||||
}
|
||||
|
||||
func emptyFinalNoticeDetail(prov string, u *provider.Usage, reasoningLen int) string {
|
||||
finish := "unknown"
|
||||
if u != nil && u.FinishReason != "" {
|
||||
finish = u.FinishReason
|
||||
@@ -1689,6 +1740,14 @@ func emptyFinalNotice(prov string, u *provider.Usage, reasoningLen int) string {
|
||||
return fmt.Sprintf("empty final answer blocked: %s returned no visible answer text (finish=%s, reasoning=%d chars); retrying", prov, finish, reasoningLen)
|
||||
}
|
||||
|
||||
func executorHandoffNoticeText() string {
|
||||
return "The assistant answered before taking action; asking it to use the required tools."
|
||||
}
|
||||
|
||||
func toolBudgetNoticeText() string {
|
||||
return "Tool round limit reached; asking the assistant to summarize progress."
|
||||
}
|
||||
|
||||
func streamRecoveryMessage(hasPartialText, hadPartialTool bool) string {
|
||||
switch {
|
||||
case hadPartialTool:
|
||||
@@ -2136,7 +2195,7 @@ func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutc
|
||||
}
|
||||
|
||||
const blockedAdvice = "Change approach: do not keep retrying a blocked tool by changing the tool, command, or arguments. Respect the permission, plan-mode, hook, or loop-guard blocker; use an already-allowed tool, ask the user for the specific approval or choice if appropriate, or explain the blocker in your final answer."
|
||||
var guard, notice string
|
||||
var guard, detail string
|
||||
if stormHit {
|
||||
subject := fmt.Sprintf("%q", calls[0].Name)
|
||||
short := calls[0].Name
|
||||
@@ -2160,22 +2219,26 @@ func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutc
|
||||
guard = fmt.Sprintf(
|
||||
"[loop guard] %s has now %s %d times in a row with the same host response. Re-sending it — even with the wording changed — will not help: the calls keep hitting the same outcome. %s",
|
||||
subject, action, a.stormCount, advice)
|
||||
notice = fmt.Sprintf(
|
||||
detail = fmt.Sprintf(
|
||||
"loop guard: %s hit the same host response %d× — nudging the model to change approach",
|
||||
short, a.stormCount)
|
||||
} else {
|
||||
guard = fmt.Sprintf(
|
||||
"[loop guard] every tool call in the last %d turns has been blocked by the host (permission, plan mode, hook, or loop guard). Switching tools, reordering calls, or rewording arguments will not help while the blockers stand. %s",
|
||||
a.blockedTurnStreak, blockedAdvice)
|
||||
notice = fmt.Sprintf(
|
||||
detail = fmt.Sprintf(
|
||||
"loop guard: every tool call blocked %d turns in a row — nudging the model to change approach",
|
||||
a.blockedTurnStreak)
|
||||
}
|
||||
results[0] = outcomes[0].output + "\n\n" + guard
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: notice})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: loopGuardNoticeText(), Detail: detail})
|
||||
a.armLoopGuardPass(receiptMark)
|
||||
}
|
||||
|
||||
func loopGuardNoticeText() string {
|
||||
return "The assistant is stuck retrying a blocked action; asking it to change approach."
|
||||
}
|
||||
|
||||
// batchStormSignature returns a per-turn fixation signature — each call's
|
||||
// (name, error/blocker) in order — and ok=true only when every call errored or
|
||||
// was blocked. ok=false (any success) means the turn made progress, so the
|
||||
@@ -2356,9 +2419,10 @@ func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) toolOutc
|
||||
}
|
||||
callID := call.ID
|
||||
cctx = tool.WithProgress(cctx, func(chunk string) {
|
||||
a.sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: callID, Output: chunk}})
|
||||
a.sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: callID, Output: secrets.RedactToolOutput(chunk)}})
|
||||
})
|
||||
result, err := t.Execute(cctx, json.RawMessage(call.Arguments))
|
||||
result = secrets.RedactToolOutput(result)
|
||||
if a.evidence != nil {
|
||||
if call.Name == "complete_step" {
|
||||
rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, t.ReadOnly())
|
||||
|
||||
@@ -254,7 +254,7 @@ func TestCacheHitSurvivesTooSmallWindow(t *testing.T) {
|
||||
paused := false
|
||||
for _, n := range sink.notices {
|
||||
t.Logf("notice: %s", n)
|
||||
if strings.Contains(n, "Auto-compaction paused") {
|
||||
if strings.Contains(n, "Automatic context cleanup paused") {
|
||||
paused = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,8 @@ func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
|
||||
// rewriting the prefix — a compaction here would needlessly crater the cache.
|
||||
if u.PromptTokens >= soft && u.PromptTokens < snip && !a.softCompactNoticed {
|
||||
a.softCompactNoticed = true
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("context reached %.0f%% of window; keeping cache-first prefix until compact threshold %.0f%%", a.softCompactRatio*100, a.compactRatio*100)})
|
||||
detail := fmt.Sprintf("context reached %.0f%% of window; keeping cache-first prefix until compact threshold %.0f%%", a.softCompactRatio*100, a.compactRatio*100)
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Context is getting large; preserving cache until cleanup is needed.", Detail: detail})
|
||||
return
|
||||
}
|
||||
if u.PromptTokens >= snip && u.PromptTokens < high {
|
||||
@@ -128,7 +129,7 @@ func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
|
||||
}
|
||||
}
|
||||
if err := a.compact(ctx, "auto", "", force); err != nil {
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("compaction skipped: %v", err)})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Context cleanup skipped for now.", Detail: fmt.Sprintf("compaction skipped: %v", err)})
|
||||
return
|
||||
}
|
||||
// A healthy compaction drops the prompt under the trigger, so the next turn
|
||||
@@ -139,7 +140,7 @@ func (a *Agent) maybeCompact(ctx context.Context, u *provider.Usage) {
|
||||
a.consecutiveCompacts++
|
||||
if a.consecutiveCompacts >= 2 {
|
||||
a.compactStuck = true
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf(
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Automatic context cleanup paused because the context window is too small.", Detail: fmt.Sprintf(
|
||||
"context_window=%d is too small for compaction to help (the system prompt plus one turn already exceeds %.0f%% of it); raise context_window or shrink tool output. Auto-compaction paused until the prompt drops.",
|
||||
a.contextWindow, a.compactRatio*100)})
|
||||
}
|
||||
@@ -258,7 +259,7 @@ func (a *Agent) compact(ctx context.Context, trigger, instructions string, force
|
||||
// deterministic marker rather than aborting. /compact then always frees
|
||||
// context (and auto-compaction can't loop on a still-full window); the
|
||||
// verbatim user turns kept above are untouched.
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "compaction summary unavailable (" + err.Error() + "); folded mechanically"})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Context was compacted without a generated summary.", Detail: "compaction summary unavailable (" + err.Error() + "); folded mechanically"})
|
||||
summary = mechanicalFoldDigest(len(fold), archived)
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ func compactionsPerTurn(t *testing.T, windowTok int, blob, finalText string, tur
|
||||
case event.CompactionStarted:
|
||||
started++
|
||||
case event.Notice:
|
||||
if strings.Contains(e.Text, "Auto-compaction paused") {
|
||||
if strings.Contains(e.Text, "Automatic context cleanup paused") {
|
||||
paused = true
|
||||
}
|
||||
if strings.Contains(e.Text, "pruned") {
|
||||
|
||||
@@ -663,7 +663,7 @@ func TestMaybeCompactThreshold(t *testing.T) {
|
||||
if len(prov.got) != 0 {
|
||||
t.Fatalf("soft threshold called summarizer: %+v", prov.got)
|
||||
}
|
||||
if len(notices) != 1 || !strings.Contains(notices[0].Text, "context reached 50%") {
|
||||
if len(notices) != 1 || notices[0].Text != "Context is getting large; preserving cache until cleanup is needed." || !strings.Contains(notices[0].Detail, "context reached 50%") {
|
||||
t.Fatalf("soft threshold notice = %+v", notices)
|
||||
}
|
||||
a.maybeCompact(context.Background(), &provider.Usage{PromptTokens: 60})
|
||||
|
||||
@@ -62,7 +62,7 @@ func hostAdvances(sink *recordSink) int {
|
||||
|
||||
func readinessBlocked(sink *recordSink) bool {
|
||||
for _, e := range sink.kinds(event.Notice) {
|
||||
if strings.Contains(e.Text, "readiness blocked") {
|
||||
if e.Text == finalReadinessNoticeText() && strings.Contains(e.Detail, "latest successful todo_write") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinal(t *testing.T) {
|
||||
{toolCallChunk("b3", "bash", `{"command":"ls -la"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "blocked by permission"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
sink, notices := warnNoticeRecorder()
|
||||
sink, notices := noticeRecorder()
|
||||
a := New(prov, reg, NewSession(""), Options{
|
||||
Gate: &stubGate{deny: map[string]bool{"bash": true}},
|
||||
}, sink)
|
||||
@@ -454,7 +454,7 @@ func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinal(t *testing.T) {
|
||||
t.Fatalf("bash results = %d, want exactly %d blocked attempts", len(got), stormBreakThreshold)
|
||||
}
|
||||
if len(*notices) == 0 {
|
||||
t.Fatal("loop guard should emit a user-facing warning notice")
|
||||
t.Fatal("loop guard should emit a user-facing notice")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,18 @@ func TestFinalReadinessCheckAuditsIncompleteTodos(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalReadinessNoticeTextHidesInternalReason(t *testing.T) {
|
||||
msg := finalReadinessNoticeText()
|
||||
for _, hidden := range []string{"final-answer", "readiness blocked", "todo_write", "latest successful"} {
|
||||
if strings.Contains(strings.ToLower(msg), hidden) {
|
||||
t.Fatalf("finalReadinessNoticeText() = %q, should not expose %q", msg, hidden)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(msg), "finish or explain") {
|
||||
t.Fatalf("finalReadinessNoticeText() = %q, want user-facing recovery action", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalReadinessAllowsFinalAfterLoopGuardedToolBlocker(t *testing.T) {
|
||||
todo := evidence.Receipt{ToolName: "todo_write", Success: true, Todos: []evidence.TodoItem{{Content: "edit", Status: "in_progress"}}}
|
||||
writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Paths: []string{"a.go"}}
|
||||
|
||||
@@ -83,17 +83,22 @@ func TestFinishReasonMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyFinalNotice carries the diagnostics that tell the three empty-answer
|
||||
// causes apart in reports: which provider, how it stopped, and whether the model
|
||||
// produced reasoning-only output.
|
||||
// TestEmptyFinalNotice keeps the user-facing line short while preserving the
|
||||
// diagnostics that tell empty-answer causes apart in expandable details.
|
||||
func TestEmptyFinalNotice(t *testing.T) {
|
||||
msg := emptyFinalNotice("deepseek-flash", &provider.Usage{FinishReason: "stop"}, 512)
|
||||
for _, want := range []string{"deepseek-flash", "finish=stop", "reasoning=512"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("notice %q missing %q", msg, want)
|
||||
msg := emptyFinalNotice()
|
||||
for _, hidden := range []string{"blocked", "finish=", "reasoning="} {
|
||||
if strings.Contains(msg, hidden) {
|
||||
t.Errorf("notice %q should not expose internal diagnostic %q", msg, hidden)
|
||||
}
|
||||
}
|
||||
if got := emptyFinalNotice("p", nil, 0); !strings.Contains(got, "finish=unknown") {
|
||||
detail := emptyFinalNoticeDetail("deepseek-flash", &provider.Usage{FinishReason: "stop"}, 512)
|
||||
for _, want := range []string{"deepseek-flash", "finish=stop", "reasoning=512"} {
|
||||
if !strings.Contains(detail, want) {
|
||||
t.Errorf("notice detail %q missing %q", detail, want)
|
||||
}
|
||||
}
|
||||
if got := emptyFinalNoticeDetail("p", nil, 0); !strings.Contains(got, "finish=unknown") {
|
||||
t.Errorf("nil usage should report finish=unknown, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent/testutil"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/memorycompiler"
|
||||
)
|
||||
|
||||
// readCompilerStrategies decodes the persisted Memory v5 strategy counters so
|
||||
// the test can observe learning-side effects without exporting runtime state.
|
||||
func readCompilerStrategies(t *testing.T, dir string) []memorycompiler.Strategy {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(dir, "state.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read state: %v", err)
|
||||
}
|
||||
var st struct {
|
||||
Strategies []memorycompiler.Strategy `json:"strategies"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
t.Fatalf("unmarshal state: %v", err)
|
||||
}
|
||||
return st.Strategies
|
||||
}
|
||||
|
||||
// requireBugfixCounters asserts the persisted bugfix strategy's success/failure
|
||||
// counters.
|
||||
func requireBugfixCounters(t *testing.T, dir, when string, successes, failures int) {
|
||||
t.Helper()
|
||||
for _, s := range readCompilerStrategies(t, dir) {
|
||||
if s.ID != "bugfix-reproduce-first" {
|
||||
continue
|
||||
}
|
||||
if s.Successes != successes || s.Failures != failures {
|
||||
t.Fatalf("%s: counters = %d ok / %d fail, want %d ok / %d fail (%+v)",
|
||||
when, s.Successes, s.Failures, successes, failures, s)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("%s: bugfix strategy missing from state", when)
|
||||
}
|
||||
|
||||
func TestCorrectiveFollowUpRevisesPreviousOutcome(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rt := memorycompiler.New(dir)
|
||||
mp := testutil.NewMock("m", testutil.Turn{Text: "done"}, testutil.Turn{Text: "sorry"})
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
|
||||
t.Fatalf("turn 1: %v", err)
|
||||
}
|
||||
for _, s := range readCompilerStrategies(t, dir) {
|
||||
if s.ID == "bugfix-reproduce-first" && (s.Successes != 1 || s.Failures != 0) {
|
||||
t.Fatalf("turn 1 not recorded as success: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.Run(context.Background(), "不对,还是报错"); err != nil {
|
||||
t.Fatalf("turn 2: %v", err)
|
||||
}
|
||||
for _, s := range readCompilerStrategies(t, dir) {
|
||||
if s.ID != "bugfix-reproduce-first" {
|
||||
continue
|
||||
}
|
||||
if s.Successes != 0 || s.Failures < 1 {
|
||||
t.Fatalf("corrective follow-up did not revise turn 1 outcome: %+v", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("bugfix strategy missing from state")
|
||||
}
|
||||
|
||||
func TestNeutralFollowUpDoesNotReviseOutcome(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rt := memorycompiler.New(dir)
|
||||
mp := testutil.NewMock("m", testutil.Turn{Text: "done"}, testutil.Turn{Text: "done again"})
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
|
||||
t.Fatalf("turn 1: %v", err)
|
||||
}
|
||||
if err := a.Run(context.Background(), "fix another bug in the lexer"); err != nil {
|
||||
t.Fatalf("turn 2: %v", err)
|
||||
}
|
||||
for _, s := range readCompilerStrategies(t, dir) {
|
||||
if s.ID != "bugfix-reproduce-first" {
|
||||
continue
|
||||
}
|
||||
if s.Successes != 2 || s.Failures != 0 {
|
||||
t.Fatalf("neutral follow-up moved counters: %+v", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("bugfix strategy missing from state")
|
||||
}
|
||||
|
||||
// TestStaleOutcomeRefNotRevisableAfterCompilerToggle covers the reviewer
|
||||
// scenario: success turn records a ref → /memory-v5 off → intervening turn →
|
||||
// on again → corrective input. The intervening turn must consume the ref even
|
||||
// though the runtime was nil, so the old trace is not downgraded.
|
||||
func TestStaleOutcomeRefNotRevisableAfterCompilerToggle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rt := memorycompiler.New(dir)
|
||||
mp := testutil.NewMock("m",
|
||||
testutil.Turn{Text: "done"},
|
||||
testutil.Turn{Text: "chat answer"},
|
||||
testutil.Turn{Text: "sorry"},
|
||||
)
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
|
||||
t.Fatalf("turn 1: %v", err)
|
||||
}
|
||||
requireBugfixCounters(t, dir, "after turn 1", 1, 0)
|
||||
|
||||
a.SetMemoryCompiler(nil) // /memory-v5 off
|
||||
if err := a.Run(context.Background(), "tell me about the weather"); err != nil {
|
||||
t.Fatalf("intervening turn: %v", err)
|
||||
}
|
||||
a.SetMemoryCompiler(memorycompiler.New(dir)) // /memory-v5 on
|
||||
|
||||
if err := a.Run(context.Background(), "不对,还是报错"); err != nil {
|
||||
t.Fatalf("corrective turn: %v", err)
|
||||
}
|
||||
// The corrective input must not revise turn 1: its ref was consumed by the
|
||||
// intervening turn. (The corrective turn itself may add new samples, so
|
||||
// only assert turn 1's success survived.)
|
||||
for _, s := range readCompilerStrategies(t, dir) {
|
||||
if s.ID == "bugfix-reproduce-first" && s.Successes != 1 {
|
||||
t.Fatalf("stale ref revised turn 1 after compiler toggle: %+v", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutcomeRefClearedOnSessionSwitch: switching sessions breaks the
|
||||
// "immediately preceding turn" relationship, so the first input of the new
|
||||
// session must not revise the old session's outcome even without intervening
|
||||
// turns.
|
||||
func TestOutcomeRefClearedOnSessionSwitch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rt := memorycompiler.New(dir)
|
||||
mp := testutil.NewMock("m",
|
||||
testutil.Turn{Text: "done"},
|
||||
testutil.Turn{Text: "sorry"},
|
||||
)
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
|
||||
t.Fatalf("turn 1: %v", err)
|
||||
}
|
||||
requireBugfixCounters(t, dir, "after turn 1", 1, 0)
|
||||
|
||||
a.SetSession(NewSession("")) // resume/switch to another conversation
|
||||
if err := a.Run(context.Background(), "不对,还是报错"); err != nil {
|
||||
t.Fatalf("new-session turn: %v", err)
|
||||
}
|
||||
for _, s := range readCompilerStrategies(t, dir) {
|
||||
if s.ID == "bugfix-reproduce-first" && s.Successes != 1 {
|
||||
t.Fatalf("new session's first input revised the old session's outcome: %+v", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-17
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"reasonix/internal/fileutil"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/secrets"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
@@ -200,7 +201,17 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
// snapshot) that captured before locking could land out of order: the
|
||||
// stalest capture written last would then read the newer transcript it
|
||||
// lost the race to as a bogus stale-prefix conflict.
|
||||
msgs, version := s.snapshotWithVersion()
|
||||
msgs, version, rewriteVersion := s.snapshotWithVersion()
|
||||
// Durable transcripts are always redacted, independent of the live
|
||||
// [secrets] redact_tool_output toggle. Digest consistency holds because
|
||||
// Redact is deterministic and idempotent (see secrets.Redact): a loaded
|
||||
// session's messages are already redacted, so re-redacting them here is a
|
||||
// byte-for-byte no-op and the snapshot keeps its prefix relationship to
|
||||
// the on-disk transcript — the same digest/prefix machinery that guards
|
||||
// against bogus stale-prefix conflicts (#6083) sees identical bytes. The
|
||||
// persisted baseline (markPersisted) is likewise recorded over the
|
||||
// redacted form, matching what LoadSession will digest back.
|
||||
msgs = secrets.RedactMessages(msgs)
|
||||
digest, contentBytes, err := digestAndSizeSessionMessages(msgs)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -249,10 +260,10 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
s.markPersisted(path, digest, version, revision)
|
||||
s.markPersisted(path, digest, version, revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
s.markPersisted(path, digest, version, decision.revision)
|
||||
s.markPersisted(path, digest, version, decision.revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
if decision.appendOnly && probe.native {
|
||||
@@ -289,7 +300,7 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
// as a stale-runtime conflict.
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
}
|
||||
s.markPersisted(path, digest, version, revision)
|
||||
s.markPersisted(path, digest, version, revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
baseRevision = decision.revision
|
||||
@@ -351,7 +362,7 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
s.markPersisted(path, digest, version, revision)
|
||||
s.markPersisted(path, digest, version, revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -540,7 +551,12 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
|
||||
if originalPath == "" {
|
||||
return RecoveryBranchInfo{}, fmt.Errorf("empty original session path")
|
||||
}
|
||||
msgs, version := s.snapshotWithVersion()
|
||||
msgs, version, rewriteVersion := s.snapshotWithVersion()
|
||||
// Same redaction contract as save(): recovery branches are durable
|
||||
// transcripts too, and the coverage checks below compare against on-disk
|
||||
// content that save() already redacted — comparing a raw snapshot against
|
||||
// it would misread pure coverage as divergence and fork a bogus branch.
|
||||
msgs = secrets.RedactMessages(msgs)
|
||||
preview, turns := SessionPreviewFromMessages(msgs)
|
||||
if turns == 0 {
|
||||
return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
|
||||
@@ -626,7 +642,7 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
|
||||
if err != nil {
|
||||
return RecoveryBranchInfo{}, err
|
||||
}
|
||||
s.markPersisted(recoveryPath, digest, version, meta.Revision)
|
||||
s.markPersisted(recoveryPath, digest, version, meta.Revision, rewriteVersion)
|
||||
return RecoveryBranchInfo{Path: recoveryPath, Digest: digestText, Existing: true, Meta: meta, Preview: preview, Turns: turns}, nil
|
||||
}
|
||||
} else if loadErr != nil && !os.IsNotExist(loadErr) {
|
||||
@@ -663,7 +679,7 @@ func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranch
|
||||
slog.Warn("session: keeping recovery branch after event index write failure",
|
||||
"path", recoveryPath, "err", err)
|
||||
}
|
||||
s.markPersisted(recoveryPath, digest, version, meta.Revision)
|
||||
s.markPersisted(recoveryPath, digest, version, meta.Revision, rewriteVersion)
|
||||
return RecoveryBranchInfo{Path: recoveryPath, Digest: digestText, Meta: meta, Preview: preview, Turns: turns}, nil
|
||||
}
|
||||
|
||||
@@ -808,19 +824,19 @@ func (s *Session) persistState(path string) sessionPersistState {
|
||||
return sessionPersistState{}
|
||||
}
|
||||
|
||||
func (s *Session) markPersisted(path string, digest [sha256.Size]byte, version uint64, revision int64) {
|
||||
s.setPersistedBaseline(path, digest, version, revision, true)
|
||||
func (s *Session) markPersisted(path string, digest [sha256.Size]byte, version uint64, revision int64, rewriteVersion int) {
|
||||
s.setPersistedBaseline(path, digest, version, revision, true, rewriteVersion)
|
||||
}
|
||||
|
||||
// markPersistedRevisionUnknown records a baseline whose ledger revision could
|
||||
// not be learned because the meta sidecar was unreadable. The digest and
|
||||
// version still anchor ownership checks; revision-based CAS stays disarmed
|
||||
// until a successful save records the real revision via markPersisted.
|
||||
func (s *Session) markPersistedRevisionUnknown(path string, digest [sha256.Size]byte, version uint64) {
|
||||
s.setPersistedBaseline(path, digest, version, 0, false)
|
||||
func (s *Session) markPersistedRevisionUnknown(path string, digest [sha256.Size]byte, version uint64, rewriteVersion int) {
|
||||
s.setPersistedBaseline(path, digest, version, 0, false, rewriteVersion)
|
||||
}
|
||||
|
||||
func (s *Session) setPersistedBaseline(path string, digest [sha256.Size]byte, version uint64, revision int64, revisionKnown bool) {
|
||||
func (s *Session) setPersistedBaseline(path string, digest [sha256.Size]byte, version uint64, revision int64, revisionKnown bool, rewriteVersion int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.persisted = sessionPersistState{
|
||||
@@ -831,6 +847,12 @@ func (s *Session) setPersistedBaseline(path string, digest [sha256.Size]byte, ve
|
||||
revisionKnown: revisionKnown,
|
||||
ok: true,
|
||||
}
|
||||
// rewriteVersion was captured together with the persisted snapshot; only
|
||||
// move forward so a slower save that captured earlier cannot roll the
|
||||
// baseline back below a rewrite a faster save already persisted.
|
||||
if rewriteVersion > s.persistedRewriteVersion {
|
||||
s.persistedRewriteVersion = rewriteVersion
|
||||
}
|
||||
}
|
||||
|
||||
// sessionContentRevision reads the CAS ledger (revision + content digest) from
|
||||
@@ -1119,13 +1141,13 @@ func LoadSession(path string) (*Session, error) {
|
||||
// on-disk revision as another runtime's write and fork a recovery
|
||||
// branch. Anchor the baseline on digest+version only until a
|
||||
// successful save re-learns the revision.
|
||||
s.markPersistedRevisionUnknown(path, digest, s.version)
|
||||
s.markPersistedRevisionUnknown(path, digest, s.version, s.rewriteVersion)
|
||||
} else {
|
||||
revision := int64(0)
|
||||
if ok {
|
||||
revision = meta.Revision
|
||||
}
|
||||
s.markPersisted(path, digest, s.version, revision)
|
||||
s.markPersisted(path, digest, s.version, revision, s.rewriteVersion)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
@@ -1471,7 +1493,7 @@ func reconcileOverlongSessionFilenames(dir string) error {
|
||||
renamed := map[string]string{} // old branch ID -> new branch ID
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, guardianSidecarSuffix) {
|
||||
if e.IsDir() || !store.IsSessionTranscriptName(name) {
|
||||
continue
|
||||
}
|
||||
if len(name) <= maxSessionBasenameBytes {
|
||||
@@ -1595,6 +1617,9 @@ func migrateSessionSidecars(oldPath, newPath, newID string) error {
|
||||
}
|
||||
for _, pair := range [][2]string{
|
||||
{store.SessionGoalState(oldPath), store.SessionGoalState(newPath)},
|
||||
{store.SessionEventLog(oldPath), store.SessionEventLog(newPath)},
|
||||
{store.SessionEventIndex(oldPath), store.SessionEventIndex(newPath)},
|
||||
{store.SessionConflictLog(oldPath), store.SessionConflictLog(newPath)},
|
||||
{store.SessionCheckpointDir(oldPath), store.SessionCheckpointDir(newPath)},
|
||||
{store.SessionJobsDir(oldPath), store.SessionJobsDir(newPath)},
|
||||
} {
|
||||
@@ -1620,7 +1645,7 @@ func reparentSessionBranches(dir string, renamed map[string]string) error {
|
||||
var errs []error
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, guardianSidecarSuffix) {
|
||||
if e.IsDir() || !store.IsSessionTranscriptName(name) {
|
||||
continue
|
||||
}
|
||||
if len(name)+len(".meta") > nameMaxBytes {
|
||||
|
||||
+144
-2
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// touch sets a file's mtime to t. Used by the listing-order test so it
|
||||
@@ -67,6 +68,91 @@ func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRedactsSecretsOnDisk(t *testing.T) {
|
||||
secret := "sk-real-secret-value-123456"
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
|
||||
s.Add(provider.Message{
|
||||
Role: provider.RoleTool,
|
||||
Name: "bash",
|
||||
ToolCallID: "call_1",
|
||||
Content: "DEEPSEEK_API_KEY=" + secret + "\n",
|
||||
})
|
||||
|
||||
path := filepath.Join(t.TempDir(), "redacted.jsonl")
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved session: %v", err)
|
||||
}
|
||||
if strings.Contains(string(body), secret) {
|
||||
t.Fatalf("session persisted raw secret:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), "DEEPSEEK_API_KEY=sk-rea") {
|
||||
t.Fatalf("session did not persist a masked credential marker:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveRedactionKeepsSnapshotBaselineStable is the digest-consistency
|
||||
// contract behind redact-on-save: because Redact is idempotent, a loaded
|
||||
// (already-redacted) transcript re-saves as the exact same bytes, so
|
||||
// SaveSnapshot must take the up-to-date path — no rewrite, no revision bump,
|
||||
// and above all no bogus snapshot conflict / recovery fork (#6083's failure
|
||||
// shape). Appending after the resave must land as a plain append.
|
||||
func TestSaveRedactionKeepsSnapshotBaselineStable(t *testing.T) {
|
||||
secret := "sk-real-secret-value-123456"
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "inspect"})
|
||||
s.Add(provider.Message{
|
||||
Role: provider.RoleTool,
|
||||
Name: "bash",
|
||||
ToolCallID: "call_1",
|
||||
Content: "DEEPSEEK_API_KEY=" + secret + "\n",
|
||||
})
|
||||
|
||||
path := filepath.Join(t.TempDir(), "stable.jsonl")
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("first SaveSnapshot: %v", err)
|
||||
}
|
||||
rev1, _, err := sessionContentRevision(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read revision: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
// Re-saving the loaded transcript re-runs RedactMessages over content that
|
||||
// is already masked; idempotence means identical bytes and the up-to-date
|
||||
// skip, not a conflict or a revision bump.
|
||||
if err := loaded.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("resave of loaded redacted session: %v", err)
|
||||
}
|
||||
rev2, _, err := sessionContentRevision(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read revision after resave: %v", err)
|
||||
}
|
||||
if rev1 != rev2 {
|
||||
t.Fatalf("no-op resave bumped revision %d -> %d; redaction broke digest stability", rev1, rev2)
|
||||
}
|
||||
|
||||
// A continued conversation still append-saves cleanly on top.
|
||||
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
if err := loaded.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("append snapshot after redacted round-trip: %v", err)
|
||||
}
|
||||
reloaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if got := len(reloaded.Messages); got != 4 {
|
||||
t.Fatalf("message count after append = %d, want 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadLargeMessage(t *testing.T) {
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "run it"})
|
||||
@@ -797,7 +883,7 @@ func TestSaveSnapshotAllowsExactAppendFromStaleRevisionBaseline(t *testing.T) {
|
||||
}
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "two"})
|
||||
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true)
|
||||
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, 0)
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot exact append from stale revision baseline: %v", err)
|
||||
}
|
||||
@@ -845,7 +931,7 @@ func TestSaveSnapshotAllowsCompatibleSystemAppendFromStaleRevisionBaseline(t *te
|
||||
msgs[0] = provider.Message{Role: provider.RoleSystem, Content: "sys v2"}
|
||||
msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: "two"})
|
||||
s.Replace(msgs)
|
||||
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true)
|
||||
s.setPersistedBaseline(path, staleBaseline.digest, staleBaseline.version, staleBaseline.revision, true, 0)
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("SaveSnapshot compatible-system append from stale baseline: %v", err)
|
||||
}
|
||||
@@ -2010,3 +2096,59 @@ func readSessionEventsForTest(t *testing.T, path string) []sessionEventRecord {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestReconcileOverlongMigratesDiagnosticSidecars pins two halves of one fix:
|
||||
// overlong-name reconciliation must skip .events.jsonl / .conflicts.jsonl
|
||||
// sidecars instead of renaming each into a fake session with fabricated meta,
|
||||
// and the owning transcript's rename must carry those sidecars along instead
|
||||
// of orphaning them under the retired stem.
|
||||
func TestReconcileOverlongMigratesDiagnosticSidecars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 236-byte transcript name: past the 224 reconcile bound, while its
|
||||
// .events.jsonl (243) and .conflicts.jsonl (246) still fit under 255 —
|
||||
// exactly the window where the old suffix filter mistook them for
|
||||
// overlong sessions.
|
||||
id := strings.Repeat("s", 230)
|
||||
oldPath := filepath.Join(dir, id+".jsonl")
|
||||
content := `{"role":"system","content":"sys"}` + "\n" + `{"role":"user","content":"hello"}` + "\n"
|
||||
if err := os.WriteFile(oldPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(store.SessionEventLog(oldPath),
|
||||
[]byte(`{"schema_version":1,"type":"replace","messages":[{"role":"user","content":"hello"}]}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(store.SessionConflictLog(oldPath),
|
||||
[]byte(`{"outcome":"forked_recovery_branch"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ReconcileSessionSidecars(dir); err != nil {
|
||||
t.Fatalf("ReconcileSessionSidecars: %v", err)
|
||||
}
|
||||
|
||||
newPath := filepath.Join(dir, recoveryParentStem(id)+".jsonl")
|
||||
if _, err := os.Stat(newPath); err != nil {
|
||||
t.Fatalf("renamed transcript missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventLog(newPath)); err != nil {
|
||||
t.Fatalf("event log not migrated with the rename: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionConflictLog(newPath)); err != nil {
|
||||
t.Fatalf("conflict log not migrated with the rename: %v", err)
|
||||
}
|
||||
for _, gone := range []string{oldPath, store.SessionEventLog(oldPath), store.SessionConflictLog(oldPath)} {
|
||||
if _, err := os.Stat(gone); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s still present under the retired stem (err=%v)", filepath.Base(gone), err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if store.IsSessionTranscriptName(e.Name()) && e.Name() != filepath.Base(newPath) {
|
||||
t.Fatalf("reconcile fabricated an extra session from a sidecar: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
type secretOutputTool struct{}
|
||||
|
||||
func (secretOutputTool) Name() string { return "secret_output" }
|
||||
func (secretOutputTool) Description() string { return "" }
|
||||
func (secretOutputTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (secretOutputTool) ReadOnly() bool { return true }
|
||||
func (secretOutputTool) Execute(context.Context, json.RawMessage) (string, error) {
|
||||
return "DEEPSEEK_API_KEY=sk-real-secret-value-123456\n", nil
|
||||
}
|
||||
|
||||
func TestExecuteOneRedactsToolResultBeforeHistory(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(secretOutputTool{})
|
||||
a := &Agent{tools: reg, session: NewSession("")}
|
||||
|
||||
outcome := a.executeOne(context.Background(), provider.ToolCall{ID: "call_1", Name: "secret_output", Arguments: `{}`})
|
||||
if strings.Contains(outcome.output, "sk-real-secret-value-123456") {
|
||||
t.Fatalf("tool outcome leaked raw secret:\n%s", outcome.output)
|
||||
}
|
||||
if !strings.Contains(outcome.output, "DEEPSEEK_API_KEY=sk-rea") {
|
||||
t.Fatalf("tool outcome missing masked marker:\n%s", outcome.output)
|
||||
}
|
||||
}
|
||||
+40
-16
@@ -18,7 +18,15 @@ type Session struct {
|
||||
Messages []provider.Message
|
||||
version uint64
|
||||
rewriteVersion int // bumped each time the log is rewritten (compact/fold)
|
||||
persisted sessionPersistState
|
||||
// persistedRewriteVersion is the highest rewriteVersion whose transcript
|
||||
// has fully reached disk. It lives on the Session — not on the controller
|
||||
// — so swapping session objects can never orphan or misattribute the
|
||||
// baseline: NeedsRewriteSave always compares a session against its own
|
||||
// save history. Save paths advance it under s.mu with the rewriteVersion
|
||||
// captured alongside the message snapshot, never a re-read one, so a
|
||||
// compaction landing mid-save stays unpersisted.
|
||||
persistedRewriteVersion int
|
||||
persisted sessionPersistState
|
||||
// normalizedDirty is set when LoadSession repaired the history on the way in
|
||||
// (empty tool-call names, dangling calls, truncated args, …). The repair
|
||||
// already lives in Messages, so the next Save persists it automatically as
|
||||
@@ -67,7 +75,7 @@ func (s *Session) Replace(msgs []provider.Message) {
|
||||
// while a turn appends. Frontends (History, Save) use it instead of touching the
|
||||
// live slice.
|
||||
func (s *Session) Snapshot() []provider.Message {
|
||||
msgs, _ := s.snapshotWithVersion()
|
||||
msgs, _, _ := s.snapshotWithVersion()
|
||||
return msgs
|
||||
}
|
||||
|
||||
@@ -97,12 +105,13 @@ func (s *Session) CloneWithMessages(msgs []provider.Message) *Session {
|
||||
version++
|
||||
}
|
||||
return &Session{
|
||||
Messages: append([]provider.Message(nil), msgs...),
|
||||
version: version,
|
||||
rewriteVersion: s.rewriteVersion,
|
||||
persisted: s.persisted,
|
||||
normalizedDirty: s.normalizedDirty,
|
||||
eventLogDamaged: s.eventLogDamaged,
|
||||
Messages: append([]provider.Message(nil), msgs...),
|
||||
version: version,
|
||||
rewriteVersion: s.rewriteVersion,
|
||||
persistedRewriteVersion: s.persistedRewriteVersion,
|
||||
persisted: s.persisted,
|
||||
normalizedDirty: s.normalizedDirty,
|
||||
eventLogDamaged: s.eventLogDamaged,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,19 +133,24 @@ func (s *Session) CloneWithMessagesIfCompatible(msgs []provider.Message) (*Sessi
|
||||
version++
|
||||
}
|
||||
return &Session{
|
||||
Messages: append([]provider.Message(nil), msgs...),
|
||||
version: version,
|
||||
rewriteVersion: s.rewriteVersion,
|
||||
persisted: s.persisted,
|
||||
normalizedDirty: s.normalizedDirty,
|
||||
eventLogDamaged: s.eventLogDamaged,
|
||||
Messages: append([]provider.Message(nil), msgs...),
|
||||
version: version,
|
||||
rewriteVersion: s.rewriteVersion,
|
||||
persistedRewriteVersion: s.persistedRewriteVersion,
|
||||
persisted: s.persisted,
|
||||
normalizedDirty: s.normalizedDirty,
|
||||
eventLogDamaged: s.eventLogDamaged,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *Session) snapshotWithVersion() ([]provider.Message, uint64) {
|
||||
// snapshotWithVersion returns the messages together with the version and
|
||||
// rewriteVersion they were captured under, in one lock window: save paths
|
||||
// persist exactly this rewriteVersion as the new baseline, so a rewrite that
|
||||
// lands after the capture cannot be misrecorded as saved.
|
||||
func (s *Session) snapshotWithVersion() ([]provider.Message, uint64, int) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return append([]provider.Message(nil), s.Messages...), s.version
|
||||
return append([]provider.Message(nil), s.Messages...), s.version, s.rewriteVersion
|
||||
}
|
||||
|
||||
// RewriteVersion returns the current rewrite version.
|
||||
@@ -146,6 +160,16 @@ func (s *Session) RewriteVersion() int {
|
||||
return s.rewriteVersion
|
||||
}
|
||||
|
||||
// NeedsRewriteSave reports whether the history has been rewritten in memory
|
||||
// (compaction, prune) since the last successful full save of this session.
|
||||
// Snapshot paths use it to decide that the next write must be an owned
|
||||
// rewrite instead of an append.
|
||||
func (s *Session) NeedsRewriteSave() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.rewriteVersion > s.persistedRewriteVersion
|
||||
}
|
||||
|
||||
// IncrementRewrite bumps the rewrite version by 1.
|
||||
func (s *Session) IncrementRewrite() {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -585,7 +585,7 @@ func TestSameContentRetryHealsLedgerForSurvivingSaver(t *testing.T) {
|
||||
if err := os.WriteFile(metaPath, staleMeta, 0o644); err != nil {
|
||||
t.Fatalf("rewind meta: %v", err)
|
||||
}
|
||||
s.setPersistedBaseline(path, baseline.digest, baseline.version, baseline.revision, true)
|
||||
s.setPersistedBaseline(path, baseline.digest, baseline.version, baseline.revision, true, 0)
|
||||
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("autosave retry: %v", err)
|
||||
|
||||
@@ -402,3 +402,73 @@ func TestNewSessionPathEmptyModel(t *testing.T) {
|
||||
t.Errorf("empty model should use 'session' fallback: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
// --- rewrite-save baseline ---
|
||||
|
||||
// TestNeedsRewriteSaveFollowsSaves pins the baseline's lifecycle on the
|
||||
// session object itself: an in-memory rewrite demands a rewrite save, every
|
||||
// full save re-anchors (including the plain force Save the depth-cap recovery
|
||||
// path uses), and the baseline never moves backwards when a slower save
|
||||
// reports an older capture.
|
||||
func TestNeedsRewriteSaveFollowsSaves(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
|
||||
if s.NeedsRewriteSave() {
|
||||
t.Fatal("fresh session should not need a rewrite save")
|
||||
}
|
||||
s.IncrementRewrite()
|
||||
if !s.NeedsRewriteSave() {
|
||||
t.Fatal("in-memory rewrite must demand a rewrite save")
|
||||
}
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if s.NeedsRewriteSave() {
|
||||
t.Fatal("force save must re-anchor the rewrite baseline")
|
||||
}
|
||||
s.IncrementRewrite()
|
||||
if err := s.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("SaveRewrite: %v", err)
|
||||
}
|
||||
if s.NeedsRewriteSave() {
|
||||
t.Fatal("SaveRewrite must re-anchor the rewrite baseline")
|
||||
}
|
||||
|
||||
// A slower save that captured an older rewriteVersion must not roll the
|
||||
// baseline back below what a faster save already persisted.
|
||||
digest, err := digestSessionMessages(s.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("digest: %v", err)
|
||||
}
|
||||
s.markPersisted(path, digest, 1, 1, 0)
|
||||
if s.NeedsRewriteSave() {
|
||||
t.Fatal("stale capture rolled the rewrite baseline backwards")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRewriteBaselineStaysWithClones: an unpersisted rewrite travels with the
|
||||
// clone, and the source persisting later does not mark the clone's copy as
|
||||
// saved — each session object owns its own baseline, so no swap can orphan or
|
||||
// misattribute it.
|
||||
func TestRewriteBaselineStaysWithClones(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
|
||||
s.IncrementRewrite()
|
||||
clone := s.CloneWithMessages(s.Snapshot())
|
||||
if !clone.NeedsRewriteSave() {
|
||||
t.Fatal("clone must inherit the unpersisted rewrite")
|
||||
}
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if s.NeedsRewriteSave() {
|
||||
t.Fatal("source baseline not re-anchored by save")
|
||||
}
|
||||
if !clone.NeedsRewriteSave() {
|
||||
t.Fatal("saving the source must not mark the clone's rewrite persisted")
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,10 @@ func (o okTool) Schema() json.RawMessage { retu
|
||||
func (o okTool) ReadOnly() bool { return true }
|
||||
func (o okTool) Execute(context.Context, json.RawMessage) (string, error) { return "ok", nil }
|
||||
|
||||
func warnNoticeRecorder() (event.Sink, *[]string) {
|
||||
func noticeRecorder() (event.Sink, *[]string) {
|
||||
var notices []string
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Notice && e.Level == event.LevelWarn {
|
||||
if e.Kind == event.Notice {
|
||||
notices = append(notices, e.Text)
|
||||
}
|
||||
})
|
||||
@@ -48,13 +48,13 @@ func warnNoticeRecorder() (event.Sink, *[]string) {
|
||||
// TestStormBreakerEscalatesRepeatedFailure: once the same tool has failed the
|
||||
// same way stormBreakThreshold times in a row, the model-facing result must carry
|
||||
// the loop-guard directive (not just the raw error again), and the user must get
|
||||
// a warn notice. The arguments DIFFER on every call — mirroring the live failure
|
||||
// a notice. The arguments DIFFER on every call — mirroring the live failure
|
||||
// mode where a stuck model re-words the payload — to prove detection keys on the
|
||||
// error, not the bytes.
|
||||
func TestStormBreakerEscalatesRepeatedFailure(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(failTool{name: "write_file"})
|
||||
sink, notices := warnNoticeRecorder()
|
||||
sink, notices := noticeRecorder()
|
||||
a := New(nil, reg, NewSession(""), Options{}, sink)
|
||||
|
||||
args := []string{`{"content":"Mountains are`, `{"path":"n.txt","content":"Peaks rise`, `{}`}
|
||||
@@ -74,7 +74,7 @@ func TestStormBreakerEscalatesRepeatedFailure(t *testing.T) {
|
||||
t.Errorf("loop-guard result should still preserve the original error, got: %q", last)
|
||||
}
|
||||
if len(*notices) == 0 {
|
||||
t.Errorf("loop guard should emit a warn notice to the user")
|
||||
t.Errorf("loop guard should emit a notice to the user")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestStormBreakerEscalatesRepeatedFailure(t *testing.T) {
|
||||
func TestStormBreakerEscalatesRepeatedBlockedPermission(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "bash", readOnly: false})
|
||||
sink, notices := warnNoticeRecorder()
|
||||
sink, notices := noticeRecorder()
|
||||
a := New(nil, reg, NewSession(""), Options{
|
||||
Gate: &stubGate{deny: map[string]bool{"bash": true}},
|
||||
}, sink)
|
||||
@@ -109,7 +109,7 @@ func TestStormBreakerEscalatesRepeatedBlockedPermission(t *testing.T) {
|
||||
t.Fatalf("permission loop guard should preserve blocked context, got: %q", last)
|
||||
}
|
||||
if len(*notices) == 0 {
|
||||
t.Errorf("loop guard should emit a warn notice to the user")
|
||||
t.Errorf("loop guard should emit a notice to the user")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestStormBreakerEscalatesAlternatingBlockedShapes(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "bash", readOnly: false})
|
||||
reg.Add(fakeTool{name: "web_fetch", readOnly: false})
|
||||
sink, notices := warnNoticeRecorder()
|
||||
sink, notices := noticeRecorder()
|
||||
a := New(nil, reg, NewSession(""), Options{
|
||||
Gate: &stubGate{deny: map[string]bool{"bash": true, "web_fetch": true}},
|
||||
}, sink)
|
||||
@@ -143,7 +143,7 @@ func TestStormBreakerEscalatesAlternatingBlockedShapes(t *testing.T) {
|
||||
t.Fatal("streak guard should arm the final-readiness loop-guard pass")
|
||||
}
|
||||
if len(*notices) == 0 {
|
||||
t.Errorf("streak loop guard should emit a warn notice to the user")
|
||||
t.Errorf("streak loop guard should emit a notice to the user")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ func TestStormBreakerEscalatesRepeatedBatch(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(failTool{name: "write_a"})
|
||||
reg.Add(failTool{name: "write_b"})
|
||||
sink, notices := warnNoticeRecorder()
|
||||
sink, notices := noticeRecorder()
|
||||
a := New(nil, reg, NewSession(""), Options{}, sink)
|
||||
|
||||
batch := []provider.ToolCall{
|
||||
@@ -202,7 +202,7 @@ func TestStormBreakerEscalatesRepeatedBatch(t *testing.T) {
|
||||
t.Errorf("guard should name the repeated batch, got: %q", first)
|
||||
}
|
||||
if len(*notices) == 0 {
|
||||
t.Errorf("loop guard should emit a warn notice for a repeated batch")
|
||||
t.Errorf("loop guard should emit a notice for a repeated batch")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ func TestStormBreakerBatchResetsOnPartialSuccess(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(failTool{name: "write_file"})
|
||||
reg.Add(okTool{name: "read_file"})
|
||||
sink, notices := warnNoticeRecorder()
|
||||
sink, notices := noticeRecorder()
|
||||
a := New(nil, reg, NewSession(""), Options{}, sink)
|
||||
|
||||
batch := []provider.ToolCall{
|
||||
@@ -229,7 +229,7 @@ func TestStormBreakerBatchResetsOnPartialSuccess(t *testing.T) {
|
||||
t.Fatalf("a batch with a succeeding call should never trip the guard, got: %q", first)
|
||||
}
|
||||
if len(*notices) != 0 {
|
||||
t.Errorf("no warn notice expected when part of the batch succeeds, got %v", *notices)
|
||||
t.Errorf("no notice expected when part of the batch succeeds, got %v", *notices)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ func TestStormBreakerBatchResetsOnPartialSuccess(t *testing.T) {
|
||||
func TestStormBreakerSilentBelowThreshold(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(failTool{name: "write_file"})
|
||||
sink, notices := warnNoticeRecorder()
|
||||
sink, notices := noticeRecorder()
|
||||
a := New(nil, reg, NewSession(""), Options{}, sink)
|
||||
|
||||
call := provider.ToolCall{Name: "write_file", Arguments: `{"content":"x`}
|
||||
@@ -251,7 +251,7 @@ func TestStormBreakerSilentBelowThreshold(t *testing.T) {
|
||||
t.Fatalf("guard fired after only %d repeats (threshold %d)", stormBreakThreshold-1, stormBreakThreshold)
|
||||
}
|
||||
if len(*notices) != 0 {
|
||||
t.Errorf("no warn notice expected below threshold, got %v", *notices)
|
||||
t.Errorf("no notice expected below threshold, got %v", *notices)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestStormBreakerResetsOnSuccess(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(failTool{name: "write_file"})
|
||||
reg.Add(okTool{name: "read_file"})
|
||||
sink, notices := warnNoticeRecorder()
|
||||
sink, notices := noticeRecorder()
|
||||
a := New(nil, reg, NewSession(""), Options{}, sink)
|
||||
|
||||
fail := provider.ToolCall{Name: "write_file", Arguments: `{"content":"x`}
|
||||
@@ -278,6 +278,6 @@ func TestStormBreakerResetsOnSuccess(t *testing.T) {
|
||||
t.Fatalf("guard should have reset after a successful turn, got: %q", last)
|
||||
}
|
||||
if len(*notices) != 0 {
|
||||
t.Errorf("no warn notice expected when a success breaks the run, got %v", *notices)
|
||||
t.Errorf("no notice expected when a success breaks the run, got %v", *notices)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package autoresearch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDirectionFingerprintDistinguishesLongPrefixes: two directions sharing a
|
||||
// >56-char slug prefix used to collapse to one fingerprint, wrongly counting
|
||||
// the second as a repeat and inflating StaleCount toward a forced pivot.
|
||||
func TestDirectionFingerprintDistinguishesLongPrefixes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
task, err := store.CreateTask("Long prefix fingerprints", CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
prefix := "Benchmark the desktop frontend markdown rendering pipeline for "
|
||||
|
||||
progress, err := store.RecordDirection(task.ID, Direction{
|
||||
Summary: prefix + "large tables",
|
||||
AcceptedEvidenceIDs: []string{"f1"},
|
||||
Now: time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordDirection first: %v", err)
|
||||
}
|
||||
if progress.StaleCount != 0 {
|
||||
t.Fatalf("first direction stale = %d, want 0", progress.StaleCount)
|
||||
}
|
||||
|
||||
progress, err = store.RecordDirection(task.ID, Direction{
|
||||
Summary: prefix + "code blocks",
|
||||
AcceptedEvidenceIDs: []string{"f2"},
|
||||
Now: time.Date(2026, 7, 7, 10, 1, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordDirection second: %v", err)
|
||||
}
|
||||
// A genuinely different direction with accepted evidence must not be
|
||||
// counted as a repeat.
|
||||
if progress.StaleCount != 0 {
|
||||
t.Fatalf("distinct long-prefix direction treated as repeat: stale = %d, want 0", progress.StaleCount)
|
||||
}
|
||||
|
||||
// An exact repeat must still be detected.
|
||||
progress, err = store.RecordDirection(task.ID, Direction{
|
||||
Summary: prefix + "code blocks",
|
||||
AcceptedEvidenceIDs: []string{"f3"},
|
||||
Now: time.Date(2026, 7, 7, 10, 2, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordDirection third: %v", err)
|
||||
}
|
||||
if progress.StaleCount != 1 {
|
||||
t.Fatalf("exact repeat not detected: stale = %d, want 1", progress.StaleCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectionFingerprintDistinguishesCJK: directions differing only in CJK
|
||||
// text slugify to the same string ("task") and used to collide.
|
||||
func TestDirectionFingerprintDistinguishesCJK(t *testing.T) {
|
||||
a := directionFingerprint("评测甲方案的渲染性能")
|
||||
b := directionFingerprint("评测乙方案的渲染性能")
|
||||
if a == b {
|
||||
t.Fatalf("CJK-only-diff directions share a fingerprint: %q", a)
|
||||
}
|
||||
if directionFingerprint("评测甲方案的渲染性能") != a {
|
||||
t.Fatal("fingerprint is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectionFingerprintBackwardCompatShortASCII: short ASCII summaries keep
|
||||
// the bare slug so fingerprints recorded by older versions still match.
|
||||
func TestDirectionFingerprintBackwardCompatShortASCII(t *testing.T) {
|
||||
got := directionFingerprint("Profile markdown rendering")
|
||||
want := slugify("Profile markdown rendering")
|
||||
if got != want {
|
||||
t.Fatalf("short ASCII fingerprint = %q, want legacy slug %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordDirectionMigratesLegacyFingerprint: a directions_tried.json entry
|
||||
// written by an older version (bare truncated slug) must still match its own
|
||||
// summary on repeat, not be double-counted as a new direction.
|
||||
func TestRecordDirectionMigratesLegacyFingerprint(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
task, err := store.CreateTask("Legacy fingerprint migration", CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
summary := "Benchmark the desktop frontend markdown rendering pipeline for large tables"
|
||||
|
||||
// Simulate a legacy entry: fingerprint from the old bare slugify.
|
||||
dirPath := filepath.Join(task.Root, "state", "directions_tried.json")
|
||||
legacy := []DirectionTried{{
|
||||
Fingerprint: slugify(summary),
|
||||
Summary: summary,
|
||||
FirstSeenIteration: 1,
|
||||
LastSeenIteration: 1,
|
||||
Count: 1,
|
||||
}}
|
||||
data, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dirPath, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
progress, err := store.RecordDirection(task.ID, Direction{
|
||||
Summary: summary,
|
||||
Now: time.Date(2026, 7, 7, 11, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordDirection: %v", err)
|
||||
}
|
||||
// Repeat of the legacy direction: stale should increment (repeat + no
|
||||
// accepted evidence), and the entry should be migrated, not duplicated.
|
||||
if progress.StaleCount != 1 {
|
||||
t.Fatalf("legacy repeat not detected: stale = %d, want 1", progress.StaleCount)
|
||||
}
|
||||
raw, err := os.ReadFile(dirPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var directions []DirectionTried
|
||||
if err := json.Unmarshal(raw, &directions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(directions) != 1 {
|
||||
t.Fatalf("directions = %+v, want single migrated entry", directions)
|
||||
}
|
||||
if directions[0].Count != 2 {
|
||||
t.Fatalf("migrated count = %d, want 2", directions[0].Count)
|
||||
}
|
||||
if directions[0].Fingerprint != directionFingerprint(summary) {
|
||||
t.Fatalf("entry not migrated to new fingerprint: %q", directions[0].Fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTailJSONLLinesMatchesFullScan: the tail reader must return exactly the
|
||||
// same entries as a full scan for every limit, including limits larger than
|
||||
// the file and files bigger than one read chunk.
|
||||
func TestTailJSONLLinesMatchesFullScan(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
task, err := store.CreateTask("Tail read equivalence", CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
// Write enough heartbeats that the log exceeds one 64KiB chunk.
|
||||
long := strings.Repeat("x", 700)
|
||||
for i := 0; i < 150; i++ {
|
||||
if err := store.AppendHeartbeat(task.ID, Heartbeat{
|
||||
Status: HeartbeatTurnDone,
|
||||
Iteration: i + 1,
|
||||
Message: fmt.Sprintf("turn-%03d %s", i, long),
|
||||
CreatedAt: time.Date(2026, 7, 7, 12, 0, i%60, 0, time.UTC),
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendHeartbeat %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
all, err := store.Heartbeats(task.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Heartbeats(0): %v", err)
|
||||
}
|
||||
if len(all) != 150 {
|
||||
t.Fatalf("full scan = %d heartbeats, want 150", len(all))
|
||||
}
|
||||
for _, limit := range []int{1, 3, 149, 150, 500} {
|
||||
got, err := store.Heartbeats(task.ID, limit)
|
||||
if err != nil {
|
||||
t.Fatalf("Heartbeats(%d): %v", limit, err)
|
||||
}
|
||||
want := all
|
||||
if limit < len(all) {
|
||||
want = all[len(all)-limit:]
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("Heartbeats(%d) = %d entries, want %d", limit, len(got), len(want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i].Iteration != want[i].Iteration {
|
||||
t.Fatalf("Heartbeats(%d)[%d].Iteration = %d, want %d", limit, i, got[i].Iteration, want[i].Iteration)
|
||||
}
|
||||
}
|
||||
}
|
||||
// LastHeartbeat must be the newest entry.
|
||||
last, ok, err := store.LastHeartbeat(task.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LastHeartbeat: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if last.Iteration != 150 {
|
||||
t.Fatalf("LastHeartbeat iteration = %d, want 150", last.Iteration)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindingsBoundedTailMatchesFullScan mirrors the heartbeat check for the
|
||||
// findings log, which desktop views read with a limit.
|
||||
func TestFindingsBoundedTailMatchesFullScan(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewStore(root)
|
||||
task, err := store.CreateTask("Findings tail equivalence", CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
for i := 0; i < 25; i++ {
|
||||
if err := store.AppendFinding(task.ID, Finding{
|
||||
ID: fmt.Sprintf("f%03d", i),
|
||||
Kind: FindingKindManual,
|
||||
Summary: fmt.Sprintf("finding %03d", i),
|
||||
Accepted: i%2 == 0,
|
||||
CreatedAt: time.Date(2026, 7, 7, 13, 0, i%60, 0, time.UTC),
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendFinding %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
all, err := store.Findings(task.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Findings(0): %v", err)
|
||||
}
|
||||
if len(all) != 25 || all[0].ID != "f024" {
|
||||
t.Fatalf("full scan = %d findings first %q, want 25 / f024 (newest first)", len(all), all[0].ID)
|
||||
}
|
||||
got, err := store.Findings(task.ID, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Findings(5): %v", err)
|
||||
}
|
||||
if len(got) != 5 || got[0].ID != "f024" || got[4].ID != "f020" {
|
||||
t.Fatalf("Findings(5) = %+v, want newest five f024..f020", got)
|
||||
}
|
||||
}
|
||||
+169
-18
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -260,16 +261,20 @@ func (s *Store) Findings(taskID string, limit int) ([]Finding, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer storeRoot.Close()
|
||||
path := filepath.Join(taskRel, "state", "findings.jsonl")
|
||||
// Bounded requests (the newest-N views) read only the file tail; limit 0
|
||||
// keeps the full scan because accepted-evidence lookups need every entry.
|
||||
lines, err := tailJSONLLines(storeRoot, path, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var findings []Finding
|
||||
if err := readJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), func(data []byte) error {
|
||||
for _, line := range lines {
|
||||
var f Finding
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return err
|
||||
if err := json.Unmarshal(line, &f); err != nil {
|
||||
return nil, fmt.Errorf("autoresearch: parse %s: %w", path, 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]
|
||||
@@ -307,16 +312,21 @@ func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer storeRoot.Close()
|
||||
path := filepath.Join(taskRel, "logs", "heartbeat.jsonl")
|
||||
// Bounded requests only need the file tail; heartbeat logs grow one line
|
||||
// per turn for the life of a task, so a full scan per read is a per-turn
|
||||
// cost that keeps rising.
|
||||
lines, err := tailJSONLLines(storeRoot, path, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var heartbeats []Heartbeat
|
||||
if err := readJSONL(storeRoot, filepath.Join(taskRel, "logs", "heartbeat.jsonl"), func(data []byte) error {
|
||||
for _, line := range lines {
|
||||
var h Heartbeat
|
||||
if err := json.Unmarshal(data, &h); err != nil {
|
||||
return err
|
||||
if err := json.Unmarshal(line, &h); err != nil {
|
||||
return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err)
|
||||
}
|
||||
heartbeats = append(heartbeats, h)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit > 0 && len(heartbeats) > limit {
|
||||
heartbeats = heartbeats[len(heartbeats)-limit:]
|
||||
@@ -366,15 +376,20 @@ func (s *Store) RecordDirection(taskID string, d Direction) (*Progress, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fp := slugify(d.Summary)
|
||||
fp := directionFingerprint(d.Summary)
|
||||
repeated := false
|
||||
for i := range directions {
|
||||
if directions[i].Fingerprint == fp {
|
||||
repeated = true
|
||||
directions[i].Count++
|
||||
directions[i].LastSeenIteration = progress.Iteration
|
||||
break
|
||||
// Legacy entries carry pre-hash fingerprints; recompute from the
|
||||
// stored summary so repeats recorded by older versions still match,
|
||||
// then migrate the entry in place.
|
||||
if directions[i].Fingerprint != fp && directionFingerprint(directions[i].Summary) != fp {
|
||||
continue
|
||||
}
|
||||
repeated = true
|
||||
directions[i].Fingerprint = fp
|
||||
directions[i].Count++
|
||||
directions[i].LastSeenIteration = progress.Iteration
|
||||
break
|
||||
}
|
||||
if !repeated {
|
||||
directions = append(directions, DirectionTried{
|
||||
@@ -619,6 +634,87 @@ func readJSONL(root *os.Root, path string, each func([]byte) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailJSONLLines returns the last limit non-empty lines of a JSONL file in
|
||||
// file order, reading backward in fixed-size chunks so per-turn readers do not
|
||||
// rescan an append-only log that grows for the life of a task. limit <= 0
|
||||
// reads the whole file (legacy unbounded behavior).
|
||||
func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) {
|
||||
if limit <= 0 {
|
||||
var lines [][]byte
|
||||
if err := readJSONL(root, path, func(data []byte) error {
|
||||
line := make([]byte, len(data))
|
||||
copy(line, data)
|
||||
lines = append(lines, line)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
f, err := root.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("autoresearch: stat %s: %w", path, err)
|
||||
}
|
||||
const chunkSize = 64 * 1024
|
||||
var (
|
||||
buf []byte
|
||||
off = info.Size()
|
||||
)
|
||||
for off > 0 {
|
||||
readLen := int64(chunkSize)
|
||||
if off < readLen {
|
||||
readLen = off
|
||||
}
|
||||
off -= readLen
|
||||
chunk := make([]byte, readLen)
|
||||
if _, err := f.ReadAt(chunk, off); err != nil {
|
||||
return nil, fmt.Errorf("autoresearch: read %s: %w", path, err)
|
||||
}
|
||||
buf = append(chunk, buf...)
|
||||
// Stop once the buffered tail holds enough complete lines. Count
|
||||
// newline-separated non-empty segments after the first newline (the
|
||||
// first segment may be a partial line unless we reached offset 0).
|
||||
if countCompleteTailLines(buf, off == 0) > limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
segments := strings.Split(string(buf), "\n")
|
||||
if off > 0 && len(segments) > 0 {
|
||||
segments = segments[1:] // drop the leading partial line
|
||||
}
|
||||
var lines [][]byte
|
||||
for _, seg := range segments {
|
||||
seg = strings.TrimSpace(seg)
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, []byte(seg))
|
||||
}
|
||||
if len(lines) > limit {
|
||||
lines = lines[len(lines)-limit:]
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func countCompleteTailLines(buf []byte, atStart bool) int {
|
||||
segments := strings.Split(string(buf), "\n")
|
||||
if !atStart && len(segments) > 0 {
|
||||
segments = segments[1:]
|
||||
}
|
||||
count := 0
|
||||
for _, seg := range segments {
|
||||
if strings.TrimSpace(seg) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *Store) loadDirections(root *os.Root, taskRel string) ([]DirectionTried, error) {
|
||||
path := filepath.Join(taskRel, "state", "directions_tried.json")
|
||||
data, err := root.ReadFile(path)
|
||||
@@ -712,3 +808,58 @@ func slugify(s string) string {
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
// directionFingerprint identifies a direction for repeat detection. slugify
|
||||
// alone truncates to 56 chars and drops non-ASCII runes, so two different
|
||||
// directions sharing a long ASCII prefix (or differing only in CJK text)
|
||||
// collapsed to one fingerprint and wrongly inflated StaleCount/PivotCount on
|
||||
// long tasks. Append a hash of the full normalized text when slugify lost
|
||||
// distinguishing content (truncation or non-ASCII letters/digits); keep the
|
||||
// bare slug otherwise so fingerprints recorded by older versions still match,
|
||||
// and punctuation-only differences stay fuzzy-matched as before.
|
||||
func directionFingerprint(summary string) string {
|
||||
slug := slugify(summary)
|
||||
if slug == slugifyUnbounded(summary) && !containsNonASCIIWord(summary) {
|
||||
return slug
|
||||
}
|
||||
normalized := strings.Join(strings.Fields(strings.ToLower(summary)), " ")
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(normalized))
|
||||
return fmt.Sprintf("%s-%08x", slug, h.Sum32())
|
||||
}
|
||||
|
||||
// slugifyUnbounded matches slugify without the 56-char cap, used to detect
|
||||
// whether truncation dropped content.
|
||||
func slugifyUnbounded(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(), "-")
|
||||
if slug == "" {
|
||||
return "task"
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
// containsNonASCIIWord reports whether s carries letters/digits that slugify
|
||||
// discards entirely (e.g. CJK), meaning distinct summaries could share a slug.
|
||||
func containsNonASCIIWord(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+49
-23
@@ -44,6 +44,7 @@ import (
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/secrets"
|
||||
"reasonix/internal/skill"
|
||||
"reasonix/internal/tool"
|
||||
"reasonix/internal/tool/builtin"
|
||||
@@ -146,6 +147,13 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Arm the credential-protection layers from the user-global [secrets]
|
||||
// section before any tool, hook, or plugin subprocess can spawn. Package
|
||||
// globals are correct here because [secrets] is user-global (project
|
||||
// reasonix.toml cannot override it), so concurrent workspaces agree.
|
||||
secrets.SetRedactToolOutput(cfg.SecretsRedactToolOutput())
|
||||
secrets.SetFilterSubprocessEnv(cfg.Secrets.FilterSubprocessEnv)
|
||||
secrets.SetProtectSensitiveFiles(cfg.Secrets.ProtectSensitiveFiles)
|
||||
modelName := opts.Model
|
||||
if modelName == "" {
|
||||
modelName = cfg.DefaultModel
|
||||
@@ -182,21 +190,25 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
ReadOnlyCommands: cfg.Agent.PlanModeReadOnlyCommands,
|
||||
}
|
||||
if ignored := planModePolicy.IgnoredAllowedTools(); len(ignored) > 0 {
|
||||
detail := fmt.Sprintf("plan_mode_allowed_tools ignored known blocked entries: %s; this setting only declares extra read-only custom tools and cannot unlock known blocked tools or unsafe bash. For shell exploration, declare concrete read-only prefixes in plan_mode_read_only_commands (for example \"gh issue view\"); use read_only_task/read_only_skill instead of task/run_skill while planning.", strings.Join(ignored, ", "))
|
||||
sink.Emit(event.Event{
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Text: fmt.Sprintf("plan_mode_allowed_tools ignored known blocked entries: %s; this setting only declares extra read-only custom tools and cannot unlock known blocked tools or unsafe bash. For shell exploration, declare concrete read-only prefixes in plan_mode_read_only_commands (for example \"gh issue view\"); use read_only_task/read_only_skill instead of task/run_skill while planning.", strings.Join(ignored, ", ")),
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Text: "Some plan-mode tool settings were ignored.",
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
if ignored := planModePolicy.IgnoredReadOnlyCommands(); len(ignored) > 0 {
|
||||
detail := fmt.Sprintf("plan_mode_read_only_commands ignored unsafe entries: %s; declare concrete read-only commands such as \"gh issue view\", not shell interpreters, overly broad prefixes, malformed prefixes, or writer-capable command verbs", strings.Join(ignored, ", "))
|
||||
sink.Emit(event.Event{
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Text: fmt.Sprintf("plan_mode_read_only_commands ignored unsafe entries: %s; declare concrete read-only commands such as \"gh issue view\", not shell interpreters, overly broad prefixes, malformed prefixes, or writer-capable command verbs", strings.Join(ignored, ", ")),
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Text: "Some plan-mode command settings were ignored.",
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
if migErr != nil {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "config migration from ~/.reasonix failed: " + migErr.Error()})
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Config migration did not complete.", Detail: "config migration from ~/.reasonix failed: " + migErr.Error()})
|
||||
} else if migrated != nil {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: migrated.Notice()})
|
||||
}
|
||||
@@ -207,7 +219,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
// (RequireKey is false so the UI stays reachable) and then fail silently on the
|
||||
// first request, showing as an empty/dead model. Surface the cause up front.
|
||||
if !opts.RequireKey && entry.RequiresAPIKey() && entry.APIKey() == "" {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Text: fmt.Sprintf("model %q is selected but its API key %s is not set — requests will fail until you set it", modelName, entry.APIKeyEnv)})
|
||||
sink.Emit(event.Event{Kind: event.Notice, Text: "Selected model is missing its API key.", Detail: fmt.Sprintf("model %q is selected but its API key %s is not set — requests will fail until you set it", modelName, entry.APIKeyEnv)})
|
||||
}
|
||||
jm := jobs.NewManager(sink, jobs.WithStalledWarningAfter(time.Duration(cfg.BackgroundJobStalledWarningSeconds())*time.Second))
|
||||
sessionDir := opts.SessionDir
|
||||
@@ -310,6 +322,10 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
reg := tool.NewRegistry()
|
||||
bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: cfg.WriteRootsForRoot(root), ForbidReadRoots: cfg.ForbidReadRootsForRoot(root), Network: cfg.Sandbox.Network}
|
||||
bashSpec.Shell = shell
|
||||
// The session-data guard blocks agent writes into Reasonix's own session
|
||||
// stores (they race the app's saves and surface as conflict-copy loops);
|
||||
// explicit allow_write entries stay a sanctioned escape hatch.
|
||||
sessionGuard := builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots())
|
||||
if bashSpec.Mode == "enforce" && !sandbox.Available() {
|
||||
fmt.Fprintln(stderr, "warning: "+sandbox.UnavailableMessage())
|
||||
}
|
||||
@@ -323,7 +339,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
enabledBuiltins = tokenEconomyBuiltins(enabledBuiltins)
|
||||
}
|
||||
readPathResolver := builtin.NewPathResolver()
|
||||
addBuiltins(reg, enabledBuiltins, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec, cfg.ForbidReadRootsForRoot(root), readPathResolver)
|
||||
addBuiltins(reg, enabledBuiltins, cfg.WriteRootsForRoot(root), bashSpec, bashTimeout, searchSpec, stderr, root, proxySpec, cfg.ForbidReadRootsForRoot(root), readPathResolver, sessionGuard)
|
||||
// Use the caller-supplied shared host when set, so controllers for the same
|
||||
// workspace root reuse running MCP processes (e.g. one CodeGraph daemon
|
||||
// instead of one per tab). Otherwise construct a private host per controller.
|
||||
@@ -431,7 +447,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
}
|
||||
}
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
|
||||
Text: fmt.Sprintf("mcp %s: %v", s.Name, err)})
|
||||
Text: "An MCP server failed to start.", Detail: fmt.Sprintf("mcp %s: %v", s.Name, err)})
|
||||
continue
|
||||
}
|
||||
for _, t := range tools {
|
||||
@@ -448,8 +464,8 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
// controller's session-scoped PluginCtx — so the auxiliary surfaces
|
||||
// keep streaming in after Start returns without holding up the agent.
|
||||
go host.StartPhaseB(ctx, sink)
|
||||
if text, ok := MCPStartupNotice(host.Failures()); ok {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
|
||||
if text, detail, ok := MCPStartupNotice(host.Failures()); ok {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text, Detail: detail})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1134,12 +1150,12 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
||||
ge, ok := cfg.ResolveModel(guardianModel)
|
||||
if !ok {
|
||||
slog.Warn("guardian model is not a configured provider — guardian disabled", "model", guardianModel)
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf("guardian_model %q not found — guardian disabled", guardianModel)})
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Guardian was disabled because its model was not found.", Detail: fmt.Sprintf("guardian_model %q not found — guardian disabled", guardianModel)})
|
||||
} else {
|
||||
pProv, err := NewProviderWithProxy(ge, proxySpec)
|
||||
if err != nil {
|
||||
slog.Warn("guardian provider construction failed — guardian disabled", "model", guardianModel, "err", err)
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf("guardian construction failed: %v — guardian disabled", err)})
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Guardian was disabled because it could not start.", Detail: fmt.Sprintf("guardian construction failed: %v — guardian disabled", err)})
|
||||
} else {
|
||||
guardianReg := agent.FilterReadOnlyRegistry(reg, agent.SubagentMetaTools()...)
|
||||
ctrlOpts.Guardian = guardian.NewSession(pProv, guardianReg, guardian.PolicyPrompt(), guardianModel, cfg.Agent.GuardianTemperature, ge.Price, sink)
|
||||
@@ -1479,12 +1495,14 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
|
||||
// the listed directories.
|
||||
// When workDir is non-empty, tools resolve relative paths against it instead of
|
||||
// the process cwd, enabling concurrent multi-project sessions.
|
||||
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string, proxySpec netclient.ProxySpec, forbidReadRoots []string, readPathResolver *builtin.PathResolver) {
|
||||
// sessionGuard blocks writer-tool targets inside Reasonix's own session stores
|
||||
// and makes bash warn when a command references them.
|
||||
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string, proxySpec netclient.ProxySpec, forbidReadRoots []string, readPathResolver *builtin.PathResolver, sessionGuard builtin.SessionDataGuard) {
|
||||
// If a workspace directory is set, use workspace-bound tools that resolve
|
||||
// paths relative to that directory. Otherwise fall back to the process-cwd
|
||||
// compile-time builtins.
|
||||
if workDir != "" {
|
||||
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec, ReadPaths: readPathResolver}
|
||||
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec, ReadPaths: readPathResolver, SessionGuard: sessionGuard}
|
||||
for _, t := range ws.Tools(enabled...) {
|
||||
reg.Add(t)
|
||||
}
|
||||
@@ -1508,8 +1526,8 @@ func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sand
|
||||
// preserved on replace): file-writers bound to the workspace, read tools
|
||||
// bound to forbid-read roots, bash to the OS sandbox, web_fetch to the proxy.
|
||||
// Only replace tools actually enabled/present.
|
||||
confined := append(builtin.ConfineWriters(writeRoots),
|
||||
builtin.ConfineBash(bashSpec, bashTimeout),
|
||||
confined := append(builtin.ConfineWriters(writeRoots, sessionGuard),
|
||||
builtin.ConfineBash(bashSpec, sessionGuard, bashTimeout),
|
||||
builtin.ConfineSearch(searchSpec, bashSpec, forbidReadRoots),
|
||||
builtin.ConfineWebFetch(proxySpec))
|
||||
confined = append(confined, builtin.ConfineReaders(forbidReadRoots)...)
|
||||
@@ -1733,23 +1751,31 @@ func autoShellPrefer(prefer string) bool {
|
||||
|
||||
// MCPStartupNotice formats the warning shown when configured MCP servers failed
|
||||
// to connect, naming the first few; ok is false when none failed.
|
||||
func MCPStartupNotice(failures []plugin.Failure) (text string, ok bool) {
|
||||
func MCPStartupNotice(failures []plugin.Failure) (text, detail string, ok bool) {
|
||||
if len(failures) == 0 {
|
||||
return "", false
|
||||
return "", "", false
|
||||
}
|
||||
names := make([]string, 0, min(len(failures), 3))
|
||||
details := make([]string, 0, len(failures))
|
||||
for i, f := range failures {
|
||||
if i >= 3 {
|
||||
break
|
||||
continue
|
||||
}
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
for _, f := range failures {
|
||||
line := f.Name
|
||||
if strings.TrimSpace(f.Error) != "" {
|
||||
line += ": " + strings.TrimSpace(f.Error)
|
||||
}
|
||||
details = append(details, line)
|
||||
}
|
||||
more := ""
|
||||
if len(failures) > len(names) {
|
||||
more = fmt.Sprintf(" (+%d more)", len(failures)-len(names))
|
||||
}
|
||||
return fmt.Sprintf("%d MCP server(s) failed to start: %s%s — run /mcp for details",
|
||||
len(failures), strings.Join(names, ", "), more), true
|
||||
return "Some MCP servers failed to start; run /mcp for details.", fmt.Sprintf("%d MCP server(s) failed to start: %s%s\n%s",
|
||||
len(failures), strings.Join(names, ", "), more, strings.Join(details, "\n")), true
|
||||
}
|
||||
|
||||
// LSPSpecs returns the language → server map: the built-in defaults overlaid with
|
||||
|
||||
@@ -1985,12 +1985,15 @@ model = "x"
|
||||
defer ctrl.Close()
|
||||
|
||||
for _, notice := range notices {
|
||||
if notice.Level == event.LevelWarn && strings.Contains(notice.Text, "plan_mode_allowed_tools") && strings.Contains(notice.Text, "bash") {
|
||||
if strings.Contains(notice.Text, "custom_reader") {
|
||||
t.Fatalf("warning should name ignored entries only, got %q", notice.Text)
|
||||
if notice.Level == event.LevelWarn && strings.Contains(notice.Detail, "plan_mode_allowed_tools") && strings.Contains(notice.Detail, "bash") {
|
||||
if notice.Text != "Some plan-mode tool settings were ignored." {
|
||||
t.Fatalf("warning text = %q, want short user-facing text", notice.Text)
|
||||
}
|
||||
if !strings.Contains(notice.Text, "plan_mode_read_only_commands") || !strings.Contains(notice.Text, "read_only_task/read_only_skill") {
|
||||
t.Fatalf("warning should suggest plan-mode migration paths, got %q", notice.Text)
|
||||
if strings.Contains(notice.Detail, "custom_reader") {
|
||||
t.Fatalf("warning should name ignored entries only, got %q", notice.Detail)
|
||||
}
|
||||
if !strings.Contains(notice.Detail, "plan_mode_read_only_commands") || !strings.Contains(notice.Detail, "read_only_task/read_only_skill") {
|
||||
t.Fatalf("warning should suggest plan-mode migration paths, got %q", notice.Detail)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -2033,10 +2036,13 @@ model = "x"
|
||||
defer ctrl.Close()
|
||||
|
||||
for _, notice := range notices {
|
||||
if notice.Level == event.LevelWarn && strings.Contains(notice.Text, "plan_mode_read_only_commands") && strings.Contains(notice.Text, "bash") {
|
||||
ignoredList := strings.TrimSpace(strings.SplitN(strings.TrimPrefix(notice.Text, "plan_mode_read_only_commands ignored unsafe entries:"), ";", 2)[0])
|
||||
if notice.Level == event.LevelWarn && strings.Contains(notice.Detail, "plan_mode_read_only_commands") && strings.Contains(notice.Detail, "bash") {
|
||||
if notice.Text != "Some plan-mode command settings were ignored." {
|
||||
t.Fatalf("warning text = %q, want short user-facing text", notice.Text)
|
||||
}
|
||||
ignoredList := strings.TrimSpace(strings.SplitN(strings.TrimPrefix(notice.Detail, "plan_mode_read_only_commands ignored unsafe entries:"), ";", 2)[0])
|
||||
if ignoredList != "bash" {
|
||||
t.Fatalf("warning should name ignored command prefixes only, got %q from %q", ignoredList, notice.Text)
|
||||
t.Fatalf("warning should name ignored command prefixes only, got %q from %q", ignoredList, notice.Detail)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -2158,7 +2164,7 @@ model = "x"
|
||||
func TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{}, nil, nil)
|
||||
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{}, nil, nil, builtin.SessionDataGuard{})
|
||||
for _, name := range []string{
|
||||
"todo_write",
|
||||
"complete_step",
|
||||
|
||||
@@ -83,11 +83,11 @@ model = "m"
|
||||
api_key_env = "`+keyEnv+`"
|
||||
`)
|
||||
|
||||
var notices []string
|
||||
var notices []event.Event
|
||||
ctrl, err := Build(context.Background(), Options{
|
||||
Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Notice {
|
||||
notices = append(notices, e.Text)
|
||||
notices = append(notices, e)
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -98,7 +98,7 @@ api_key_env = "`+keyEnv+`"
|
||||
|
||||
found := false
|
||||
for _, n := range notices {
|
||||
if strings.Contains(n, keyEnv) {
|
||||
if n.Text == "Selected model is missing its API key." && strings.Contains(n.Detail, keyEnv) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/secrets"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -616,6 +617,7 @@ func searchBotProjectsWithRG(ctx context.Context, rg string, projects []botProje
|
||||
}
|
||||
args = append(args, roots...)
|
||||
cmd := exec.CommandContext(ctx, rg, args...)
|
||||
cmd.Env = secrets.ProcessEnv()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
|
||||
+7
-6
@@ -190,12 +190,13 @@ func (f *acpFactory) SessionConfigState(_ context.Context, p acp.SessionConfigSt
|
||||
func acpBuiltinTools(cfg *config.Config, cwd string, writeRoots []string) []tool.Tool {
|
||||
bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: writeRoots, Network: cfg.Sandbox.Network}
|
||||
ws := builtin.Workspace{
|
||||
Dir: cwd,
|
||||
WriteRoots: writeRoots,
|
||||
Bash: bashSpec,
|
||||
BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
|
||||
Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil),
|
||||
ProxySpec: cfg.NetworkProxySpec(),
|
||||
Dir: cwd,
|
||||
WriteRoots: writeRoots,
|
||||
Bash: bashSpec,
|
||||
BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
|
||||
Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil),
|
||||
ProxySpec: cfg.NetworkProxySpec(),
|
||||
SessionGuard: builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots()),
|
||||
}
|
||||
return ws.Tools(cfg.Tools.Enabled...)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
func (m *chatTUI) showBranchTree() {
|
||||
branches, err := m.ctrl.Branches()
|
||||
m.followSessionLease()
|
||||
if err != nil {
|
||||
m.notice("tree: " + err.Error())
|
||||
return
|
||||
@@ -71,6 +72,7 @@ func (m *chatTUI) runBranchCommand(input string) {
|
||||
return
|
||||
} else if fromTurn {
|
||||
if _, err := m.ctrl.ForkNamed(n-1, name); err != nil {
|
||||
m.followSessionLease()
|
||||
return
|
||||
}
|
||||
m.followSessionLease()
|
||||
@@ -78,6 +80,7 @@ func (m *chatTUI) runBranchCommand(input string) {
|
||||
return
|
||||
} else {
|
||||
if _, err := m.ctrl.Branch(name); err != nil {
|
||||
m.followSessionLease()
|
||||
return
|
||||
}
|
||||
m.followSessionLease()
|
||||
@@ -96,12 +99,15 @@ func (m *chatTUI) runSwitchCommand(input string) {
|
||||
// failures fall through to SwitchBranch, which reports them as before.
|
||||
if m.leases != nil {
|
||||
if branches, err := m.ctrl.Branches(); err == nil {
|
||||
m.followSessionLease()
|
||||
if match, err := control.ResolveBranchRef(branches, ref); err == nil {
|
||||
if err := m.rebindSessionLease(match.Path); err != nil {
|
||||
m.notice("switch: " + sessionLeaseHeldNotice(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m.followSessionLease()
|
||||
}
|
||||
}
|
||||
if _, err := m.ctrl.SwitchBranch(ref); err != nil {
|
||||
|
||||
@@ -365,6 +365,11 @@ const resetMouseTracking = ansi.ResetModeMouseX10 +
|
||||
// snapshots on success.
|
||||
type compactDoneMsg struct{ err error }
|
||||
|
||||
// tuiShutdownMsg asks the live TUI model to persist its current controller and
|
||||
// quit. It is injected from the signal handler so shutdown does not snapshot a
|
||||
// stale controller captured before an in-TUI rebuild.
|
||||
type tuiShutdownMsg struct{}
|
||||
|
||||
// elapsedTickMsg fires once a second while a turn runs, driving the "thinking
|
||||
// Ns" counter in the status line.
|
||||
type elapsedTickMsg struct{}
|
||||
@@ -401,14 +406,20 @@ func (m chatTUI) runStatusline() tea.Cmd {
|
||||
return func() tea.Msg { return statuslineMsg{out: runStatuslineCmd(cmd, string(payload))} }
|
||||
}
|
||||
|
||||
const statuslineCommandTimeout = 2 * time.Second
|
||||
|
||||
// runStatuslineCmd runs a status-line command with the JSON context on stdin and
|
||||
// returns its first stdout line (status lines are a single row). A tight timeout
|
||||
// keeps a slow script from stalling the UI; any failure collapses to "".
|
||||
func runStatuslineCmd(cmd, stdinPayload string) string {
|
||||
return runStatuslineCmdWithTimeout(cmd, stdinPayload, statuslineCommandTimeout)
|
||||
}
|
||||
|
||||
func runStatuslineCmdWithTimeout(cmd, stdinPayload string, timeout time.Duration) string {
|
||||
res := hook.DefaultSpawner(context.Background(), hook.SpawnInput{
|
||||
Command: cmd,
|
||||
Stdin: stdinPayload + "\n",
|
||||
Timeout: 2 * time.Second,
|
||||
Timeout: timeout,
|
||||
})
|
||||
out := strings.TrimSpace(res.Stdout)
|
||||
if i := strings.IndexByte(out, '\n'); i >= 0 {
|
||||
@@ -1358,14 +1369,25 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashCompactFailed, msg.err))
|
||||
} else {
|
||||
_ = m.ctrl.Snapshot()
|
||||
m.followSessionLease()
|
||||
}
|
||||
|
||||
case tuiShutdownMsg:
|
||||
if m.ctrl != nil {
|
||||
_ = m.ctrl.Snapshot()
|
||||
m.followSessionLease()
|
||||
}
|
||||
return m, tea.Quit
|
||||
|
||||
case modelSwitchMsg:
|
||||
m.modelSwitchPending = false
|
||||
m.pendingModelSwitch = nil
|
||||
if msg.err != nil {
|
||||
m.notice("model: " + msg.err.Error())
|
||||
// Build failed — no old controller to retire.
|
||||
// Build failed — no old controller to retire. The kept controller
|
||||
// may still have been retargeted to a recovery branch by the
|
||||
// pre-switch snapshot, so the lease must follow it.
|
||||
m.followSessionLease()
|
||||
} else {
|
||||
m.ctrl = msg.ctrl
|
||||
m.label = msg.label
|
||||
|
||||
+1
-2
@@ -796,8 +796,7 @@ func chatREPL(args []string) int {
|
||||
signal.Notify(hangup, syscall.SIGHUP, syscall.SIGTERM)
|
||||
go func() {
|
||||
for range hangup {
|
||||
_ = ctrl.Snapshot()
|
||||
p.Quit()
|
||||
p.Send(tuiShutdownMsg{})
|
||||
}
|
||||
}()
|
||||
final, runErr := p.Run()
|
||||
|
||||
@@ -262,6 +262,9 @@ func (m *chatTUI) branchArgItems(val string) ([]compItem, int, bool) {
|
||||
return nil, from, true
|
||||
}
|
||||
branches, err := m.ctrl.Branches()
|
||||
// Branches snapshots first, which can retarget the controller to a
|
||||
// recovery branch; keep the lease on whatever the controller now owns.
|
||||
m.followSessionLease()
|
||||
if err != nil {
|
||||
return nil, from, true
|
||||
}
|
||||
|
||||
@@ -491,7 +491,7 @@ func TestSlashArgCompletionMemoryV5(t *testing.T) {
|
||||
if !m.completion.active || m.completion.kind != compSlashArg {
|
||||
t.Fatalf("/memory-v5 should open arg completion: %+v", m.completion)
|
||||
}
|
||||
for _, want := range []string{"status", "off", "observe", "compact", "on"} {
|
||||
for _, want := range []string{"status", "off", "observe", "compact", "on", "learnings"} {
|
||||
if !hasLabel(m.completion.items, want) {
|
||||
t.Fatalf("/memory-v5 completion missing %q: %v", want, labels(m.completion.items))
|
||||
}
|
||||
|
||||
@@ -5,11 +5,18 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/doctor"
|
||||
)
|
||||
|
||||
func doctorCommand(args []string, version string) int {
|
||||
if len(args) > 0 && args[0] == "session" {
|
||||
return doctorSessionCommand(args[1:], version)
|
||||
}
|
||||
if len(args) > 0 && args[0] == "redact-sessions" {
|
||||
return doctorRedactSessionsCommand(args[1:])
|
||||
}
|
||||
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||
jsonOut := fs.Bool("json", false, "print diagnostics as JSON")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -29,3 +36,127 @@ func doctorCommand(args []string, version string) int {
|
||||
fmt.Print(doctor.RenderText(report))
|
||||
return 0
|
||||
}
|
||||
|
||||
type stringListFlag []string
|
||||
|
||||
func (f *stringListFlag) String() string {
|
||||
if f == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(*f, string(os.PathListSeparator))
|
||||
}
|
||||
|
||||
func (f *stringListFlag) Set(value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
*f = append(*f, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func doctorRedactSessionsCommand(args []string) int {
|
||||
fs := flag.NewFlagSet("doctor redact-sessions", flag.ContinueOnError)
|
||||
var dirs stringListFlag
|
||||
dryRun := fs.Bool("dry-run", false, "show how many session files would be redacted without writing")
|
||||
jsonOut := fs.Bool("json", false, "print result as JSON")
|
||||
fs.Var(&dirs, "dir", "session directory to scan; repeat to scan multiple directories")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: reasonix doctor redact-sessions [--dry-run] [--json] [--dir PATH]")
|
||||
return 2
|
||||
}
|
||||
res := doctor.RedactSessions(doctor.RedactSessionsOptions{
|
||||
Dirs: []string(dirs),
|
||||
DryRun: *dryRun,
|
||||
})
|
||||
if *jsonOut {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(res); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
action := "redacted"
|
||||
if *dryRun {
|
||||
action = "would redact"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "session secret cleanup %s %d/%d files", action, res.FilesChanged, res.FilesScanned)
|
||||
if res.FilesSkipped > 0 {
|
||||
fmt.Fprintf(os.Stdout, " (%d skipped: active lease held)", res.FilesSkipped)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout)
|
||||
}
|
||||
for _, msg := range res.Errors {
|
||||
fmt.Fprintln(os.Stderr, "warning:", msg)
|
||||
}
|
||||
if len(res.Errors) > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func doctorSessionCommand(args []string, version string) int {
|
||||
ref := ""
|
||||
outPath := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch arg {
|
||||
case "-h", "--help":
|
||||
fmt.Fprintln(os.Stdout, "usage: reasonix doctor session <branch-id-or-path> [--zip] [--out PATH]")
|
||||
fmt.Fprintln(os.Stdout, "")
|
||||
fmt.Fprintln(os.Stdout, "Bundles the session transcript, persistence sidecars, conflict diagnostics,")
|
||||
fmt.Fprintln(os.Stdout, "and the recovery parent chain into a zip for support. Unlike `reasonix doctor`,")
|
||||
fmt.Fprintln(os.Stdout, "bundled transcripts are NOT redacted; share only with a trusted support channel.")
|
||||
return 0
|
||||
case "--zip":
|
||||
// The subcommand currently writes a zip by default. Keep --zip as an
|
||||
// explicit, script-friendly marker so support replies can say exactly
|
||||
// what to run.
|
||||
case "--out":
|
||||
i++
|
||||
if i >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "error: --out requires a path")
|
||||
return 2
|
||||
}
|
||||
outPath = args[i]
|
||||
default:
|
||||
if v, ok := strings.CutPrefix(arg, "--out="); ok {
|
||||
if v == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --out requires a path")
|
||||
return 2
|
||||
}
|
||||
outPath = v
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
fmt.Fprintf(os.Stderr, "error: unknown doctor session flag %s\n", arg)
|
||||
return 2
|
||||
}
|
||||
if ref != "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: reasonix doctor session <branch-id-or-path> [--zip] [--out PATH]")
|
||||
return 2
|
||||
}
|
||||
ref = arg
|
||||
}
|
||||
}
|
||||
if ref == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: reasonix doctor session <branch-id-or-path> [--zip] [--out PATH]")
|
||||
return 2
|
||||
}
|
||||
result, err := doctor.WriteSessionBundle(doctor.SessionBundleOptions{
|
||||
Version: version,
|
||||
SessionRef: ref,
|
||||
OutputPath: outPath,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(result.Path)
|
||||
fmt.Fprintln(os.Stderr, "note: the bundle contains full session transcripts without redaction; share it only with a trusted support channel")
|
||||
return 0
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -35,6 +36,30 @@ func TestRunDispatchesDoctor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorSessionCommandWritesBundle(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
sessionDir := filepath.Join(home, "sessions")
|
||||
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sessionDir, "abc.jsonl"), []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outPath := filepath.Join(t.TempDir(), "abc-diag.zip")
|
||||
out := captureStdout(t, func() {
|
||||
if rc := doctorCommand([]string{"session", "abc", "--zip", "--out", outPath}, "test-version"); rc != 0 {
|
||||
t.Fatalf("doctor session rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
if strings.TrimSpace(out) != outPath {
|
||||
t.Fatalf("doctor session output = %q, want %q", strings.TrimSpace(out), outPath)
|
||||
}
|
||||
if info, err := os.Stat(outPath); err != nil || info.Size() == 0 {
|
||||
t.Fatalf("bundle stat = %v, %v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
@@ -55,3 +80,52 @@ func captureStdout(t *testing.T, fn func()) string {
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestDoctorSessionCommandOutEqualsForm(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
sessionDir := filepath.Join(home, "sessions")
|
||||
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sessionDir, "abc.jsonl"), []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outPath := filepath.Join(t.TempDir(), "abc-diag.zip")
|
||||
out := captureStdout(t, func() {
|
||||
if rc := doctorCommand([]string{"session", "abc", "--out=" + outPath}, "test-version"); rc != 0 {
|
||||
t.Fatalf("doctor session rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
if strings.TrimSpace(out) != outPath {
|
||||
t.Fatalf("doctor session output = %q, want %q", strings.TrimSpace(out), outPath)
|
||||
}
|
||||
if rc := doctorCommand([]string{"session", "abc", "--out="}, "test-version"); rc != 2 {
|
||||
t.Fatalf("empty --out= rc = %d, want usage error 2", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRedactSessionsCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
const secret = "sk-real-secret-value-123456"
|
||||
path := filepath.Join(dir, "abc.jsonl")
|
||||
if err := os.WriteFile(path, []byte(`{"role":"tool","content":"DEEPSEEK_API_KEY=`+secret+`"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
if rc := doctorCommand([]string{"redact-sessions", "--dir", dir}, "test-version"); rc != 0 {
|
||||
t.Fatalf("doctor redact-sessions rc = %d, want 0", rc)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "session secret cleanup redacted 1/1 files") {
|
||||
t.Fatalf("unexpected output:\n%s", out)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), secret) {
|
||||
t.Fatalf("doctor redact-sessions leaked secret:\n%s", data)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,14 @@ func (m *chatTUI) runEffortCommand(input string) tea.Cmd {
|
||||
// controller back to the original file, re-conflicting on every later save.
|
||||
carried := m.ctrl.History()
|
||||
prevPath := m.ctrl.SessionPath()
|
||||
// Move the lease before the rebuilt controller binds prevPath for writing
|
||||
// (AdoptHistory resumes there): after a snapshot retarget the lease still
|
||||
// guards the old path, and the async build must not open an unguarded
|
||||
// writer on the recovery branch.
|
||||
if err := m.rebindSessionLease(prevPath); err != nil {
|
||||
m.notice("effort: " + sessionLeaseHeldNotice(err))
|
||||
return nil
|
||||
}
|
||||
oldCtrl := m.ctrl
|
||||
build := m.buildController
|
||||
m.modelSwitchPending = true
|
||||
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user